浏览代码

feat: complete ontology workbench lifecycle

马小龙 3 月之前
父节点
当前提交
68d556bf8e
共有 35 个文件被更改,包括 2402 次插入 和 67 次删除
  1. 101 7
      app/api/data_development/routes.py
  2. 5 1
      app/core/data_research/data_elements.py
  3. 4 1
      app/core/data_research/ontology/change_sets.py
  4. 9 0
      app/core/data_research/ontology/publication.py
  5. 26 0
      app/core/data_research/ontology/repository.py
  6. 15 0
      app/core/data_research/repository.py
  7. 9 0
      app/core/system/permissions.py
  8. 101 7
      deployment/app/api/data_development/routes.py
  9. 5 1
      deployment/app/core/data_research/data_elements.py
  10. 4 1
      deployment/app/core/data_research/ontology/change_sets.py
  11. 9 0
      deployment/app/core/data_research/ontology/publication.py
  12. 26 0
      deployment/app/core/data_research/ontology/repository.py
  13. 15 0
      deployment/app/core/data_research/repository.py
  14. 9 0
      deployment/app/core/system/permissions.py
  15. 93 1
      docs/architecture/OPENAPI.yaml
  16. 213 0
      docs/superpowers/plans/2026-07-23-ontology-workbench-complete-chain.md
  17. 27 4
      docs/validation/data-research-v60-v65-acceptance.md
  18. 24 1
      frontend/src/api/dataDevelopment.js
  19. 1 1
      frontend/src/router/routes.js
  20. 16 3
      frontend/src/utils/request.js
  21. 252 0
      frontend/src/views/dataGovernance/ontology/components/GraphSectionEditor.vue
  22. 142 0
      frontend/src/views/dataGovernance/ontology/components/SuggestionReviewDialog.vue
  23. 133 0
      frontend/src/views/dataGovernance/ontology/components/VersionWorkspace.vue
  24. 82 5
      frontend/src/views/dataGovernance/ontology/index.vue
  25. 86 0
      frontend/src/views/dataGovernance/ontology/model.js
  26. 597 19
      frontend/src/views/dataGovernance/ontology/workbench.vue
  27. 80 0
      frontend/tests/ontology-workbench-model.test.mjs
  28. 17 1
      tests/data_research/test_data_element_api.py
  29. 39 0
      tests/data_research/test_data_element_lifecycle.py
  30. 39 2
      tests/data_research/test_ontology_api.py
  31. 43 3
      tests/data_research/test_ontology_dynamic_api.py
  32. 75 4
      tests/data_research/test_ontology_frontend_contract.py
  33. 27 0
      tests/data_research/test_ontology_repository.py
  34. 64 5
      tests/integration/test_data_research_docker_api.py
  35. 14 0
      tests/test_permission_matrix.py

+ 101 - 7
app/api/data_development/routes.py

@@ -318,6 +318,25 @@ def cancel_ingestion_job(job_uid):
         return _error(error)
 
 
+@bp.route("/data-elements", methods=["GET"])
+def list_data_elements():
+    try:
+        records = get_data_element_service().list(
+            status=request.args.get("status"),
+            business_domain_uid=request.args.get("business_domain_uid"),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [_element(item) for item in records],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:
@@ -433,6 +452,66 @@ def create_ontology():
         return _error(error)
 
 
+@bp.route("/ontologies/<ontology_uid>", methods=["GET"])
+def get_ontology(ontology_uid):
+    try:
+        return jsonify(success(_ontology(get_ontology_service().get(ontology_uid)))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/versions", methods=["GET"])
+def list_ontology_versions(ontology_uid):
+    try:
+        return jsonify(
+            success(
+                [
+                    _ontology_version(item)
+                    for item in get_ontology_service().list_versions(ontology_uid)
+                ]
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/graph", methods=["GET"])
+def get_ontology_graph(ontology_uid):
+    try:
+        service = get_ontology_service()
+        ontology = service.get(ontology_uid)
+        version = service.latest_version(ontology_uid)
+        data = (
+            _ontology_version(version)
+            if version is not None
+            else {
+                "uid": None,
+                "ontology_uid": str(ontology_uid),
+                "version": 0,
+                "parent_version_uid": None,
+                "status": "draft",
+                "content_hash": None,
+                "graph_document": {
+                    name: []
+                    for name in (
+                        "classes",
+                        "properties",
+                        "relations",
+                        "constraints",
+                        "domain_links",
+                        "element_mappings",
+                    )
+                },
+                "created_by": None,
+            }
+        )
+        response = jsonify(success(data))
+        response.headers["ETag"] = f'"{int(ontology.draft_revision)}"'
+        return response, 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/ontologies/<ontology_uid>/graph", methods=["PATCH"])
 def save_ontology_graph(ontology_uid):
     etag = str(request.headers.get("If-Match") or "").strip().strip('"')
@@ -460,7 +539,7 @@ def validate_ontology(ontology_uid):
             {"code": item.code, "message": item.message, "path": item.path}
             for item in issues
         ]
-        return jsonify(success(data)), 422 if data else 200
+        return jsonify(success(data)), 200
     except Exception as error:
         return _error(error)
 
@@ -506,13 +585,20 @@ def rollback_ontology(ontology_uid):
 @bp.route("/ontologies/<ontology_uid>/suggestions", methods=["POST"])
 def generate_ontology_suggestions(ontology_uid):
     try:
-        records = get_ontology_dynamic_service().generate(
+        result = get_ontology_dynamic_service().generate(
             ontology_uid,
             request.get_json(silent=True) or {},
             _identity().get("id") or _identity().get("sub"),
         )
-        data = [
-            {
+        records = (
+            result.get("suggestions") or []
+            if isinstance(result, dict)
+            else result
+        )
+        suggestions = [
+            item
+            if isinstance(item, dict)
+            else {
                 "uid": item.uid,
                 "kind": item.kind,
                 "payload": item.payload,
@@ -522,10 +608,16 @@ def generate_ontology_suggestions(ontology_uid):
                 "model_version": item.model_version,
                 "prompt_version": item.prompt_version,
             }
-            if not isinstance(item, dict)
-            else item
             for item in records
         ]
+        data = {
+            "change_set_uid": (
+                result.get("change_set_uid")
+                if isinstance(result, dict)
+                else None
+            ),
+            "suggestions": suggestions,
+        }
         return jsonify(success(data)), 201
     except Exception as error:
         return _error(error)
@@ -568,8 +660,10 @@ def export_ontology(ontology_uid):
 @bp.route("/ontologies/import", methods=["POST"])
 def import_ontology():
     try:
+        uploaded = request.files.get("file")
+        content = uploaded.read() if uploaded is not None else request.get_data(cache=False)
         result = get_ontology_exchange_service().import_document(
-            request.get_data(cache=False),
+            content,
             request.args.get("format") or "json",
             _identity().get("id") or _identity().get("sub"),
         )

+ 5 - 1
app/core/data_research/data_elements.py

@@ -51,6 +51,11 @@ class DataElementService:
         self.uid_factory = uid_factory
         self.outbox_enqueue = outbox_enqueue
 
+    def list(self, *, status=None, business_domain_uid=None):
+        return self.repository.list(
+            status=status, business_domain_uid=business_domain_uid
+        )
+
     def create_draft(self, payload, actor_uid):
         if not isinstance(payload, dict):
             raise DataElementInvalid("payload must be an object")
@@ -115,4 +120,3 @@ class DataElementService:
                 payload={"uid": updated.uid, "version": updated.current_version},
             )
         return updated
-

+ 4 - 1
app/core/data_research/ontology/change_sets.py

@@ -131,7 +131,10 @@ class SqlAlchemyDynamicOntologyService:
             )
         )
         self.session.commit()
-        return suggestions
+        return {
+            "change_set_uid": change_set.uid,
+            "suggestions": suggestions,
+        }
 
     def decide(self, ontology_uid, change_set_uid, decisions, actor_uid):
         from app.core.data_research.ontology.suggestions import Suggestion

+ 9 - 0
app/core/data_research/ontology/publication.py

@@ -100,6 +100,15 @@ class OntologyApplicationService:
     def list(self):
         return self.repository.list()
 
+    def get(self, uid):
+        return self.repository.get(uid)
+
+    def list_versions(self, uid):
+        return self.repository.list_versions(uid)
+
+    def latest_version(self, uid):
+        return self.repository.latest_version(uid)
+
     def create(self, payload, actor_uid):
         try:
             ontology = self.repository.create(

+ 26 - 0
app/core/data_research/ontology/repository.py

@@ -57,6 +57,21 @@ class MemoryOntologyRepository:
     def get_version(self, uid):
         return self.versions.get(str(uid))
 
+    def list_versions(self, ontology_uid):
+        return sorted(
+            (
+                item
+                for item in self.versions.values()
+                if item.ontology_uid == str(ontology_uid)
+            ),
+            key=lambda item: item.version,
+            reverse=True,
+        )
+
+    def latest_version(self, ontology_uid):
+        versions = self.list_versions(ontology_uid)
+        return versions[0] if versions else None
+
     def save_draft(self, ontology_uid, graph, *, expected_revision, actor_uid):
         ontology = self.get(ontology_uid)
         if ontology is None:
@@ -193,6 +208,17 @@ class SqlAlchemyOntologyRepository:
         ).order_by(OntologyVersionModel.version.desc()).first()
         return self._version(model) if model is not None else None
 
+    def list_versions(self, ontology_uid):
+        from app.models.data_research import OntologyVersionModel
+
+        models = (
+            self.session.query(OntologyVersionModel)
+            .filter_by(ontology_uid=str(ontology_uid))
+            .order_by(OntologyVersionModel.version.desc())
+            .all()
+        )
+        return [self._version(model) for model in models]
+
     def save_draft(self, ontology_uid, graph, *, expected_revision, actor_uid):
         from app.models.data_research import OntologyModel, OntologyVersionModel
 

+ 15 - 0
app/core/data_research/repository.py

@@ -127,6 +127,21 @@ class SqlAlchemyDataElementRepository:
         model = self.session.query(DataElement).filter_by(code=str(code)).first()
         return self._record(model) if model is not None else None
 
+    def list(self, *, status=None, business_domain_uid=None):
+        query = self.session.query(DataElement)
+        if status:
+            query = query.filter(DataElement.status == str(status))
+        if business_domain_uid:
+            query = query.filter(
+                DataElement.business_domain_uids.contains(
+                    [str(business_domain_uid)]
+                )
+            )
+        return [
+            self._record(model)
+            for model in query.order_by(DataElement.code.asc()).all()
+        ]
+
     def save(self, record):
         model = self.session.get(DataElement, str(record.uid))
         if model is None:

+ 9 - 0
app/core/system/permissions.py

@@ -91,6 +91,15 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/publish") or path.endswith("/rollback"):
             return (ONTOLOGIES_PUBLISH,)
         return (ONTOLOGIES_EDIT,)
+    if path.startswith("/api/bd/"):
+        if path in {
+            "/api/bd/list",
+            "/api/bd/detail",
+            "/api/bd/graphall",
+            "/api/bd/search",
+        }:
+            return (READ_GOVERNANCE,)
+        return (EDIT_GOVERNANCE,)
     if path.startswith("/api/system/workbench"):
         return (READ_GOVERNANCE,)
     if (

+ 101 - 7
deployment/app/api/data_development/routes.py

@@ -318,6 +318,25 @@ def cancel_ingestion_job(job_uid):
         return _error(error)
 
 
+@bp.route("/data-elements", methods=["GET"])
+def list_data_elements():
+    try:
+        records = get_data_element_service().list(
+            status=request.args.get("status"),
+            business_domain_uid=request.args.get("business_domain_uid"),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [_element(item) for item in records],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/data-elements", methods=["POST"])
 def create_data_element():
     try:
@@ -433,6 +452,66 @@ def create_ontology():
         return _error(error)
 
 
+@bp.route("/ontologies/<ontology_uid>", methods=["GET"])
+def get_ontology(ontology_uid):
+    try:
+        return jsonify(success(_ontology(get_ontology_service().get(ontology_uid)))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/versions", methods=["GET"])
+def list_ontology_versions(ontology_uid):
+    try:
+        return jsonify(
+            success(
+                [
+                    _ontology_version(item)
+                    for item in get_ontology_service().list_versions(ontology_uid)
+                ]
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/graph", methods=["GET"])
+def get_ontology_graph(ontology_uid):
+    try:
+        service = get_ontology_service()
+        ontology = service.get(ontology_uid)
+        version = service.latest_version(ontology_uid)
+        data = (
+            _ontology_version(version)
+            if version is not None
+            else {
+                "uid": None,
+                "ontology_uid": str(ontology_uid),
+                "version": 0,
+                "parent_version_uid": None,
+                "status": "draft",
+                "content_hash": None,
+                "graph_document": {
+                    name: []
+                    for name in (
+                        "classes",
+                        "properties",
+                        "relations",
+                        "constraints",
+                        "domain_links",
+                        "element_mappings",
+                    )
+                },
+                "created_by": None,
+            }
+        )
+        response = jsonify(success(data))
+        response.headers["ETag"] = f'"{int(ontology.draft_revision)}"'
+        return response, 200
+    except Exception as error:
+        return _error(error)
+
+
 @bp.route("/ontologies/<ontology_uid>/graph", methods=["PATCH"])
 def save_ontology_graph(ontology_uid):
     etag = str(request.headers.get("If-Match") or "").strip().strip('"')
@@ -460,7 +539,7 @@ def validate_ontology(ontology_uid):
             {"code": item.code, "message": item.message, "path": item.path}
             for item in issues
         ]
-        return jsonify(success(data)), 422 if data else 200
+        return jsonify(success(data)), 200
     except Exception as error:
         return _error(error)
 
@@ -506,13 +585,20 @@ def rollback_ontology(ontology_uid):
 @bp.route("/ontologies/<ontology_uid>/suggestions", methods=["POST"])
 def generate_ontology_suggestions(ontology_uid):
     try:
-        records = get_ontology_dynamic_service().generate(
+        result = get_ontology_dynamic_service().generate(
             ontology_uid,
             request.get_json(silent=True) or {},
             _identity().get("id") or _identity().get("sub"),
         )
-        data = [
-            {
+        records = (
+            result.get("suggestions") or []
+            if isinstance(result, dict)
+            else result
+        )
+        suggestions = [
+            item
+            if isinstance(item, dict)
+            else {
                 "uid": item.uid,
                 "kind": item.kind,
                 "payload": item.payload,
@@ -522,10 +608,16 @@ def generate_ontology_suggestions(ontology_uid):
                 "model_version": item.model_version,
                 "prompt_version": item.prompt_version,
             }
-            if not isinstance(item, dict)
-            else item
             for item in records
         ]
+        data = {
+            "change_set_uid": (
+                result.get("change_set_uid")
+                if isinstance(result, dict)
+                else None
+            ),
+            "suggestions": suggestions,
+        }
         return jsonify(success(data)), 201
     except Exception as error:
         return _error(error)
@@ -568,8 +660,10 @@ def export_ontology(ontology_uid):
 @bp.route("/ontologies/import", methods=["POST"])
 def import_ontology():
     try:
+        uploaded = request.files.get("file")
+        content = uploaded.read() if uploaded is not None else request.get_data(cache=False)
         result = get_ontology_exchange_service().import_document(
-            request.get_data(cache=False),
+            content,
             request.args.get("format") or "json",
             _identity().get("id") or _identity().get("sub"),
         )

+ 5 - 1
deployment/app/core/data_research/data_elements.py

@@ -51,6 +51,11 @@ class DataElementService:
         self.uid_factory = uid_factory
         self.outbox_enqueue = outbox_enqueue
 
+    def list(self, *, status=None, business_domain_uid=None):
+        return self.repository.list(
+            status=status, business_domain_uid=business_domain_uid
+        )
+
     def create_draft(self, payload, actor_uid):
         if not isinstance(payload, dict):
             raise DataElementInvalid("payload must be an object")
@@ -115,4 +120,3 @@ class DataElementService:
                 payload={"uid": updated.uid, "version": updated.current_version},
             )
         return updated
-

+ 4 - 1
deployment/app/core/data_research/ontology/change_sets.py

@@ -131,7 +131,10 @@ class SqlAlchemyDynamicOntologyService:
             )
         )
         self.session.commit()
-        return suggestions
+        return {
+            "change_set_uid": change_set.uid,
+            "suggestions": suggestions,
+        }
 
     def decide(self, ontology_uid, change_set_uid, decisions, actor_uid):
         from app.core.data_research.ontology.suggestions import Suggestion

+ 9 - 0
deployment/app/core/data_research/ontology/publication.py

@@ -100,6 +100,15 @@ class OntologyApplicationService:
     def list(self):
         return self.repository.list()
 
+    def get(self, uid):
+        return self.repository.get(uid)
+
+    def list_versions(self, uid):
+        return self.repository.list_versions(uid)
+
+    def latest_version(self, uid):
+        return self.repository.latest_version(uid)
+
     def create(self, payload, actor_uid):
         try:
             ontology = self.repository.create(

+ 26 - 0
deployment/app/core/data_research/ontology/repository.py

@@ -57,6 +57,21 @@ class MemoryOntologyRepository:
     def get_version(self, uid):
         return self.versions.get(str(uid))
 
+    def list_versions(self, ontology_uid):
+        return sorted(
+            (
+                item
+                for item in self.versions.values()
+                if item.ontology_uid == str(ontology_uid)
+            ),
+            key=lambda item: item.version,
+            reverse=True,
+        )
+
+    def latest_version(self, ontology_uid):
+        versions = self.list_versions(ontology_uid)
+        return versions[0] if versions else None
+
     def save_draft(self, ontology_uid, graph, *, expected_revision, actor_uid):
         ontology = self.get(ontology_uid)
         if ontology is None:
@@ -193,6 +208,17 @@ class SqlAlchemyOntologyRepository:
         ).order_by(OntologyVersionModel.version.desc()).first()
         return self._version(model) if model is not None else None
 
+    def list_versions(self, ontology_uid):
+        from app.models.data_research import OntologyVersionModel
+
+        models = (
+            self.session.query(OntologyVersionModel)
+            .filter_by(ontology_uid=str(ontology_uid))
+            .order_by(OntologyVersionModel.version.desc())
+            .all()
+        )
+        return [self._version(model) for model in models]
+
     def save_draft(self, ontology_uid, graph, *, expected_revision, actor_uid):
         from app.models.data_research import OntologyModel, OntologyVersionModel
 

+ 15 - 0
deployment/app/core/data_research/repository.py

@@ -127,6 +127,21 @@ class SqlAlchemyDataElementRepository:
         model = self.session.query(DataElement).filter_by(code=str(code)).first()
         return self._record(model) if model is not None else None
 
+    def list(self, *, status=None, business_domain_uid=None):
+        query = self.session.query(DataElement)
+        if status:
+            query = query.filter(DataElement.status == str(status))
+        if business_domain_uid:
+            query = query.filter(
+                DataElement.business_domain_uids.contains(
+                    [str(business_domain_uid)]
+                )
+            )
+        return [
+            self._record(model)
+            for model in query.order_by(DataElement.code.asc()).all()
+        ]
+
     def save(self, record):
         model = self.session.get(DataElement, str(record.uid))
         if model is None:

+ 9 - 0
deployment/app/core/system/permissions.py

@@ -91,6 +91,15 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if path.endswith("/publish") or path.endswith("/rollback"):
             return (ONTOLOGIES_PUBLISH,)
         return (ONTOLOGIES_EDIT,)
+    if path.startswith("/api/bd/"):
+        if path in {
+            "/api/bd/list",
+            "/api/bd/detail",
+            "/api/bd/graphall",
+            "/api/bd/search",
+        }:
+            return (READ_GOVERNANCE,)
+        return (EDIT_GOVERNANCE,)
     if path.startswith("/api/system/workbench"):
         return (READ_GOVERNANCE,)
     if (

+ 93 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 138
+x-route-count: 142
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -1669,6 +1669,24 @@ paths:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/data-elements":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_data_elements_get
+      summary: "list data elements"
+      x-source: "app/api/data_development/routes.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
     post:
       tags: [data_development]
       operationId: data_development_create_data_element_post
@@ -1954,6 +1972,31 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/ontologies/{ontology_uid}":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_ontology_get
+      summary: "get ontology"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: ontology_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/ontologies/{ontology_uid}/change-sets/{change_set_uid}/decisions":
     post:
       tags: [data_development]
@@ -2042,6 +2085,30 @@ paths:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/ontologies/{ontology_uid}/graph":
+    get:
+      tags: [data_development]
+      operationId: data_development_get_ontology_graph_get
+      summary: "get ontology graph"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: ontology_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
     patch:
       tags: [data_development]
       operationId: data_development_save_ontology_graph_patch
@@ -2201,6 +2268,31 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/development/v1/ontologies/{ontology_uid}/versions":
+    get:
+      tags: [data_development]
+      operationId: data_development_list_ontology_versions_get
+      summary: "list ontology versions"
+      x-source: "app/api/data_development/routes.py"
+      parameters:
+        - name: ontology_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/development/v1/semantic/properties/{property_uid}":
     get:
       tags: [data_development]

+ 213 - 0
docs/superpowers/plans/2026-07-23-ontology-workbench-complete-chain.md

@@ -0,0 +1,213 @@
+# Ontology Workbench Complete Chain Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将现有“新建本体 + JSON 文本框”升级为可加载、可编辑、可校验、可比较、可回滚、可评审建议、可导入导出的完整本体治理工作台。
+
+**Architecture:** PostgreSQL 继续作为本体、版本、变更集和发布状态的权威控制面;现有发布服务继续通过 Outbox 投影到 Neo4j。后端补充只读详情、版本、草稿和数据元素查询契约,并让动态建议返回可评审的变更集 UID;前端使用 Vue 2 + Vuetify 结构化编辑六类图数据,同时保留 JSON 高级模式。
+
+**Tech Stack:** Flask、SQLAlchemy、PostgreSQL、Vue 2、Vuetify 2、Axios、Python pytest、Node 24、Docker Compose。
+
+## Global Constraints
+
+- 不新增前端框架或第三方 UI 依赖,复用现有 Vuetify 与 MDI。
+- 已发布版本不可原地修改;编辑始终创建新草稿版本。
+- 草稿保存必须使用 `If-Match` 修订号,冲突时重新加载而不是静默覆盖。
+- `viewer` 只读,`editor` 可编辑/校验/建议评审,`admin` 才能发布和回滚。
+- 动态建议必须显示来源、置信度和证据,并经过逐项接受/拒绝后才应用到草稿。
+- 导入支持 JSON/OWL 文件;导出支持 JSON/OWL 下载。
+- 所有生产代码先有失败测试,再做最小实现。
+
+---
+
+### Task 1: 本体详情、版本与草稿读取契约
+
+**Files:**
+- Modify: `app/core/data_research/ontology/repository.py`
+- Modify: `app/core/data_research/ontology/publication.py`
+- Modify: `app/api/data_development/routes.py`
+- Test: `tests/data_research/test_ontology_api.py`
+- Test: `tests/data_research/test_ontology_repository.py`
+
+**Interfaces:**
+- Produces: `repository.list_versions(ontology_uid) -> list[OntologyVersion]`
+- Produces: `GET /api/development/v1/ontologies/<uid>`
+- Produces: `GET /api/development/v1/ontologies/<uid>/versions`
+- Produces: `GET /api/development/v1/ontologies/<uid>/graph` with `ETag`
+
+- [ ] **Step 1: Write failing repository and API tests**
+
+```python
+def test_detail_versions_and_graph_are_readable(client):
+    detail = client.get("/api/development/v1/ontologies/ontology-1")
+    versions = client.get("/api/development/v1/ontologies/ontology-1/versions")
+    graph = client.get("/api/development/v1/ontologies/ontology-1/graph")
+    assert detail.status_code == 200
+    assert versions.get_json()["data"][0]["graph_document"]["classes"]
+    assert graph.headers["ETag"] == '"2"'
+```
+
+- [ ] **Step 2: Run tests and verify missing routes fail**
+
+Run: `PYTHONPATH=. .venv/bin/pytest -q tests/data_research/test_ontology_api.py tests/data_research/test_ontology_repository.py`
+
+Expected: FAIL because detail/version/GET graph contracts do not exist.
+
+- [ ] **Step 3: Implement repository/service/routes**
+
+Return ontology metadata with latest/active version summaries; return versions newest first; return the latest graph plus the ontology draft revision in `ETag`.
+
+- [ ] **Step 4: Run tests and verify green**
+
+Run: `PYTHONPATH=. .venv/bin/pytest -q tests/data_research/test_ontology_api.py tests/data_research/test_ontology_repository.py`
+
+Expected: PASS.
+
+### Task 2: 可选择的数据元素与动态建议变更集契约
+
+**Files:**
+- Modify: `app/core/data_research/repository.py`
+- Modify: `app/core/data_research/data_elements.py`
+- Modify: `app/core/data_research/ontology/change_sets.py`
+- Modify: `app/api/data_development/routes.py`
+- Test: `tests/data_research/test_data_element_lifecycle.py`
+- Test: `tests/data_research/test_ontology_dynamic_api.py`
+
+**Interfaces:**
+- Produces: `GET /api/development/v1/data-elements?status=published&business_domain_uid=<uid>`
+- Produces: suggestion response `{change_set_uid, suggestions}`
+- Consumes: decision payload `{decisions: [{suggestion_uid, decision, reason, payload?}]}`
+
+- [ ] **Step 1: Write failing list and change-set response tests**
+- [ ] **Step 2: Run focused tests and verify expected failures**
+- [ ] **Step 3: Add filtered repository listing and return persisted change-set UID**
+- [ ] **Step 4: Run focused tests and verify green**
+
+Run: `PYTHONPATH=. .venv/bin/pytest -q tests/data_research/test_data_element_lifecycle.py tests/data_research/test_ontology_dynamic_api.py`
+
+Expected: PASS.
+
+### Task 3: 前端状态模型与 API 客户端
+
+**Files:**
+- Create: `frontend/src/views/dataGovernance/ontology/model.js`
+- Modify: `frontend/src/api/dataDevelopment.js`
+- Test: `frontend/tests/ontology-workbench-model.test.mjs`
+- Test: `tests/test_frontend_contract.py`
+
+**Interfaces:**
+- Produces: `emptyGraph()`, `normalizeGraph()`, `applySuggestionDecisions()`, `canEditOntology()`, `canPublishOntology()`
+- Produces API methods: `getOntology`, `getOntologyGraph`, `getOntologyVersions`, `getDataElements`, `reviewOntologyChangeSet`, `importOntology`, `exportOntology`
+
+- [ ] **Step 1: Write Node tests for graph normalization, suggestion application and permissions**
+
+```javascript
+test('viewer is read-only and only publisher can publish', () => {
+  assert.equal(canEditOntology(['governance:read']), false)
+  assert.equal(canEditOntology(['ontologies:edit']), true)
+  assert.equal(canPublishOntology(['ontologies:edit']), false)
+  assert.equal(canPublishOntology(['ontologies:publish']), true)
+})
+```
+
+- [ ] **Step 2: Run Node tests and verify module is missing**
+
+Run: `node --experimental-default-type=module --test tests/ontology-workbench-model.test.mjs`
+
+Expected: FAIL with module not found.
+
+- [ ] **Step 3: Implement pure state helpers and API methods**
+- [ ] **Step 4: Run Node and Python frontend-contract tests**
+
+Run: `node --experimental-default-type=module --test tests/ontology-workbench-model.test.mjs`
+
+Expected: PASS.
+
+### Task 4: 结构化本体中心与工作台
+
+**Files:**
+- Modify: `frontend/src/views/dataGovernance/ontology/index.vue`
+- Rewrite: `frontend/src/views/dataGovernance/ontology/workbench.vue`
+- Create: `frontend/src/views/dataGovernance/ontology/components/GraphSectionEditor.vue`
+- Create: `frontend/src/views/dataGovernance/ontology/components/VersionWorkspace.vue`
+- Test: `tests/test_frontend_contract.py`
+
+**Interfaces:**
+- Consumes: Task 1-3 APIs and helpers.
+- Produces: detail load, section editing, domain selection, element mapping, JSON advanced mode, save, validate, publish, diff and rollback UI.
+
+- [ ] **Step 1: Add failing source-contract assertions for every workflow control**
+- [ ] **Step 2: Run contract test and verify missing controls fail**
+- [ ] **Step 3: Implement center-page domain selection and permission-aware creation**
+- [ ] **Step 4: Implement workbench header, six section editors and advanced JSON mode**
+- [ ] **Step 5: Implement save/reload/conflict handling, validation results and permission-aware publish**
+- [ ] **Step 6: Implement version history, version pair selection, diff display and admin rollback**
+- [ ] **Step 7: Run contract tests and production build**
+
+Run: `PYTHONPATH=. .venv/bin/pytest -q tests/test_frontend_contract.py`
+
+Run: `npm run build`
+
+Expected: PASS/build exit 0.
+
+### Task 5: 动态建议评审与导入导出
+
+**Files:**
+- Modify: `frontend/src/views/dataGovernance/ontology/workbench.vue`
+- Create: `frontend/src/views/dataGovernance/ontology/components/SuggestionReviewDialog.vue`
+- Modify: `app/api/data_development/routes.py`
+- Test: `tests/data_research/test_ontology_dynamic_api.py`
+- Test: `tests/test_frontend_contract.py`
+
+**Interfaces:**
+- Consumes: `{change_set_uid, suggestions}` from Task 2.
+- Produces: suggestion generate/review/apply flow and multipart JSON/OWL import.
+
+- [ ] **Step 1: Write failing tests for multipart import and suggestion-review controls**
+- [ ] **Step 2: Run focused tests and verify expected failures**
+- [ ] **Step 3: Accept multipart uploads while preserving raw-body compatibility**
+- [ ] **Step 4: Implement suggestion payload input, evidence/confidence display and immutable decisions**
+- [ ] **Step 5: Implement JSON/OWL file import and download actions**
+- [ ] **Step 6: Run focused tests and production build**
+
+Expected: PASS/build exit 0.
+
+### Task 6: Docker integration and browser acceptance
+
+**Files:**
+- Modify: `tests/integration/test_data_research_docker_api.py`
+- Modify: `docs/validation/data-research-v60-v65-acceptance.md`
+
+**Interfaces:**
+- Verifies the complete user path through `http://localhost:18183`.
+
+- [ ] **Step 1: Rebuild backend and frontend containers**
+
+Run: `docker compose -f deploy/docker/docker-compose.yml up -d --build backend frontend`
+
+- [ ] **Step 2: Run live Docker API integration**
+
+Run: `RUN_DATA_RESEARCH_DOCKER_API=1 TEST_BACKEND_URL=http://127.0.0.1:18183/api TEST_DATABASE_URL=postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops TEST_ADMIN_PASSWORD=AdminPass123 PYTHONPATH=. .venv/bin/pytest -q tests/integration/test_data_research_docker_api.py`
+
+Expected: PASS.
+
+- [ ] **Step 3: Run browser flow**
+
+Flow: 本体中心 → 新建/选择本体 → 加载现有草稿 → 编辑一个概念 → 保存 → 校验 → 查看版本差异 → 生成并评审建议 → 导出 → 管理员发布。
+
+- [ ] **Step 4: Run full regression**
+
+Run: `PYTHONPATH=. .venv/bin/pytest -q`
+
+Run: `node --experimental-default-type=module --test frontend/tests/ontology-workbench-model.test.mjs`
+
+Run: `npm --prefix frontend run build`
+
+Expected: all tests pass and build exits 0; only documented pre-existing warnings remain.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app frontend tests docs
+git commit -m "feat: complete ontology workbench workflow"
+```

+ 27 - 4
docs/validation/data-research-v60-v65-acceptance.md

@@ -1,6 +1,6 @@
 # 数据研发 V60-V65 验收报告
 
-日期:2026-07-22
+日期:2026-07-22(2026-07-23 补充本体工作台完整链路验收)
 
 ## 1. 验收结论
 
@@ -21,13 +21,13 @@ V60-V65 全部完成:多源采集、证据治理、数据元素生命周期、
 
 | 验收命令 | 结果 |
 |---|---|
-| `PYTHONPATH=. .venv/bin/pytest -q tests/data_research tests/acceptance/test_data_research_v60_v65.py` | `79 passed` |
-| `PYTHONPATH=. .venv/bin/pytest -q` | `372 passed, 23 skipped, 59 subtests passed` |
+| `PYTHONPATH=. .venv/bin/pytest -q tests/data_research tests/test_permission_matrix.py` | `87 passed` |
+| `PYTHONPATH=. .venv/bin/pytest -q` | `380 passed, 25 skipped, 59 subtests passed` |
 | Docker 真实服务集成矩阵(含数据源故障恢复、Runner、Kestra、n8n、MCP、RBAC、迁移) | `64 passed` |
 | 数据研发容器 HTTP 端到端(CSV/MinIO/任务/双业务域本体/Outbox) | `1 passed` |
 | 启用全部本地集成开关后的全仓回归 | `397 passed, 59 subtests passed` |
 | `PATH=<bundled-node-24>:$PATH npm --prefix frontend run build` | 构建成功;0 error,21 条既有 `no-console` 警告,另有依赖年龄/CSS 顺序/包体积警告 |
-| `python scripts/generate_openapi.py --output docs/architecture/OPENAPI.yaml` | 138 个操作 |
+| `.venv/bin/python scripts/generate_openapi.py --output docs/architecture/OPENAPI.yaml` | 142 个操作 |
 | `PYTHONPATH=. .venv/bin/pytest -q tests/test_architecture_artifacts.py` | 通过 |
 | `diff -qr app deployment/app` | 无差异 |
 | `git diff --check` | 通过 |
@@ -57,3 +57,26 @@ V60-V65 全部完成:多源采集、证据治理、数据元素生命周期、
 - RBAC、V54 离线模型降级、跨存储 Outbox 失败重试和幂等消费。
 
 本轮本地 Docker 集成矩阵无跳过项。Docker 测试环境保持运行,供后续浏览器验收或继续开发使用。
+
+## 6. 本体工作台完整链路补充验收
+
+2026-07-23 在 `http://localhost:18183` 完成本体中心与工作台补充验收:
+
+- 本体中心支持主责域、协作域建模并进入工作台;viewer 可只读访问,editor 可编辑,admin 可发布和回滚。
+- 工作台支持概念、属性、关系、约束、业务域和数据元素映射六类对象的结构化增删改,并保留高级 JSON 入口。
+- 详情、图谱、版本列表和 ETag 修订读取完整;保存草稿后数据库内容与页面修订号一致。
+- 校验问题以可读结果返回,不再因 422 被前端拦截;补齐主责域后校验通过。
+- 版本差异展示新增概念和业务域;发布后版本不可变,回滚从已发布版本创建新草稿。
+- 动态建议展示来源、证据和置信度,支持接受、拒绝、编辑后接受及评审意见;接受结果需再次保存草稿。
+- JSON/OWL 导出入口可用;JSON multipart 导入通过真实 Docker API 上传、创建新本体并读取新图谱验证,页面导入弹窗和同路由 UID 切换刷新逻辑已验证。
+- 浏览器实际捕获并修复请求封装缺少 `patch` 及 `If-Match` 覆盖 `Content-Type` 两个运行时问题;修复后页面保存、数据库图谱、校验和版本记录一致,浏览器控制台无 error/warning。
+
+补充验证证据:
+
+| 验收项 | 结果 |
+|---|---|
+| 前端本体模型 Node 测试 | `5 passed` |
+| 前端生产构建 | 成功;0 error,保留 21 条既有 `no-console` 警告及既有依赖/CSS/包体积提示 |
+| Docker 数据研发 HTTP 集成 | `1 passed`,覆盖详情、图谱、版本、ETag、校验、发布、导出和 multipart 导入 |
+| 浏览器工作台完整操作 | 草稿修订推进至 5;保存、校验、发布、差异、回滚、建议评审、导出均通过 |
+| Docker 服务状态 | backend、frontend、PostgreSQL、Neo4j、MinIO 等验收服务 healthy |

+ 24 - 1
frontend/src/api/dataDevelopment.js

@@ -9,6 +9,9 @@ export const getIngestionJob = uid => http.get(`${BASE}/${uid}`)
 export const retryIngestionJob = uid => http.post(`${BASE}/${uid}/retry`)
 export const cancelIngestionJob = uid => http.post(`${BASE}/${uid}/cancel`)
 export const getOntologies = () => http.get(ONTOLOGY_BASE)
+export const getOntology = uid => http.get(`${ONTOLOGY_BASE}/${uid}`)
+export const getOntologyGraph = uid => http.get(`${ONTOLOGY_BASE}/${uid}/graph`)
+export const getOntologyVersions = uid => http.get(`${ONTOLOGY_BASE}/${uid}/versions`)
 export const createOntology = params => http.post(ONTOLOGY_BASE, params)
 export const saveOntologyGraph = (uid, graph, revision) => http.patch(
   `${ONTOLOGY_BASE}/${uid}/graph`,
@@ -29,6 +32,7 @@ export const rollbackOntology = (uid, params) => http.post(
   `${ONTOLOGY_BASE}/${uid}/rollback`,
   params
 )
+export const getDataElements = params => http.get('/development/v1/data-elements', params)
 export const uploadSourceFile = formData => http.post('/development/v1/sources/files', formData)
 export const getEvidence = (uid, download = false) => http.get(
   `/development/v1/evidence/${uid}${download ? '?download=1' : ''}`
@@ -46,6 +50,18 @@ export const generateOntologySuggestions = (uid, params) => http.post(
   `${ONTOLOGY_BASE}/${uid}/suggestions`,
   params
 )
+export const reviewOntologyChangeSet = (uid, changeSetUid, decisions) => http.post(
+  `${ONTOLOGY_BASE}/${uid}/change-sets/${changeSetUid}/decisions`,
+  { decisions }
+)
+export const exportOntology = (uid, format) => http.getDownload(
+  `${ONTOLOGY_BASE}/${uid}/export`,
+  { format }
+)
+export const importOntology = (formData, format) => http.upload(
+  `${ONTOLOGY_BASE}/import?format=${format}`,
+  formData
+)
 
 export default {
   createIngestionJob,
@@ -54,16 +70,23 @@ export default {
   retryIngestionJob,
   cancelIngestionJob,
   getOntologies,
+  getOntology,
+  getOntologyGraph,
+  getOntologyVersions,
   createOntology,
   saveOntologyGraph,
   validateOntology,
   publishOntology,
   diffOntology,
   rollbackOntology,
+  getDataElements,
   uploadSourceFile,
   getEvidence,
   decideCandidates,
   createDataElement,
   transitionDataElement,
-  generateOntologySuggestions
+  generateOntologySuggestions,
+  reviewOntologyChangeSet,
+  exportOntology,
+  importOntology
 }

+ 1 - 1
frontend/src/router/routes.js

@@ -268,7 +268,7 @@ export default {
           meta: {
             keepAlive: false,
             allowClick: false,
-            roles: ['editor', 'admin'],
+            roles: ['viewer', 'editor', 'admin'],
             enName: 'Ontology Workbench',
             icon: 'mdi-vector-polyline-edit',
             editModules: false,

+ 16 - 3
frontend/src/utils/request.js

@@ -83,10 +83,23 @@ const http = {
       transformRequest: [(params) => {
         return JSON.stringify(params)
       }],
+      ...config,
       headers: {
-        'Content-Type': 'application/json'
-      },
-      ...config
+        'Content-Type': 'application/json',
+        ...(config.headers || {})
+      }
+    })
+  },
+  patch (url, params, config = {}) {
+    return service.patch(url, params, {
+      transformRequest: [(params) => {
+        return JSON.stringify(params)
+      }],
+      ...config,
+      headers: {
+        'Content-Type': 'application/json',
+        ...(config.headers || {})
+      }
     })
   },
   get (url, params) {

+ 252 - 0
frontend/src/views/dataGovernance/ontology/components/GraphSectionEditor.vue

@@ -0,0 +1,252 @@
+<template>
+  <v-card outlined class="section-editor">
+    <v-card-title class="py-3">
+      <div>
+        <div class="text-h6">{{ title }}</div>
+        <div class="text-caption text--secondary">{{ description }}</div>
+      </div>
+      <v-spacer />
+      <v-btn v-if="!readOnly" small color="primary" @click="openCreate">
+        <v-icon left small>mdi-plus</v-icon>新增{{ singular }}
+      </v-btn>
+    </v-card-title>
+    <v-divider />
+    <v-data-table
+      :headers="headers"
+      :items="items"
+      :items-per-page="8"
+      :no-data-text="`暂无${title}`"
+      dense
+    >
+      <template v-slot:[`item.required`]="{ item }">
+        {{ item.required ? '是' : '否' }}
+      </template>
+      <template v-slot:[`item.actions`]="{ item }">
+        <v-btn v-if="!readOnly" text x-small color="primary" @click="openEdit(item)">
+          编辑
+        </v-btn>
+        <v-btn v-if="!readOnly" text x-small color="error" @click="remove(item)">
+          删除
+        </v-btn>
+      </template>
+    </v-data-table>
+
+    <v-dialog v-model="dialog" max-width="620">
+      <v-card>
+        <v-card-title>{{ editing ? `编辑${singular}` : `新增${singular}` }}</v-card-title>
+        <v-card-text>
+          <template v-for="field in fields">
+            <v-switch
+              v-if="field.type === 'boolean'"
+              :key="field.key"
+              v-model="form[field.key]"
+              :label="field.label"
+              color="primary"
+            />
+            <v-select
+              v-else-if="field.type === 'select'"
+              :key="field.key"
+              v-model="form[field.key]"
+              :items="optionsFor(field)"
+              :label="field.label"
+              clearable
+              outlined
+              dense
+            />
+            <v-textarea
+              v-else-if="field.type === 'textarea'"
+              :key="field.key"
+              v-model="form[field.key]"
+              :label="field.label"
+              outlined
+              rows="3"
+            />
+            <v-text-field
+              v-else
+              :key="field.key"
+              v-model="form[field.key]"
+              :label="field.label"
+              outlined
+              dense
+            />
+          </template>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="dialog = false">取消</v-btn>
+          <v-btn color="primary" :disabled="!formIdentity" @click="submit">
+            保存
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+  </v-card>
+</template>
+
+<script>
+const DEFINITIONS = {
+  classes: {
+    title: '概念',
+    singular: '概念',
+    description: '定义本体中的业务对象及继承关系',
+    fields: [
+      { key: 'uid', label: '概念 UID' },
+      { key: 'name', label: '概念名称' },
+      { key: 'parent_uid', label: '父概念', type: 'select', source: 'classes' },
+      { key: 'description', label: '定义说明', type: 'textarea' }
+    ]
+  },
+  properties: {
+    title: '属性',
+    singular: '属性',
+    description: '定义概念属性、基数和数据元素映射',
+    fields: [
+      { key: 'uid', label: '属性 UID' },
+      { key: 'name', label: '属性名称' },
+      { key: 'class_uid', label: '所属概念', type: 'select', source: 'classes' },
+      { key: 'cardinality', label: '基数', type: 'select', source: 'cardinality' },
+      { key: 'required', label: '必填属性', type: 'boolean' },
+      { key: 'data_element_uid', label: '数据元素', type: 'select', source: 'elements' }
+    ]
+  },
+  relations: {
+    title: '关系',
+    singular: '关系',
+    description: '连接两个概念并表达业务语义',
+    fields: [
+      { key: 'uid', label: '关系 UID' },
+      { key: 'name', label: '关系名称' },
+      { key: 'from_class_uid', label: '起点概念', type: 'select', source: 'classes' },
+      { key: 'to_class_uid', label: '终点概念', type: 'select', source: 'classes' }
+    ]
+  },
+  constraints: {
+    title: '约束',
+    singular: '约束',
+    description: '记录本体级业务规则和约束表达式',
+    fields: [
+      { key: 'uid', label: '约束 UID' },
+      { key: 'name', label: '约束名称' },
+      { key: 'expression', label: '约束表达式', type: 'textarea' }
+    ]
+  },
+  domain_links: {
+    title: '业务域',
+    singular: '业务域关联',
+    description: '一个本体可由一个主责业务域和多个协作域共同维护',
+    fields: [
+      { key: 'domain_uid', label: '选择业务域', type: 'select', source: 'domains' },
+      { key: 'role', label: '域角色', type: 'select', source: 'roles' }
+    ]
+  },
+  element_mappings: {
+    title: '数据元素映射',
+    singular: '映射',
+    description: '将本体属性关联到已发布的数据元素',
+    fields: [
+      { key: 'property_uid', label: '本体属性', type: 'select', source: 'properties' },
+      { key: 'data_element_uid', label: '数据元素', type: 'select', source: 'elements' }
+    ]
+  }
+}
+
+export default {
+  name: 'GraphSectionEditor',
+  props: {
+    section: { type: String, required: true },
+    items: { type: Array, default: () => [] },
+    graph: { type: Object, required: true },
+    domains: { type: Array, default: () => [] },
+    dataElements: { type: Array, default: () => [] },
+    readOnly: Boolean
+  },
+  data: () => ({
+    dialog: false,
+    editing: false,
+    form: {}
+  }),
+  computed: {
+    definition () {
+      return DEFINITIONS[this.section]
+    },
+    title () {
+      return this.definition.title
+    },
+    singular () {
+      return this.definition.singular
+    },
+    description () {
+      return this.definition.description
+    },
+    fields () {
+      return this.definition.fields
+    },
+    headers () {
+      const headers = this.fields
+        .filter(field => field.type !== 'textarea')
+        .map(field => ({ text: field.label, value: field.key }))
+      if (!this.readOnly) {
+        headers.push({ text: '操作', value: 'actions', sortable: false, width: 120 })
+      }
+      return headers
+    },
+    formIdentity () {
+      if (this.section === 'domain_links') return this.form.domain_uid
+      if (this.section === 'element_mappings') return this.form.property_uid
+      return this.form.uid
+    }
+  },
+  methods: {
+    itemOptions (items, nameKey = 'name') {
+      return (items || []).map(item => ({
+        text: `${item[nameKey] || item.code || item.uid} · ${item.uid}`,
+        value: item.uid
+      }))
+    },
+    optionsFor (field) {
+      if (field.source === 'classes') return this.itemOptions(this.graph.classes)
+      if (field.source === 'properties') return this.itemOptions(this.graph.properties)
+      if (field.source === 'domains') return this.domains
+      if (field.source === 'elements') return this.dataElements
+      if (field.source === 'roles') {
+        return [
+          { text: '主责域(owner)', value: 'owner' },
+          { text: '协作域(contributor)', value: 'contributor' }
+        ]
+      }
+      if (field.source === 'cardinality') return ['0..1', '1', '0..*', '1..*']
+      return []
+    },
+    openCreate () {
+      this.editing = false
+      this.form = this.fields.reduce((value, field) => {
+        value[field.key] = field.type === 'boolean' ? false : ''
+        return value
+      }, {})
+      if ('cardinality' in this.form) this.form.cardinality = '1'
+      if ('role' in this.form) this.form.role = 'contributor'
+      this.dialog = true
+    },
+    openEdit (item) {
+      this.editing = true
+      this.form = { ...item }
+      this.dialog = true
+    },
+    submit () {
+      this.$emit('upsert', { section: this.section, item: { ...this.form } })
+      this.dialog = false
+    },
+    remove (item) {
+      this.$confirm('提示', `确定删除该${this.singular}吗?`).then(() => {
+        this.$emit('remove', { section: this.section, item })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.section-editor {
+  min-height: 520px;
+}
+</style>

+ 142 - 0
frontend/src/views/dataGovernance/ontology/components/SuggestionReviewDialog.vue

@@ -0,0 +1,142 @@
+<template>
+  <v-dialog :value="value" max-width="880" persistent @input="$emit('input', $event)">
+    <v-card>
+      <v-card-title>
+        动态建议评审
+        <v-spacer />
+        <span class="text-caption text--secondary">变更集 {{ changeSetUid }}</span>
+      </v-card-title>
+      <v-divider />
+      <v-card-text class="pt-4 suggestion-list">
+        <v-alert v-if="!suggestions.length" type="info" outlined>
+          当前输入未生成建议。
+        </v-alert>
+        <v-card
+          v-for="suggestion in suggestions"
+          :key="suggestion.uid"
+          outlined
+          class="mb-3"
+        >
+          <v-card-text>
+            <div class="d-flex align-center">
+              <strong>{{ kindLabel(suggestion.kind) }} · {{ suggestion.payload.name || suggestion.payload.uid }}</strong>
+              <v-spacer />
+              <span class="mr-4">置信度 {{ confidence(suggestion.confidence) }}</span>
+              <v-chip x-small outlined>{{ suggestion.source || 'rule' }}</v-chip>
+            </div>
+            <div class="text-caption text--secondary mt-2">
+              证据:{{ (suggestion.evidence_uids || []).join('、') || '无' }}
+            </div>
+            <pre class="payload-view">{{ JSON.stringify(suggestion.payload, null, 2) }}</pre>
+            <v-radio-group v-model="decisions[suggestion.uid].decision" row>
+              <v-radio label="接受" value="accept" />
+              <v-radio label="拒绝" value="reject" />
+              <v-radio label="编辑后接受" value="edit" />
+            </v-radio-group>
+            <v-textarea
+              v-if="decisions[suggestion.uid].decision === 'edit'"
+              v-model="decisions[suggestion.uid].payloadText"
+              label="编辑后的建议 JSON"
+              outlined
+              rows="4"
+            />
+            <v-text-field
+              v-model="decisions[suggestion.uid].reason"
+              label="评审意见(可选)"
+              outlined
+              dense
+            />
+          </v-card-text>
+        </v-card>
+      </v-card-text>
+      <v-divider />
+      <v-card-actions>
+        <v-spacer />
+        <v-btn text @click="$emit('input', false)">取消</v-btn>
+        <v-btn
+          color="primary"
+          :loading="loading"
+          :disabled="!suggestions.length"
+          @click="submit"
+        >
+          提交评审并应用
+        </v-btn>
+      </v-card-actions>
+    </v-card>
+  </v-dialog>
+</template>
+
+<script>
+export default {
+  name: 'SuggestionReviewDialog',
+  props: {
+    value: Boolean,
+    changeSetUid: { type: String, default: '' },
+    suggestions: { type: Array, default: () => [] },
+    loading: Boolean
+  },
+  data: () => ({ decisions: {} }),
+  watch: {
+    suggestions: {
+      immediate: true,
+      handler (items) {
+        this.decisions = items.reduce((value, item) => {
+          value[item.uid] = {
+            decision: 'accept',
+            reason: '',
+            payloadText: JSON.stringify(item.payload || {}, null, 2)
+          }
+          return value
+        }, {})
+      }
+    }
+  },
+  methods: {
+    kindLabel (kind) {
+      return {
+        class: '概念',
+        property: '属性',
+        relation: '关系',
+        constraint: '约束'
+      }[kind] || kind
+    },
+    confidence (value) {
+      return `${Math.round(Number(value || 0) * 100)}%`
+    },
+    submit () {
+      try {
+        const decisions = this.suggestions.map(item => {
+          const form = this.decisions[item.uid]
+          const decision = {
+            suggestion_uid: item.uid,
+            decision: form.decision,
+            reason: form.reason
+          }
+          if (form.decision === 'edit') {
+            decision.payload = JSON.parse(form.payloadText)
+          }
+          return decision
+        })
+        this.$emit('submit', decisions)
+      } catch (error) {
+        this.$snackbar.error('编辑后的建议必须是合法 JSON')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.suggestion-list {
+  max-height: 68vh;
+  overflow: auto;
+}
+.payload-view {
+  margin-top: 10px;
+  padding: 10px;
+  background: #f6f8fb;
+  border: 1px solid #e3e8ef;
+  font-size: 12px;
+  white-space: pre-wrap;
+}
+</style>

+ 133 - 0
frontend/src/views/dataGovernance/ontology/components/VersionWorkspace.vue

@@ -0,0 +1,133 @@
+<template>
+  <v-card outlined>
+    <v-card-title class="py-3">
+      <div>
+        <div class="text-h6">版本历史</div>
+        <div class="text-caption text--secondary">比较任意两个版本,管理员可从历史版本创建回滚草稿</div>
+      </div>
+    </v-card-title>
+    <v-divider />
+    <v-row no-gutters>
+      <v-col cols="7">
+        <v-data-table
+          :headers="headers"
+          :items="versions"
+          :items-per-page="5"
+          dense
+          no-data-text="暂无版本"
+        >
+          <template v-slot:[`item.status`]="{ item }">
+            <v-chip x-small :color="item.status === 'published' ? 'success' : 'warning'" outlined>
+              {{ item.status }}
+            </v-chip>
+          </template>
+          <template v-slot:[`item.actions`]="{ item }">
+            <v-btn text x-small color="primary" @click="$emit('inspect', item)">
+              查看
+            </v-btn>
+            <v-btn
+              v-if="canRollback && item.status === 'published'"
+              text
+              x-small
+              color="error"
+              @click="$emit('rollback', item)"
+            >
+              回滚
+            </v-btn>
+          </template>
+        </v-data-table>
+      </v-col>
+      <v-divider vertical />
+      <v-col class="pa-4">
+        <div class="text-subtitle-1 mb-3">版本差异</div>
+        <div class="d-flex">
+          <v-select
+            v-model="left"
+            :items="versionOptions"
+            label="基准版本"
+            dense
+            outlined
+            class="mr-2"
+          />
+          <v-select
+            v-model="right"
+            :items="versionOptions"
+            label="目标版本"
+            dense
+            outlined
+          />
+        </div>
+        <v-btn
+          small
+          outlined
+          color="primary"
+          :disabled="!left || !right || left === right"
+          :loading="loading"
+          @click="$emit('compare', { left, right })"
+        >
+          查看差异
+        </v-btn>
+        <pre class="diff-view mt-3">{{ formattedDiff }}</pre>
+      </v-col>
+    </v-row>
+  </v-card>
+</template>
+
+<script>
+export default {
+  name: 'VersionWorkspace',
+  props: {
+    versions: { type: Array, default: () => [] },
+    diff: { type: Object, default: () => ({}) },
+    canRollback: Boolean,
+    loading: Boolean
+  },
+  data: () => ({ left: null, right: null }),
+  computed: {
+    headers () {
+      return [
+        { text: '版本', value: 'version' },
+        { text: '状态', value: 'status' },
+        { text: '创建人', value: 'created_by' },
+        { text: '内容哈希', value: 'content_hash' },
+        { text: '操作', value: 'actions', sortable: false }
+      ]
+    },
+    versionOptions () {
+      return this.versions.map(item => ({
+        text: `v${item.version} · ${item.status}`,
+        value: item.uid
+      }))
+    },
+    formattedDiff () {
+      return Object.keys(this.diff || {}).length
+        ? JSON.stringify(this.diff, null, 2)
+        : '选择两个版本后查看结构变化'
+    }
+  },
+  watch: {
+    versions: {
+      immediate: true,
+      handler (items) {
+        if (items.length >= 2) {
+          this.right = items[0].uid
+          this.left = items[1].uid
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.diff-view {
+  min-height: 180px;
+  max-height: 300px;
+  overflow: auto;
+  padding: 12px;
+  background: #f6f8fb;
+  border: 1px solid #e3e8ef;
+  font-size: 12px;
+  white-space: pre-wrap;
+}
+</style>

+ 82 - 5
frontend/src/views/dataGovernance/ontology/index.vue

@@ -6,7 +6,7 @@
         <p class="text--secondary mb-0">跨业务域管理本体定义、版本与发布状态</p>
       </div>
       <v-spacer />
-      <v-btn color="primary" @click="dialog = true">新建本体</v-btn>
+      <v-btn v-if="canEdit" color="primary" @click="openCreate">新建本体</v-btn>
     </div>
     <v-data-table :headers="headers" :items="items" :loading="loading">
       <template v-slot:[`item.domain_links`]="{ item }">
@@ -25,6 +25,23 @@
           <v-text-field v-model="form.code" label="本体编码" />
           <v-text-field v-model="form.name" label="本体名称" />
           <v-text-field v-model="form.owner_uid" label="责任人 UID" />
+          <v-select
+            v-model="ownerDomainUid"
+            :items="domainOptions"
+            label="选择业务域(主责域)"
+            outlined
+            dense
+          />
+          <v-select
+            v-model="contributorDomainUids"
+            :items="contributorOptions"
+            label="选择业务域(协作域)"
+            multiple
+            chips
+            small-chips
+            outlined
+            dense
+          />
         </v-card-text>
         <v-card-actions>
           <v-spacer /><v-btn text @click="dialog = false">取消</v-btn><v-btn color="primary" @click="create">创建</v-btn>
@@ -36,6 +53,8 @@
 
 <script>
 import { createOntology, getOntologies } from '@/api/dataDevelopment'
+import { api as governanceApi } from '@/api/dataGovernance'
+import { canEditOntology } from './model'
 
 export default {
   name: 'OntologyCenter',
@@ -44,6 +63,9 @@ export default {
     dialog: false,
     items: [],
     form: { code: '', name: '', owner_uid: '', domain_links: [] },
+    domainOptions: [],
+    ownerDomainUid: '',
+    contributorDomainUids: [],
     headers: [
       { text: '编码', value: 'code' },
       { text: '名称', value: 'name' },
@@ -52,16 +74,71 @@ export default {
       { text: '操作', value: 'actions', sortable: false }
     ]
   }),
-  created () { this.load() },
+  computed: {
+    canEdit () {
+      return canEditOntology(
+        (this.$store.state.user.userInfo || {}).permissions || []
+      )
+    },
+    contributorOptions () {
+      return this.domainOptions.filter(
+        item => item.value !== this.ownerDomainUid
+      )
+    }
+  },
+  created () {
+    this.load()
+    this.loadDomains()
+  },
   methods: {
     async load () {
       this.loading = true
       try { this.items = (await getOntologies()).data || [] } finally { this.loading = false }
     },
+    async loadDomains () {
+      try {
+        const response = await governanceApi.getBusinessDomainList({
+          current: 1,
+          size: 200,
+          type: 'all'
+        })
+        this.domainOptions = (response.data.records || [])
+          .map(item => ({
+            text: `${item.name_zh || item.name || item.name_en || '未命名业务域'} · ${item.uid || item.governance_uid || item.id}`,
+            value: String(item.uid || item.governance_uid || item.id)
+          }))
+          .filter(item => item.value && item.value !== 'undefined')
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    openCreate () {
+      this.form = { code: '', name: '', owner_uid: '', domain_links: [] }
+      this.ownerDomainUid = ''
+      this.contributorDomainUids = []
+      this.dialog = true
+    },
     async create () {
-      await createOntology(this.form)
-      this.dialog = false
-      await this.load()
+      try {
+        const domainLinks = [
+          ...(this.ownerDomainUid
+            ? [{ domain_uid: this.ownerDomainUid, role: 'owner' }]
+            : []),
+          ...this.contributorDomainUids.map(domainUid => ({
+            domain_uid: domainUid,
+            role: 'contributor'
+          }))
+        ]
+        const response = await createOntology({
+          ...this.form,
+          domain_links: domainLinks
+        })
+        this.dialog = false
+        await this.load()
+        this.$router.push(`/data-governance/ontology/${response.data.uid}`)
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
     }
   }
 }

+ 86 - 0
frontend/src/views/dataGovernance/ontology/model.js

@@ -0,0 +1,86 @@
+export const GRAPH_SECTIONS = [
+  'classes',
+  'properties',
+  'relations',
+  'constraints',
+  'domain_links',
+  'element_mappings'
+]
+
+const SUGGESTION_SECTIONS = {
+  class: 'classes',
+  property: 'properties',
+  relation: 'relations',
+  constraint: 'constraints'
+}
+
+export function emptyGraph () {
+  return GRAPH_SECTIONS.reduce((graph, section) => {
+    graph[section] = []
+    return graph
+  }, {})
+}
+
+export function normalizeGraph (value) {
+  const source = value || {}
+  return GRAPH_SECTIONS.reduce((graph, section) => {
+    graph[section] = Array.isArray(source[section])
+      ? source[section].map(item => ({ ...item }))
+      : []
+    return graph
+  }, {})
+}
+
+export function graphItemKey (section, item) {
+  if (section === 'domain_links') return String(item.domain_uid || '')
+  if (section === 'element_mappings') return String(item.property_uid || '')
+  return String(item.uid || '')
+}
+
+export function upsertGraphItem (graph, section, item) {
+  const result = normalizeGraph(graph)
+  const key = graphItemKey(section, item)
+  const index = result[section].findIndex(
+    current => graphItemKey(section, current) === key
+  )
+  if (index === -1) result[section].push({ ...item })
+  else result[section].splice(index, 1, { ...item })
+  return result
+}
+
+export function removeGraphItem (graph, section, item) {
+  const result = normalizeGraph(graph)
+  const key = graphItemKey(section, item)
+  result[section] = result[section].filter(
+    current => graphItemKey(section, current) !== key
+  )
+  return result
+}
+
+export function applySuggestionDecisions (graph, suggestions, decisions) {
+  const byUid = new Map((suggestions || []).map(item => [item.uid, item]))
+  return (decisions || []).reduce((result, decision) => {
+    if (decision.decision === 'reject') return result
+    const suggestion = byUid.get(decision.suggestion_uid)
+    if (!suggestion) return result
+    const section = SUGGESTION_SECTIONS[suggestion.kind]
+    if (!section) return result
+    const payload = decision.decision === 'edit'
+      ? decision.payload
+      : suggestion.payload
+    return upsertGraphItem(result, section, payload || {})
+  }, normalizeGraph(graph))
+}
+
+export function canEditOntology (permissions) {
+  return (permissions || []).includes('ontologies:edit')
+}
+
+export function canPublishOntology (permissions) {
+  return (permissions || []).includes('ontologies:publish')
+}
+
+export function parseRevision (etag) {
+  const match = String(etag || '').match(/(\d+)/)
+  return match ? Number(match[1]) : 0
+}

+ 597 - 19
frontend/src/views/dataGovernance/ontology/workbench.vue

@@ -1,36 +1,614 @@
 <template>
-  <div class="ontology-workbench pa-6">
-    <div class="d-flex align-center mb-4">
-      <div><h1 class="text-h4">本体工作台</h1><p class="text--secondary">图定义、校验、版本差异与发布确认</p></div>
+  <div class="ontology-workbench pa-5">
+    <div class="workbench-header d-flex align-center mb-4">
+      <v-btn icon class="mr-2" @click="$router.push('/data-governance/ontology')">
+        <v-icon>mdi-arrow-left</v-icon>
+      </v-btn>
+      <div>
+        <div class="d-flex align-center">
+          <h1 class="text-h5 mb-0">{{ ontology.name || '本体工作台' }}</h1>
+          <v-chip
+            v-if="ontology.status"
+            x-small
+            outlined
+            class="ml-3"
+            :color="ontology.status === 'published' ? 'success' : 'warning'"
+          >
+            {{ ontology.status }}
+          </v-chip>
+        </div>
+        <div class="text-caption text--secondary mt-1">
+          {{ ontology.code || $route.params.uid }} · 草稿修订 {{ revision }}
+        </div>
+      </div>
       <v-spacer />
-      <v-btn outlined class="mr-2" @click="validate">校验</v-btn>
-      <v-btn color="primary" @click="publishDialog = true">发布确认</v-btn>
+      <v-btn v-if="canEdit" text small class="mr-1" @click="importDialog = true">
+        <v-icon left small>mdi-import</v-icon>导入
+      </v-btn>
+      <v-menu offset-y>
+        <template #activator="{ on, attrs }">
+          <v-btn text small class="mr-1" v-bind="attrs" v-on="on">
+            <v-icon left small>mdi-export</v-icon>导出
+          </v-btn>
+        </template>
+        <v-list dense>
+          <v-list-item @click="exportDocument('json')">
+            <v-list-item-title>导出 JSON</v-list-item-title>
+          </v-list-item>
+          <v-list-item @click="exportDocument('owl')">
+            <v-list-item-title>导出 OWL/RDF</v-list-item-title>
+          </v-list-item>
+        </v-list>
+      </v-menu>
+      <v-btn
+        v-if="canEdit"
+        outlined
+        small
+        color="primary"
+        class="mr-2"
+        @click="generateSuggestions"
+      >
+        <v-icon left small>mdi-lightbulb-on-outline</v-icon>动态建议
+      </v-btn>
+      <v-btn
+        v-if="canEdit"
+        outlined
+        small
+        class="mr-2"
+        :loading="saving"
+        @click="save"
+      >
+        保存草稿
+      </v-btn>
+      <v-btn
+        v-if="canEdit"
+        outlined
+        small
+        class="mr-2"
+        :loading="validating"
+        @click="validate"
+      >
+        校验
+      </v-btn>
+      <v-btn
+        v-if="canPublish"
+        color="primary"
+        small
+        :disabled="!versions.length"
+        @click="publishDialog = true"
+      >
+        发布确认
+      </v-btn>
     </div>
+
+    <v-alert v-if="!canEdit" type="info" outlined dense>
+      当前为只读模式,可查看本体、校验结果和已发布版本。
+    </v-alert>
+
     <v-row>
-      <v-col cols="8"><v-textarea v-model="graphJson" outlined auto-grow label="本体图 JSON" /></v-col>
-      <v-col cols="4">
-        <v-card outlined><v-card-title>校验结果</v-card-title><v-card-text>
-          <v-alert v-for="issue in issues" :key="issue.code" type="warning" dense>{{ issue.code }} · {{ issue.message }}</v-alert>
-          <span v-if="!issues.length">尚无校验问题</span>
-        </v-card-text></v-card>
-        <v-card outlined class="mt-4"><v-card-title>版本差异</v-card-title><v-card-text><pre>{{ diff }}</pre></v-card-text></v-card>
+      <v-col cols="2">
+        <v-card outlined class="section-nav">
+          <v-list dense nav>
+            <v-list-item
+              v-for="section in sections"
+              :key="section.value"
+              :input-value="selectedSection === section.value"
+              color="primary"
+              @click="selectedSection = section.value"
+            >
+              <v-list-item-icon>
+                <v-icon small>{{ section.icon }}</v-icon>
+              </v-list-item-icon>
+              <v-list-item-content>
+                <v-list-item-title>{{ section.text }}</v-list-item-title>
+              </v-list-item-content>
+              <v-list-item-action>
+                <span class="text-caption">{{ graph[section.value].length }}</span>
+              </v-list-item-action>
+            </v-list-item>
+          </v-list>
+          <v-divider />
+          <v-list dense>
+            <v-list-item @click="openJsonEditor">
+              <v-list-item-icon><v-icon small>mdi-code-json</v-icon></v-list-item-icon>
+              <v-list-item-title>高级 JSON</v-list-item-title>
+            </v-list-item>
+          </v-list>
+        </v-card>
+      </v-col>
+
+      <v-col cols="7">
+        <graph-section-editor
+          :section="selectedSection"
+          :items="graph[selectedSection]"
+          :graph="graph"
+          :domains="domainOptions"
+          :data-elements="dataElementOptions"
+          :read-only="!canEdit"
+          @upsert="upsertItem"
+          @remove="removeItem"
+        />
+      </v-col>
+
+      <v-col cols="3">
+        <v-card outlined class="validation-panel">
+          <v-card-title class="text-subtitle-1">校验结果</v-card-title>
+          <v-divider />
+          <v-card-text>
+            <v-alert v-if="!hasValidated" type="info" text dense>
+              尚未执行校验。请先保存草稿,再点击“校验”。
+            </v-alert>
+            <v-alert
+              v-else-if="!issues.length"
+              type="success"
+              text
+              dense
+            >
+              当前草稿校验通过。
+            </v-alert>
+            <v-alert
+              v-for="issue in issues"
+              :key="`${issue.code}-${issue.path}`"
+              type="warning"
+              outlined
+              dense
+            >
+              <strong>{{ issue.code }}</strong>
+              <div>{{ issue.message }}</div>
+              <div v-if="issue.path" class="text-caption mt-1">
+                位置:{{ issue.path }}
+              </div>
+            </v-alert>
+          </v-card-text>
+        </v-card>
+
+        <v-card outlined class="mt-4">
+          <v-card-title class="text-subtitle-1">本体摘要</v-card-title>
+          <v-divider />
+          <v-list dense>
+            <v-list-item>
+              <v-list-item-content>主责人</v-list-item-content>
+              <v-list-item-action>{{ ontology.owner_uid || '-' }}</v-list-item-action>
+            </v-list-item>
+            <v-list-item>
+              <v-list-item-content>当前版本</v-list-item-content>
+              <v-list-item-action>{{ latestVersionLabel }}</v-list-item-action>
+            </v-list-item>
+            <v-list-item>
+              <v-list-item-content>关联业务域</v-list-item-content>
+              <v-list-item-action>{{ graph.domain_links.length }}</v-list-item-action>
+            </v-list-item>
+            <v-list-item>
+              <v-list-item-content>数据元素映射</v-list-item-content>
+              <v-list-item-action>{{ graph.element_mappings.length }}</v-list-item-action>
+            </v-list-item>
+          </v-list>
+        </v-card>
       </v-col>
     </v-row>
-    <v-btn color="primary" @click="save">保存草稿</v-btn>
-    <v-dialog v-model="publishDialog" max-width="480"><v-card><v-card-title>发布确认</v-card-title><v-card-text>发布后该版本不可修改,并将异步投影至语义图。</v-card-text><v-card-actions><v-spacer /><v-btn text @click="publishDialog = false">取消</v-btn><v-btn color="primary" @click="publish">确认发布</v-btn></v-card-actions></v-card></v-dialog>
+
+    <version-workspace
+      class="mt-4"
+      :versions="versions"
+      :diff="diff"
+      :can-rollback="canPublish"
+      :loading="versionLoading"
+      @compare="compareVersions"
+      @rollback="confirmRollback"
+      @inspect="inspectVersion"
+    />
+
+    <v-dialog v-model="jsonDialog" max-width="900">
+      <v-card>
+        <v-card-title>高级 JSON</v-card-title>
+        <v-card-text>
+          <v-alert type="warning" outlined dense>
+            修改后将覆盖结构化编辑器中的六个本体图分区。
+          </v-alert>
+          <v-textarea
+            v-model="graphJson"
+            label="本体图 JSON"
+            outlined
+            auto-grow
+            rows="18"
+            :readonly="!canEdit"
+          />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="jsonDialog = false">关闭</v-btn>
+          <v-btn v-if="canEdit" color="primary" @click="applyJson">应用到编辑器</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="publishDialog" max-width="480">
+      <v-card>
+        <v-card-title>发布确认</v-card-title>
+        <v-card-text>
+          发布后该版本不可修改,并将通过 Outbox 异步投影至语义图。
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="publishDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="publishing" @click="publish">
+            确认发布
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-dialog v-model="importDialog" max-width="560">
+      <v-card>
+        <v-card-title>导入本体</v-card-title>
+        <v-card-text>
+          <v-file-input
+            v-model="importFile"
+            accept=".json,.owl,.rdf,.xml"
+            label="选择 JSON 或 OWL/RDF 文件"
+            outlined
+          />
+          <v-alert type="info" text dense>
+            导入会创建一个新的本体及草稿版本,不会覆盖当前本体。
+          </v-alert>
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="importDialog = false">取消</v-btn>
+          <v-btn color="primary" :disabled="!importFile" :loading="importing" @click="importDocument">
+            开始导入
+          </v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <suggestion-review-dialog
+      v-model="suggestionDialog"
+      :change-set-uid="changeSetUid"
+      :suggestions="suggestions"
+      :loading="suggestionLoading"
+      @submit="reviewSuggestions"
+    />
   </div>
 </template>
 
 <script>
-import { publishOntology, saveOntologyGraph, validateOntology } from '@/api/dataDevelopment'
+import { saveAs } from 'file-saver'
+
+import { api as governanceApi } from '@/api/dataGovernance'
+import {
+  diffOntology,
+  exportOntology,
+  generateOntologySuggestions,
+  getDataElements,
+  getOntology,
+  getOntologyGraph,
+  getOntologyVersions,
+  importOntology,
+  publishOntology,
+  reviewOntologyChangeSet,
+  rollbackOntology,
+  saveOntologyGraph,
+  validateOntology
+} from '@/api/dataDevelopment'
+
+import GraphSectionEditor from './components/GraphSectionEditor'
+import SuggestionReviewDialog from './components/SuggestionReviewDialog'
+import VersionWorkspace from './components/VersionWorkspace'
+import {
+  applySuggestionDecisions,
+  canEditOntology,
+  canPublishOntology,
+  emptyGraph,
+  normalizeGraph,
+  removeGraphItem,
+  upsertGraphItem
+} from './model'
 
 export default {
   name: 'OntologyWorkbench',
-  data: () => ({ graphJson: '{\n  "classes": [],\n  "properties": [],\n  "relations": [],\n  "constraints": [],\n  "domain_links": [],\n  "element_mappings": []\n}', revision: 0, issues: [], diff: {}, publishDialog: false }),
+  components: {
+    GraphSectionEditor,
+    SuggestionReviewDialog,
+    VersionWorkspace
+  },
+  data: () => ({
+    ontology: {},
+    graph: emptyGraph(),
+    revision: 0,
+    versions: [],
+    issues: [],
+    diff: {},
+    hasValidated: false,
+    selectedSection: 'classes',
+    domainOptions: [],
+    domainRecords: [],
+    dataElementOptions: [],
+    dataElementRecords: [],
+    saving: false,
+    validating: false,
+    publishing: false,
+    versionLoading: false,
+    suggestionLoading: false,
+    importing: false,
+    publishDialog: false,
+    jsonDialog: false,
+    importDialog: false,
+    suggestionDialog: false,
+    importFile: null,
+    graphJson: '',
+    changeSetUid: '',
+    suggestions: [],
+    sections: [
+      { text: '概念', value: 'classes', icon: 'mdi-shape-outline' },
+      { text: '属性', value: 'properties', icon: 'mdi-format-list-bulleted-type' },
+      { text: '关系', value: 'relations', icon: 'mdi-vector-line' },
+      { text: '约束', value: 'constraints', icon: 'mdi-shield-check-outline' },
+      { text: '业务域', value: 'domain_links', icon: 'mdi-domain' },
+      { text: '数据元素映射', value: 'element_mappings', icon: 'mdi-link-variant' }
+    ]
+  }),
+  computed: {
+    permissions () {
+      return (this.$store.state.user.userInfo || {}).permissions || []
+    },
+    canEdit () {
+      return canEditOntology(this.permissions)
+    },
+    canPublish () {
+      return canPublishOntology(this.permissions)
+    },
+    latestVersionLabel () {
+      return this.versions.length
+        ? `v${this.versions[0].version} · ${this.versions[0].status}`
+        : '尚无版本'
+    }
+  },
+  created () {
+    this.loadOntology()
+  },
+  watch: {
+    '$route.params.uid' (uid, previousUid) {
+      if (uid !== previousUid) this.loadOntology()
+    }
+  },
   methods: {
-    async save () { await saveOntologyGraph(this.$route.params.uid, JSON.parse(this.graphJson), this.revision); this.revision += 1 },
-    async validate () { this.issues = (await validateOntology(this.$route.params.uid)).data || [] },
-    async publish () { await publishOntology(this.$route.params.uid, `${this.$route.params.uid}:${this.revision}`); this.publishDialog = false }
+    async loadOntology () {
+      try {
+        const uid = this.$route.params.uid
+        const [detail, graph, versions, domains, elements] = await Promise.all([
+          getOntology(uid),
+          getOntologyGraph(uid),
+          getOntologyVersions(uid),
+          governanceApi.getBusinessDomainList({ current: 1, size: 200, type: 'all' }),
+          getDataElements({ status: 'published' })
+        ])
+        this.ontology = detail.data
+        this.revision = Number(detail.data.draft_revision || 0)
+        this.graph = normalizeGraph(graph.data.graph_document)
+        this.versions = versions.data || []
+        this.domainRecords = domains.data.records || []
+        this.domainOptions = this.domainRecords
+          .map(item => ({
+            text: `${item.name_zh || item.name || item.name_en || '未命名业务域'} · ${item.uid || item.governance_uid || item.id}`,
+            value: String(item.uid || item.governance_uid || item.id)
+          }))
+          .filter(item => item.value && item.value !== 'undefined')
+        this.dataElementRecords = (elements.data || {}).records || []
+        this.dataElementOptions = this.dataElementRecords.map(item => ({
+          text: `${item.snapshot.name_zh || item.snapshot.name_en || item.code} · ${item.uid}`,
+          value: item.uid
+        }))
+        this.hasValidated = false
+        this.issues = []
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async loadVersions () {
+      const response = await getOntologyVersions(this.$route.params.uid)
+      this.versions = response.data || []
+    },
+    upsertItem ({ section, item }) {
+      this.graph = upsertGraphItem(this.graph, section, item)
+      this.hasValidated = false
+    },
+    removeItem ({ section, item }) {
+      this.graph = removeGraphItem(this.graph, section, item)
+      this.hasValidated = false
+    },
+    async save () {
+      this.saving = true
+      try {
+        await saveOntologyGraph(this.$route.params.uid, this.graph, this.revision)
+        this.revision += 1
+        await this.loadVersions()
+        this.hasValidated = false
+        this.$snackbar.success('草稿保存成功')
+      } catch (error) {
+        this.$snackbar.error(`草稿保存失败:${error}`)
+        await this.loadOntology()
+      } finally {
+        this.saving = false
+      }
+    },
+    async validate () {
+      this.validating = true
+      try {
+        const response = await validateOntology(this.$route.params.uid)
+        this.issues = response.data || []
+        this.hasValidated = true
+        if (!this.issues.length) this.$snackbar.success('本体校验通过')
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.validating = false
+      }
+    },
+    async publish () {
+      this.publishing = true
+      try {
+        await publishOntology(
+          this.$route.params.uid,
+          `${this.$route.params.uid}:${this.revision}`
+        )
+        this.publishDialog = false
+        await this.loadOntology()
+        this.$snackbar.success('本体发布成功')
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.publishing = false
+      }
+    },
+    async compareVersions ({ left, right }) {
+      this.versionLoading = true
+      try {
+        this.diff = (await diffOntology(this.$route.params.uid, left, right)).data || {}
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.versionLoading = false
+      }
+    },
+    inspectVersion (version) {
+      this.graph = normalizeGraph(version.graph_document)
+      this.hasValidated = false
+      this.$snackbar.success(`已在编辑器中载入 v${version.version} 快照`)
+    },
+    confirmRollback (version) {
+      this.$confirm(
+        '回滚确认',
+        `将从 v${version.version} 创建新的回滚草稿,是否继续?`
+      ).then(() => this.rollback(version))
+    },
+    async rollback (version) {
+      this.versionLoading = true
+      try {
+        await rollbackOntology(this.$route.params.uid, {
+          target_version_uid: version.uid,
+          expected_revision: this.revision
+        })
+        await this.loadOntology()
+        this.$snackbar.success('已创建回滚草稿,请校验后重新发布')
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.versionLoading = false
+      }
+    },
+    openJsonEditor () {
+      this.graphJson = JSON.stringify(this.graph, null, 2)
+      this.jsonDialog = true
+    },
+    applyJson () {
+      try {
+        this.graph = normalizeGraph(JSON.parse(this.graphJson))
+        this.jsonDialog = false
+        this.hasValidated = false
+      } catch (error) {
+        this.$snackbar.error('本体图必须是合法 JSON')
+      }
+    },
+    suggestionPayload () {
+      const domainByUid = new Map(
+        this.domainRecords.map(item => [
+          String(item.uid || item.governance_uid || item.id),
+          item
+        ])
+      )
+      const domains = this.graph.domain_links.map(link => {
+        const domain = domainByUid.get(String(link.domain_uid)) || {}
+        return {
+          uid: String(link.domain_uid),
+          name: domain.name_zh || domain.name || domain.name_en || link.domain_uid
+        }
+      })
+      const dataElements = this.dataElementRecords.map(item => ({
+        uid: item.uid,
+        name: item.snapshot.name_zh || item.snapshot.name_en || item.code,
+        aliases: item.snapshot.aliases || [],
+        status: item.status
+      }))
+      return { domains, data_elements: dataElements, foreign_keys: [], include_ai: false }
+    },
+    async generateSuggestions () {
+      this.suggestionLoading = true
+      try {
+        const response = await generateOntologySuggestions(
+          this.$route.params.uid,
+          this.suggestionPayload()
+        )
+        this.changeSetUid = response.data.change_set_uid
+        this.suggestions = response.data.suggestions || []
+        this.suggestionDialog = true
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.suggestionLoading = false
+      }
+    },
+    async reviewSuggestions (decisions) {
+      this.suggestionLoading = true
+      try {
+        await reviewOntologyChangeSet(
+          this.$route.params.uid,
+          this.changeSetUid,
+          decisions
+        )
+        this.graph = applySuggestionDecisions(
+          this.graph,
+          this.suggestions,
+          decisions
+        )
+        this.suggestionDialog = false
+        this.hasValidated = false
+        this.$snackbar.success('建议评审完成,已应用到当前编辑草稿')
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.suggestionLoading = false
+      }
+    },
+    async exportDocument (format) {
+      try {
+        const response = await exportOntology(this.$route.params.uid, format)
+        saveAs(response.data, response.name || `${this.ontology.code}.${format}`)
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    async importDocument () {
+      this.importing = true
+      try {
+        const extension = this.importFile.name.split('.').pop().toLowerCase()
+        const format = extension === 'json' ? 'json' : 'owl'
+        const formData = new FormData()
+        formData.append('file', this.importFile)
+        const response = await importOntology(formData, format)
+        this.importDialog = false
+        this.$router.push(`/data-governance/ontology/${response.data.ontology_uid}`)
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.importing = false
+      }
+    }
   }
 }
 </script>
+
+<style scoped>
+.ontology-workbench {
+  background: #f5f7fa;
+  min-height: 100%;
+}
+.workbench-header {
+  min-height: 58px;
+  padding: 0 4px;
+}
+.section-nav,
+.validation-panel {
+  min-height: 520px;
+}
+</style>

+ 80 - 0
frontend/tests/ontology-workbench-model.test.mjs

@@ -0,0 +1,80 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+  applySuggestionDecisions,
+  canEditOntology,
+  canPublishOntology,
+  emptyGraph,
+  normalizeGraph,
+  parseRevision,
+  upsertGraphItem
+} from '../src/views/dataGovernance/ontology/model.js'
+
+test('normalizes every ontology graph section without mutating input', () => {
+  const source = { classes: [{ uid: 'customer', name: '客户' }] }
+  const graph = normalizeGraph(source)
+
+  assert.deepEqual(Object.keys(graph), [
+    'classes',
+    'properties',
+    'relations',
+    'constraints',
+    'domain_links',
+    'element_mappings'
+  ])
+  assert.deepEqual(graph.classes, source.classes)
+  assert.notEqual(graph.classes, source.classes)
+  assert.deepEqual(emptyGraph().domain_links, [])
+})
+
+test('upserts an edited graph item by its stable identity', () => {
+  const graph = normalizeGraph({
+    classes: [{ uid: 'customer', name: '客户' }]
+  })
+
+  const updated = upsertGraphItem(
+    graph,
+    'classes',
+    { uid: 'customer', name: '客户主体' }
+  )
+
+  assert.equal(updated.classes.length, 1)
+  assert.equal(updated.classes[0].name, '客户主体')
+})
+
+test('applies accepted and edited suggestions but skips rejected suggestions', () => {
+  const graph = emptyGraph()
+  const suggestions = [
+    { uid: 's1', kind: 'class', payload: { uid: 'customer', name: '客户' } },
+    { uid: 's2', kind: 'class', payload: { uid: 'order', name: '订单' } },
+    { uid: 's3', kind: 'constraint', payload: { uid: 'c1', name: '非空' } }
+  ]
+  const decisions = [
+    { suggestion_uid: 's1', decision: 'accept' },
+    { suggestion_uid: 's2', decision: 'reject' },
+    {
+      suggestion_uid: 's3',
+      decision: 'edit',
+      payload: { uid: 'c1', name: '客户编号非空' }
+    }
+  ]
+
+  const updated = applySuggestionDecisions(graph, suggestions, decisions)
+
+  assert.deepEqual(updated.classes.map(item => item.uid), ['customer'])
+  assert.equal(updated.constraints[0].name, '客户编号非空')
+})
+
+test('permissions separate edit and publish capabilities', () => {
+  assert.equal(canEditOntology(['governance:read']), false)
+  assert.equal(canEditOntology(['ontologies:edit']), true)
+  assert.equal(canPublishOntology(['ontologies:edit']), false)
+  assert.equal(canPublishOntology(['ontologies:publish']), true)
+})
+
+test('parses weak and quoted etag revisions safely', () => {
+  assert.equal(parseRevision('"4"'), 4)
+  assert.equal(parseRevision('W/"7"'), 7)
+  assert.equal(parseRevision(undefined), 0)
+})

+ 17 - 1
tests/data_research/test_data_element_api.py

@@ -24,6 +24,10 @@ class FakeElementService:
         self.actions.append(("create", payload, actor_uid))
         return self.record
 
+    def list(self, *, status=None, business_domain_uid=None):
+        self.actions.append(("list", status, business_domain_uid))
+        return [replace(self.record, status="published")]
+
     def transition(self, uid, target_status, *, expected_version, actor_uid):
         self.actions.append(("transition", uid, target_status, expected_version, actor_uid))
         return replace(
@@ -112,6 +116,19 @@ def test_editor_creates_draft_and_submits_review(client):
     )
 
 
+def test_viewer_lists_published_elements_for_business_domain(client):
+    http, elements, _decisions = client
+    listed = http.get(
+        "/api/development/v1/data-elements"
+        "?status=published&business_domain_uid=domain-1",
+        headers={"Authorization": "Bearer editor"},
+    )
+
+    assert listed.status_code == 200
+    assert listed.get_json()["data"]["records"][0]["uid"] == "element-1"
+    assert elements.actions[-1] == ("list", "published", "domain-1")
+
+
 def test_only_publisher_can_publish(client):
     http, _elements, _decisions = client
     payload = {"target_status": "published", "expected_version": 2}
@@ -143,4 +160,3 @@ def test_candidate_decisions_are_editor_gated(client):
     assert response.status_code == 200
     assert response.get_json()["data"][0]["evidence_uids"] == ["evidence-1"]
     assert decisions.calls[0][1] == "editor-1"
-

+ 39 - 0
tests/data_research/test_data_element_lifecycle.py

@@ -15,6 +15,19 @@ class MemoryElementRepository:
     def get_by_code(self, code):
         return next((item for item in self.records.values() if item.code == code), None)
 
+    def list(self, *, status=None, business_domain_uid=None):
+        records = list(self.records.values())
+        if status:
+            records = [item for item in records if item.status == status]
+        if business_domain_uid:
+            records = [
+                item
+                for item in records
+                if business_domain_uid
+                in set(item.snapshot.get("business_domain_uids") or [])
+            ]
+        return records
+
     def save(self, record):
         self.records[record.uid] = record
         return record
@@ -111,3 +124,29 @@ def test_invalid_lifecycle_transition_is_rejected():
             actor_uid="admin-1",
         )
 
+
+def test_elements_can_be_filtered_for_ontology_mapping():
+    from app.core.data_research.data_elements import DataElementService
+
+    repository = MemoryElementRepository()
+    service = DataElementService(
+        repository, uid_factory=iter(("element-1", "element-2")).__next__
+    )
+    first = service.create_draft(valid_payload(), actor_uid="editor-1")
+    service.transition(
+        first.uid, "in_review", expected_version=1, actor_uid="editor-1"
+    )
+    service.transition(
+        first.uid, "published", expected_version=2, actor_uid="admin-1"
+    )
+    service.create_draft(
+        valid_payload(code="OTHER_ID", business_domain_uids=["domain-2"]),
+        actor_uid="editor-1",
+    )
+
+    assert [
+        item.uid
+        for item in service.list(
+            status="published", business_domain_uid="domain-1"
+        )
+    ] == ["element-1"]

+ 39 - 2
tests/data_research/test_ontology_api.py

@@ -29,6 +29,18 @@ class FakeOntologyService:
     def list(self):
         return [self.ontology]
 
+    def get(self, uid):
+        assert uid == self.ontology.uid
+        return self.ontology
+
+    def list_versions(self, uid):
+        assert uid == self.ontology.uid
+        return [self.version]
+
+    def latest_version(self, uid):
+        assert uid == self.ontology.uid
+        return self.version
+
     def create(self, payload, actor_uid):
         self.calls.append(("create", payload, actor_uid))
         return self.ontology
@@ -89,6 +101,32 @@ def test_viewer_is_read_only_and_multi_domain_roles_are_returned(client):
     assert denied.status_code == 403
 
 
+def test_viewer_loads_ontology_detail_versions_and_latest_graph(client):
+    http, _service = client
+    headers = {"Authorization": "Bearer viewer"}
+
+    detail = http.get(
+        "/api/development/v1/ontologies/ontology-1", headers=headers
+    )
+    versions = http.get(
+        "/api/development/v1/ontologies/ontology-1/versions", headers=headers
+    )
+    graph = http.get(
+        "/api/development/v1/ontologies/ontology-1/graph", headers=headers
+    )
+
+    assert detail.status_code == 200
+    assert detail.get_json()["data"]["code"] == "CUSTOMER"
+    assert versions.status_code == 200
+    assert versions.get_json()["data"][0]["uid"] == "version-1"
+    assert graph.status_code == 200
+    assert graph.headers["ETag"] == '"0"'
+    assert graph.get_json()["data"]["graph_document"]["domain_links"][0] == {
+        "domain_uid": "domain-1",
+        "role": "owner",
+    }
+
+
 def test_editor_saves_etag_draft_but_cannot_publish(client):
     http, service = client
     headers = {"Authorization": "Bearer editor", "If-Match": '"0"'}
@@ -123,9 +161,8 @@ def test_admin_validates_publishes_diffs_and_rolls_back(client):
         headers=headers,
         json={"target_version_uid": "version-1", "expected_revision": 1},
     )
-    assert validation.status_code == 422
+    assert validation.status_code == 200
     assert validation.get_json()["data"][0]["code"] == "ONTOLOGY_OWNER_REQUIRED"
     assert published.get_json()["data"]["status"] == "published"
     assert diffed.status_code == 200
     assert rolled_back.get_json()["data"]["parent_version_uid"] == "version-1"
-

+ 43 - 3
tests/data_research/test_ontology_dynamic_api.py

@@ -1,11 +1,23 @@
 from __future__ import annotations
 
+import io
+
 import pytest
 
 
 class DynamicService:
     def generate(self, ontology_uid, payload, actor_uid):
-        return [{"uid": "suggestion-1", "kind": "class", "confidence": 0.95, "evidence_uids": ["domain-1"]}]
+        return {
+            "change_set_uid": "change-1",
+            "suggestions": [
+                {
+                    "uid": "suggestion-1",
+                    "kind": "class",
+                    "confidence": 0.95,
+                    "evidence_uids": ["domain-1"],
+                }
+            ],
+        }
 
     def decide(self, ontology_uid, change_set_uid, decisions, actor_uid):
         return {"uid": change_set_uid, "ontology_uid": ontology_uid, "decisions": decisions, "actor_uid": actor_uid}
@@ -17,7 +29,13 @@ class ExchangeService:
         return b'{"ontology_uid":"ontology-1"}', "application/json", "ontology-1.json"
 
     def import_document(self, content, format_name, actor_uid):
-        return {"ontology_uid": "ontology-2", "version": 1, "format": format_name, "actor_uid": actor_uid}
+        return {
+            "ontology_uid": "ontology-2",
+            "version": 1,
+            "format": format_name,
+            "actor_uid": actor_uid,
+            "content": content.decode("utf-8"),
+        }
 
 
 class SemanticService:
@@ -58,7 +76,10 @@ def test_dynamic_suggestion_and_audited_decision_endpoints(client):
         headers=headers,
         json={"decisions": [{"suggestion_uid": "suggestion-1", "decision": "accept"}]},
     )
-    assert generated.get_json()["data"][0]["evidence_uids"] == ["domain-1"]
+    assert generated.get_json()["data"]["change_set_uid"] == "change-1"
+    assert generated.get_json()["data"]["suggestions"][0]["evidence_uids"] == [
+        "domain-1"
+    ]
     assert decided.get_json()["data"]["actor_uid"] == "editor-1"
 
 
@@ -80,3 +101,22 @@ def test_exchange_and_read_only_semantic_query_endpoints(client):
     assert exported.status_code == 200 and exported.mimetype == "application/json"
     assert imported.status_code == 201
     assert queried.get_json()["data"]["items"][0]["business_domain_uid"] == "sales"
+
+
+def test_ontology_import_accepts_browser_multipart_upload(client):
+    imported = client.post(
+        "/api/development/v1/ontologies/import?format=json",
+        headers={"Authorization": "Bearer editor"},
+        data={
+            "file": (
+                io.BytesIO(b'{"ontology_uid":"ontology-2"}'),
+                "ontology.json",
+            )
+        },
+        content_type="multipart/form-data",
+    )
+
+    assert imported.status_code == 201
+    assert imported.get_json()["data"]["content"] == (
+        '{"ontology_uid":"ontology-2"}'
+    )

+ 75 - 4
tests/data_research/test_ontology_frontend_contract.py

@@ -7,18 +7,89 @@ ROOT = Path(__file__).resolve().parents[2]
 def test_vue2_ontology_center_routes_and_api_contract_exist():
     routes = (ROOT / "frontend/src/router/routes.js").read_text(encoding="utf-8")
     api = (ROOT / "frontend/src/api/dataDevelopment.js").read_text(encoding="utf-8")
+    request = (ROOT / "frontend/src/utils/request.js").read_text(encoding="utf-8")
     index = (ROOT / "frontend/src/views/dataGovernance/ontology/index.vue").read_text(encoding="utf-8")
-    workbench = (ROOT / "frontend/src/views/dataGovernance/ontology/workbench.vue").read_text(encoding="utf-8")
+    workbench = "".join(
+        path.read_text(encoding="utf-8")
+        for path in (
+            ROOT / "frontend/src/views/dataGovernance/ontology/workbench.vue",
+            ROOT
+            / "frontend/src/views/dataGovernance/ontology/components/GraphSectionEditor.vue",
+            ROOT
+            / "frontend/src/views/dataGovernance/ontology/components/VersionWorkspace.vue",
+            ROOT
+            / "frontend/src/views/dataGovernance/ontology/components/SuggestionReviewDialog.vue",
+        )
+    )
 
     assert "ontologyCenter" in routes
     assert "dataGovernance/ontology/index" in routes
     assert "dataGovernance/ontology/workbench" in routes
-    for name in ("getOntologies", "createOntology", "validateOntology", "publishOntology", "rollbackOntology"):
+    workbench_route = routes[routes.index("path: '/data-governance/ontology/:uid'") :]
+    workbench_route = workbench_route[: workbench_route.index("alwaysShow: 0")]
+    assert "roles: ['viewer', 'editor', 'admin']" in workbench_route
+    for name in (
+        "getOntologies",
+        "getOntology",
+        "getOntologyGraph",
+        "getOntologyVersions",
+        "createOntology",
+        "validateOntology",
+        "publishOntology",
+        "diffOntology",
+        "rollbackOntology",
+        "getDataElements",
+        "reviewOntologyChangeSet",
+        "importOntology",
+        "exportOntology",
+    ):
         assert name in api
+    assert "patch (url, params, config = {})" in request
+    assert request.count("...(config.headers || {})") >= 2
     assert "本体中心" in index
-    assert "版本差异" in workbench
-    assert "发布确认" in workbench
+    assert "选择业务域" in index
+    for label in (
+        "概念",
+        "属性",
+        "关系",
+        "约束",
+        "业务域",
+        "数据元素映射",
+        "版本历史",
+        "版本差异",
+        "动态建议",
+        "导入",
+        "导出",
+        "发布确认",
+        "高级 JSON",
+    ):
+        assert label in workbench
+    assert "canEditOntology" in workbench
+    assert "canPublishOntology" in workbench
+    assert "loadOntology" in workbench
+    assert "loadVersions" in workbench
+    assert "'$route.params.uid'" in workbench
     combined = api + index + workbench
     assert "MINIO_PASSWORD" not in combined
     assert "NEO4J_PASSWORD" not in combined
     assert "DEEPSEEK_API_KEY" not in combined
+
+
+def test_ontology_workbench_uses_focused_editor_components():
+    component_root = (
+        ROOT / "frontend/src/views/dataGovernance/ontology/components"
+    )
+    editor = (component_root / "GraphSectionEditor.vue").read_text(
+        encoding="utf-8"
+    )
+    versions = (component_root / "VersionWorkspace.vue").read_text(
+        encoding="utf-8"
+    )
+    suggestions = (component_root / "SuggestionReviewDialog.vue").read_text(
+        encoding="utf-8"
+    )
+
+    assert "新增" in editor and "编辑" in editor and "删除" in editor
+    assert "查看差异" in versions and "回滚" in versions
+    assert "置信度" in suggestions and "证据" in suggestions
+    assert "接受" in suggestions and "拒绝" in suggestions

+ 27 - 0
tests/data_research/test_ontology_repository.py

@@ -69,3 +69,30 @@ def test_published_version_is_immutable_and_hash_is_canonical():
     )
     assert next_version.parent_version_uid == published.uid
     assert next_version.version == 2
+
+
+def test_versions_are_listed_newest_first():
+    from app.core.data_research.ontology.repository import MemoryOntologyRepository
+
+    ids = iter(("ontology-1", "version-1", "version-2"))
+    repository = MemoryOntologyRepository(uid_factory=ids.__next__)
+    ontology = repository.create(
+        code="CUSTOMER",
+        name="客户",
+        owner_uid="owner-1",
+        domain_links=graph()["domain_links"],
+    )
+    repository.save_draft(
+        ontology.uid, graph(), expected_revision=0, actor_uid="editor-1"
+    )
+    repository.save_draft(
+        ontology.uid,
+        graph("CustomerAccount"),
+        expected_revision=1,
+        actor_uid="editor-1",
+    )
+
+    assert [item.uid for item in repository.list_versions(ontology.uid)] == [
+        "version-2",
+        "version-1",
+    ]

+ 64 - 5
tests/integration/test_data_research_docker_api.py

@@ -38,6 +38,7 @@ def test_file_ingestion_and_dynamic_ontology_publish_through_live_api():
     owner_domain_uid = str(uuid.uuid4())
     contributor_domain_uid = str(uuid.uuid4())
     ontology_uid = None
+    imported_ontology_uid = None
     artifact_uid = None
     artifact_object_key = None
 
@@ -175,6 +176,33 @@ def test_file_ingestion_and_dynamic_ontology_publish_through_live_api():
             200,
         )
         assert draft["version"] == 1
+        detail = _data(
+            requests.get(
+                f"{base_url}/development/v1/ontologies/{ontology_uid}",
+                headers=headers,
+                timeout=20,
+            ),
+            200,
+        )
+        assert detail["uid"] == ontology_uid
+        assert detail["draft_revision"] == 1
+        graph_response = requests.get(
+            f"{base_url}/development/v1/ontologies/{ontology_uid}/graph",
+            headers=headers,
+            timeout=20,
+        )
+        graph_snapshot = _data(graph_response, 200)
+        assert graph_response.headers["ETag"] == '"1"'
+        assert graph_snapshot["graph_document"]["classes"][0]["uid"] == "customer"
+        versions = _data(
+            requests.get(
+                f"{base_url}/development/v1/ontologies/{ontology_uid}/versions",
+                headers=headers,
+                timeout=20,
+            ),
+            200,
+        )
+        assert versions[0]["version"] == 1
         assert _data(
             requests.post(
                 f"{base_url}/development/v1/ontologies/{ontology_uid}/validate",
@@ -199,6 +227,32 @@ def test_file_ingestion_and_dynamic_ontology_publish_through_live_api():
         )
         assert exported.status_code == 200
         assert exported.json()["graph_document"]["classes"][0]["uid"] == "customer"
+        imported = _data(
+            requests.post(
+                f"{base_url}/development/v1/ontologies/import?format=json",
+                headers=headers,
+                files={
+                    "file": (
+                        "ontology.json",
+                        exported.content,
+                        "application/json",
+                    )
+                },
+                timeout=20,
+            ),
+            201,
+        )
+        imported_ontology_uid = imported["ontology_uid"]
+        imported_graph = _data(
+            requests.get(
+                f"{base_url}/development/v1/ontologies/"
+                f"{imported_ontology_uid}/graph",
+                headers=headers,
+                timeout=20,
+            ),
+            200,
+        )
+        assert imported_graph["graph_document"] == graph
 
         with engine.connect() as connection:
             artifact_object_key = connection.execute(
@@ -219,25 +273,30 @@ def test_file_ingestion_and_dynamic_ontology_publish_through_live_api():
         assert outbox_payload["ontology_uid"] == ontology_uid
     finally:
         with engine.begin() as connection:
-            if ontology_uid:
+            for cleanup_ontology_uid in (
+                imported_ontology_uid,
+                ontology_uid,
+            ):
+                if not cleanup_ontology_uid:
+                    continue
                 connection.execute(
                     text(
                         "DELETE FROM public.outbox_consumptions WHERE event_id IN "
                         "(SELECT event_id FROM public.outbox_events "
                         "WHERE aggregate_id = :uid)"
                     ),
-                    {"uid": ontology_uid},
+                    {"uid": cleanup_ontology_uid},
                 )
                 connection.execute(
                     text("DELETE FROM public.outbox_events WHERE aggregate_id = :uid"),
-                    {"uid": ontology_uid},
+                    {"uid": cleanup_ontology_uid},
                 )
                 connection.execute(
                     text(
                         "UPDATE public.ontologies SET active_version_uid = NULL "
                         "WHERE uid = CAST(:uid AS uuid)"
                     ),
-                    {"uid": ontology_uid},
+                    {"uid": cleanup_ontology_uid},
                 )
                 for table in (
                     "ontology_publish_runs",
@@ -254,7 +313,7 @@ def test_file_ingestion_and_dynamic_ontology_publish_through_live_api():
                             else "DELETE FROM public.ontologies "
                             "WHERE uid = CAST(:uid AS uuid)"
                         ),
-                        {"uid": ontology_uid},
+                        {"uid": cleanup_ontology_uid},
                     )
             connection.execute(
                 text(

+ 14 - 0
tests/test_permission_matrix.py

@@ -42,6 +42,20 @@ def test_data_development_paths_have_specific_write_policies():
     ) == ("data-elements:edit",)
 
 
+def test_business_domain_read_endpoints_are_available_to_viewers():
+    from app.core.system.permissions import permission_for_request
+
+    assert permission_for_request("/api/bd/list", "POST") == (
+        "governance:read",
+    )
+    assert permission_for_request("/api/bd/detail", "POST") == (
+        "governance:read",
+    )
+    assert permission_for_request("/api/bd/save", "POST") == (
+        "governance:edit",
+    )
+
+
 def test_permissions_are_derived_server_side_from_roles():
     from app.core.system.permissions import permissions_for_roles