Просмотр исходного кода

feat: assemble production lines from published rule assets

马小龙 4 недель назад
Родитель
Сommit
f31cc2e857

+ 77 - 23
app/api/data_rules/routes.py

@@ -82,7 +82,9 @@ def _release_service() -> ProductionLineReleaseService:
     resolver = current_app.extensions.get("data_rule_schema_resolver")
     if resolver is None:
         catalog = current_app.extensions.get("data_rule_metadata_catalog")
-        resolver = SchemaResolver(catalog or Neo4jSchemaMetadataCatalog(), repository)
+        resolver = SchemaResolver(
+            catalog or Neo4jSchemaMetadataCatalog(), repository
+        )
     return ProductionLineReleaseService(repository, schema_resolver=resolver)
 
 
@@ -244,9 +246,7 @@ def _authoring_agent() -> RuleAuthoringAgent:
 @bp.post("/interpret")
 def interpret_rule():
     try:
-        body = _closed_body(
-            {"source_text", "authoring_surface", "context"}
-        )
+        body = _closed_body({"source_text", "authoring_surface", "context"})
         receipt_signer = _receipt_signer()
         repository = _repository()
         validation_context = repository.resolve_validation_context(
@@ -290,8 +290,7 @@ def interpret_rule():
                 prompt_hash=result.get("prompt_hash")
                 or _metadata_hash(result.get("prompt_version", "unknown")),
                 context_hash=result["context_hash"],
-                expires_at=datetime.now(UTC)
-                + timedelta(minutes=10),
+                expires_at=datetime.now(UTC) + timedelta(minutes=10),
             )
             result["generation_receipt"] = receipt_signer.issue(claims)
         db.session.commit()
@@ -347,7 +346,9 @@ def create_rule_version():
 @bp.post("/rule-versions/<version_id>/publish")
 def publish_rule_version(version_id: str):
     try:
-        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+        if request.get_data(cache=True) and request.get_json(
+            silent=True
+        ) not in (
             None,
             {},
         ):
@@ -369,7 +370,9 @@ def publish_rule_version(version_id: str):
 @bp.post("/rule-versions/<version_id>/validate")
 def validate_rule_version(version_id: str):
     try:
-        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+        if request.get_data(cache=True) and request.get_json(
+            silent=True
+        ) not in (
             None,
             {},
         ):
@@ -412,40 +415,85 @@ def test_rule_version(version_id: str):
 @bp.get("/rule-versions/<version_id>/evidence")
 def rule_version_evidence(version_id: str):
     try:
-        return jsonify(success(_publication_service().evidence(version_id)))
+        return jsonify(
+            success(
+                _repository().get_asset_evidence(
+                    asset_type="rule", version_id=version_id
+                )
+            )
+        )
     except (TypeError, ValueError):
         return jsonify(failed("规则证据不存在", code=404)), 404
-    except RuntimeError:
-        return jsonify(failed("规则验证服务未配置", code=503)), 503
+    except Exception:
+        current_app.logger.exception("load rule evidence failed")
+        return jsonify(failed("规则证据暂时不可用", code=503)), 503
+
+
+@bp.get("/catalog/assets/<asset_type>/<version_id>/evidence")
+def catalog_asset_evidence(asset_type: str, version_id: str):
+    try:
+        return jsonify(
+            success(
+                _repository().get_asset_evidence(
+                    asset_type=asset_type,
+                    version_id=version_id,
+                )
+            )
+        )
+    except (TypeError, ValueError):
+        return jsonify(failed("资产证据不存在", code=404)), 404
+    except Exception:
+        current_app.logger.exception("load catalog asset evidence failed")
+        return jsonify(failed("资产证据暂时不可用", code=503)), 503
 
 
 @bp.get("/catalog")
 @bp.get("/catalog/rule-versions")
 def published_rule_catalog():
     try:
-        if set(request.args) - {"query", "limit"}:
+        legacy_rule_alias = request.path.endswith("/rule-versions")
+        allowed = {"query", "limit", "offset"}
+        if not legacy_rule_alias:
+            allowed.add("asset_type")
+        if set(request.args) - allowed:
             raise ValueError("catalog query contains unsupported fields")
         query = request.args.get("query", "")
         limit = int(request.args.get("limit", "50"))
+        offset = int(request.args.get("offset", "0"))
+        asset_type = (
+            "rule"
+            if legacy_rule_alias
+            else request.args.get("asset_type") or None
+        )
+        if asset_type not in {None, "rule", "standard"}:
+            raise ValueError("catalog asset_type is invalid")
+        if len(query) > 200 or limit < 1 or limit > 100:
+            raise ValueError("catalog bounds are invalid")
+        if offset < 0 or offset > 1_000_000:
+            raise ValueError("catalog offset is invalid")
         return jsonify(
             success(
-                {
-                    "items": _publication_service().catalog(
-                        query=query, limit=limit
-                    )
-                }
+                _repository().search_published_assets(
+                    query=query,
+                    asset_type=asset_type,
+                    limit=limit,
+                    offset=offset,
+                )
             )
         )
     except (TypeError, ValueError):
         return _bad_request("规则目录查询无效")
-    except RuntimeError:
-        return jsonify(failed("规则目录服务未配置", code=503)), 503
+    except Exception:
+        current_app.logger.exception("load rule catalog failed")
+        return jsonify(failed("规则目录暂时不可用", code=503)), 503
 
 
 @bp.post("/execution-plans/<plan_id>/validate")
 def validate_physical_plan(plan_id: str):
     try:
-        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+        if request.get_data(cache=True) and request.get_json(
+            silent=True
+        ) not in (
             None,
             {},
         ):
@@ -466,7 +514,9 @@ def validate_physical_plan(plan_id: str):
 @bp.post("/execution-plans/<plan_id>/test")
 def test_physical_plan(plan_id: str):
     try:
-        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+        if request.get_data(cache=True) and request.get_json(
+            silent=True
+        ) not in (
             None,
             {},
         ):
@@ -487,7 +537,9 @@ def test_physical_plan(plan_id: str):
 @bp.post("/execution-plans/<plan_id>/publish")
 def publish_physical_plan(plan_id: str):
     try:
-        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+        if request.get_data(cache=True) and request.get_json(
+            silent=True
+        ) not in (
             None,
             {},
         ):
@@ -528,7 +580,9 @@ def create_standard_version():
 @bp.post("/standard-versions/<version_id>/publish")
 def publish_standard_version(version_id: str):
     try:
-        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+        if request.get_data(cache=True) and request.get_json(
+            silent=True
+        ) not in (
             None,
             {},
         ):

Разница между файлами не показана из-за своего большого размера
+ 599 - 183
app/core/data_rules/repository.py


+ 26 - 0
frontend/src/api/dataRules.js

@@ -24,6 +24,32 @@ export function publishRuleVersion (versionId) {
   return http.post(`/rules/rule-versions/${versionId}/publish`)
 }
 
+export function validateRuleVersion (versionId) {
+  return http.post(`/rules/rule-versions/${versionId}/validate`, {})
+}
+
+export function testRuleVersion (versionId, planId) {
+  return http.post(`/rules/rule-versions/${versionId}/test`, {
+    plan_id: planId
+  })
+}
+
+export function getRuleVersionEvidence (versionId, assetType = 'rule') {
+  return http.get(`/rules/catalog/assets/${assetType}/${versionId}/evidence`)
+}
+
+export function searchPublishedCatalog (params = {}) {
+  const query = {
+    query: params.query || '',
+    limit: params.limit || 20,
+    offset: params.offset || 0
+  }
+  if (params.asset_type && params.asset_type !== 'all') {
+    query.asset_type = params.asset_type
+  }
+  return http.get('/rules/catalog', query)
+}
+
 export function createStandardVersion (payload) {
   return http.post('/rules/standard-versions', payload)
 }

+ 178 - 0
frontend/src/components/DataRules/CompilationEvidence.vue

@@ -0,0 +1,178 @@
+<template>
+  <section class="evidence-panel">
+    <div class="d-flex align-center justify-space-between">
+      <div>
+        <div class="evidence-panel__title">可信编译与测试证据</div>
+        <div class="evidence-panel__hint">只展示服务端签发的摘要与生命周期状态。</div>
+      </div>
+      <v-btn icon small :loading="loading" aria-label="刷新规则证据" @click="load">
+        <v-icon small>mdi-refresh</v-icon>
+      </v-btn>
+    </div>
+
+    <v-skeleton-loader v-if="loading" class="mt-3" type="list-item-three-line@2" />
+    <v-alert v-else-if="errorMessage" class="mt-3 mb-0" type="error" text dense>
+      {{ errorMessage }}
+    </v-alert>
+    <v-alert v-else-if="!versionId" class="mt-3 mb-0" type="info" text dense>
+      选择固定版本后可查看证据链。
+    </v-alert>
+    <div v-else class="evidence-panel__timeline mt-4">
+      <div
+        v-for="stage in stages"
+        :key="stage.key"
+        class="evidence-panel__stage"
+      >
+        <v-icon :color="stage.color" small>{{ stage.icon }}</v-icon>
+        <div class="ml-3">
+          <div class="d-flex align-center">
+            <strong>{{ stage.label }}</strong>
+            <v-chip class="ml-2" x-small :color="stage.color" outlined>
+              {{ stage.status }}
+            </v-chip>
+          </div>
+          <div class="evidence-panel__detail">{{ stage.detail }}</div>
+          <div v-if="stage.digest" class="evidence-panel__digest">
+            摘要 {{ compactDigest(stage.digest) }}
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script>
+import { getRuleVersionEvidence } from '@/api/dataRules'
+
+export default {
+  name: 'CompilationEvidence',
+  props: {
+    versionId: {
+      type: String,
+      default: null
+    },
+    assetType: {
+      type: String,
+      default: 'rule'
+    }
+  },
+  data () {
+    return {
+      loading: false,
+      errorMessage: '',
+      evidence: null
+    }
+  },
+  computed: {
+    stages () {
+      const value = this.evidence || {}
+      return [
+        this.stage('generation', 'AI 生成凭据', value.generation),
+        this.stage('logical_compile', '逻辑计划编译', value.logical_compile),
+        this.stage('dry_run', '隔离样本测试', value.dry_run),
+        this.stage('publication', '版本发布审计', value.publication),
+        this.physicalStage(value.physical)
+      ]
+    }
+  },
+  watch: {
+    versionId: {
+      immediate: true,
+      handler () {
+        this.load()
+      }
+    }
+  },
+  methods: {
+    async load () {
+      if (!this.versionId) {
+        this.evidence = null
+        return
+      }
+      this.loading = true
+      this.errorMessage = ''
+      try {
+        const { data } = await getRuleVersionEvidence(this.versionId, this.assetType)
+        this.evidence = data.stages || data
+      } catch (error) {
+        this.evidence = null
+        this.errorMessage = error?.message || String(error || '证据加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    stage (key, label, value = null) {
+      const state = value?.status || 'pending'
+      const successful = ['success', 'published', 'tested', 'compiled'].includes(state)
+      return {
+        key,
+        label,
+        status: successful ? '可信' : state === 'failed' ? '失败' : '待完成',
+        color: successful ? 'success' : state === 'failed' ? 'error' : 'grey',
+        icon: successful ? 'mdi-check-circle' : state === 'failed' ? 'mdi-alert-circle' : 'mdi-circle-outline',
+        detail: value?.summary || '尚无服务端证据',
+        digest: value?.digest || value?.plan_hash || null
+      }
+    },
+    physicalStage (plans = []) {
+      const values = Array.isArray(plans) ? plans : []
+      const trusted = values.filter(plan => (
+        plan.compile?.status === 'success' &&
+        plan.test?.status === 'success'
+      ))
+      return {
+        key: 'physical',
+        label: '物理执行计划',
+        status: trusted.length ? '可信' : values.length ? '待完成' : '待绑定',
+        color: trusted.length ? 'success' : 'grey',
+        icon: trusted.length ? 'mdi-check-circle' : 'mdi-circle-outline',
+        detail: values.length
+          ? `${trusted.length} / ${values.length} 个计划完成编译与预检`
+          : '发布到数据工厂后生成物理计划',
+        digest: trusted[0]?.plan_hash || null
+      }
+    },
+    compactDigest (value) {
+      const digest = String(value || '')
+      return digest.length > 18 ? `${digest.slice(0, 10)}…${digest.slice(-8)}` : digest
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.evidence-panel {
+  border: 1px solid #d8e1ea;
+  border-radius: 6px;
+  padding: 14px;
+  background: #fbfcfe;
+}
+.evidence-panel__title {
+  color: #19344e;
+  font-weight: 600;
+}
+.evidence-panel__hint,
+.evidence-panel__detail {
+  color: #63778b;
+  font-size: 12px;
+}
+.evidence-panel__stage {
+  display: flex;
+  min-height: 66px;
+  position: relative;
+}
+.evidence-panel__stage:not(:last-child)::before {
+  background: #d8e1ea;
+  content: "";
+  height: 40px;
+  left: 9px;
+  position: absolute;
+  top: 23px;
+  width: 1px;
+}
+.evidence-panel__digest {
+  color: #60758a;
+  font-family: monospace;
+  font-size: 11px;
+}
+</style>

+ 355 - 0
frontend/src/components/DataRules/ProductionLineAssembler.vue

@@ -0,0 +1,355 @@
+<template>
+  <section class="assembler">
+    <div class="assembler__header">
+      <div>
+        <div class="assembler__title">数据生产线工位编排</div>
+        <div class="assembler__hint">
+          每个工位固定引用已发布的标准或规则版本;投产时不会解析自由文本。
+        </div>
+      </div>
+      <v-chip :color="releaseReadiness.ready ? 'success' : 'warning'" outlined small>
+        {{ releaseReadiness.ready ? 'Release ready' : '存在阻断项' }}
+      </v-chip>
+    </div>
+
+    <v-alert
+      v-if="releaseReadiness.blockers.length"
+      class="mt-4 mb-0"
+      type="warning"
+      text
+      dense
+    >
+      <div class="font-weight-medium">发布前请处理:</div>
+      <div v-for="blocker in releaseReadiness.blockers" :key="blocker">
+        · {{ blocker }}
+      </div>
+    </v-alert>
+
+    <div class="assembler__datasets mt-4">
+      <div>
+        <span class="assembler__eyebrow">输入数据集边</span>
+        <div>{{ inputSchemaRefs.length ? inputSchemaRefs.join('、') : '尚未选择' }}</div>
+      </div>
+      <v-icon color="primary">mdi-arrow-right</v-icon>
+      <div>
+        <span class="assembler__eyebrow">输出数据集边</span>
+        <div>{{ outputSchemaRef || '尚未选择' }}</div>
+      </div>
+    </div>
+
+    <div
+      v-for="(station, index) in stations"
+      :key="station.local_id"
+      class="assembler__station mt-4"
+    >
+      <div class="assembler__station-number">{{ index + 1 }}</div>
+      <div class="assembler__station-content">
+        <div class="d-flex align-center">
+          <v-select
+            v-model="station.component_kind"
+            class="assembler__kind"
+            :items="componentKinds"
+            item-text="label"
+            item-value="value"
+            dense
+            outlined
+            hide-details
+            label="工位类型"
+            @change="resetStationAsset(station)"
+          />
+          <v-select
+            v-model="station.stage"
+            class="assembler__stage ml-3"
+            :items="stages"
+            dense
+            outlined
+            hide-details
+            label="生产阶段"
+            @change="emitValue"
+          />
+          <v-spacer />
+          <v-btn icon small :disabled="index === 0" aria-label="工位前移" @click="move(index, -1)">
+            <v-icon small>mdi-arrow-up</v-icon>
+          </v-btn>
+          <v-btn icon small :disabled="index === stations.length - 1" aria-label="工位后移" @click="move(index, 1)">
+            <v-icon small>mdi-arrow-down</v-icon>
+          </v-btn>
+          <v-btn icon small color="error" aria-label="删除工位" @click="remove(index)">
+            <v-icon small>mdi-delete-outline</v-icon>
+          </v-btn>
+        </div>
+
+        <rule-catalog-picker
+          class="mt-3"
+          :value="stationAssetId(station)"
+          :asset-type="station.component_kind === 'standard.enforce' ? 'standard' : 'rule'"
+          :label="station.component_kind === 'standard.enforce' ? '选择已发布标准版本' : '选择已发布规则版本'"
+          @input="setStationAsset(station, $event)"
+          @select="setStationEvidence(station, $event)"
+        />
+
+        <div v-if="station.selected_asset" class="assembler__asset-summary mt-3">
+          <v-icon
+            small
+            :color="station.selected_asset.schema_compatibility === 'compatible' ? 'success' : 'warning'"
+          >
+            mdi-database-check
+          </v-icon>
+          <span class="ml-2">
+            Schema compatibility:
+            {{ station.selected_asset.schema_compatibility || 'unknown' }}
+          </span>
+          <span class="ml-4">
+            {{ station.selected_asset.latest_evidence?.test_status === 'success'
+              ? '编译/测试证据有效'
+              : '证据未满足发布要求' }}
+          </span>
+        </div>
+      </div>
+    </div>
+
+    <v-btn class="mt-4" color="primary" outlined @click="addStation">
+      <v-icon left small>mdi-plus</v-icon>
+      添加生产工位
+    </v-btn>
+  </section>
+</template>
+
+<script>
+import RuleCatalogPicker from './RuleCatalogPicker'
+
+let localSequence = 0
+
+export default {
+  name: 'ProductionLineAssembler',
+  components: { RuleCatalogPicker },
+  props: {
+    value: {
+      type: Object,
+      default: () => ({})
+    },
+    dataflowUid: {
+      type: String,
+      default: null
+    },
+    name: {
+      type: String,
+      default: '未命名数据生产线'
+    },
+    inputSchemaRefs: {
+      type: Array,
+      default: () => []
+    },
+    outputSchemaRef: {
+      type: String,
+      default: null
+    }
+  },
+  data () {
+    return {
+      componentKinds: [
+        { label: '标准质检工位', value: 'standard.enforce' },
+        { label: '规则加工工位', value: 'rule.apply' },
+        { label: '质量门禁工位', value: 'quality.check' }
+      ],
+      stages: ['extract', 'normalize', 'transform', 'quality_gate', 'write', 'publish'],
+      stations: this.readStations(this.value)
+    }
+  },
+  computed: {
+    releaseReadiness () {
+      const blockers = []
+      if (!this.dataflowUid) blockers.push('缺少受治理的数据流 UID')
+      if (!this.inputSchemaRefs.length) blockers.push('至少需要一个输入数据集边')
+      if (!this.outputSchemaRef) blockers.push('需要一个输出数据集边')
+      if (!this.stations.length) blockers.push('至少需要一个生产工位')
+      this.stations.forEach((station, index) => {
+        if (!this.stationAssetId(station)) blockers.push(`工位 ${index + 1} 尚未固定资产版本`)
+        if (this.stationAssetId(station) && !station.selected_asset) {
+          blockers.push(`工位 ${index + 1} 尚未确认目录兼容性与证据`)
+        }
+        if (station.selected_asset && station.selected_asset.schema_compatibility !== 'compatible') {
+          blockers.push(`工位 ${index + 1} 的 schema_compatibility 未通过`)
+        }
+        if (station.selected_asset && station.selected_asset.latest_evidence?.test_status !== 'success') {
+          blockers.push(`工位 ${index + 1} 缺少可信测试证据`)
+        }
+      })
+      return { ready: blockers.length === 0, blockers }
+    }
+  },
+  watch: {
+    value: {
+      deep: true,
+      handler (value) {
+        if (!this.stations.length && value?.components?.length) {
+          this.stations = this.readStations(value)
+          this.$nextTick(this.emitValue)
+        }
+      }
+    },
+    dataflowUid: 'emitValue',
+    name: 'emitValue',
+    inputSchemaRefs: {
+      deep: true,
+      handler: 'emitValue'
+    },
+    outputSchemaRef: 'emitValue'
+  },
+  mounted () {
+    this.emitValue()
+  },
+  methods: {
+    readStations (value) {
+      return (value?.components || []).map(component => ({
+        local_id: `station-${++localSequence}`,
+        component_kind: component.type,
+        stage: component.stage,
+        rule_version_id: component.rule_version_id || null,
+        standard_version_id: component.standard_version_id || null,
+        selected_asset: null
+      }))
+    },
+    addStation () {
+      this.stations.push({
+        local_id: `station-${++localSequence}`,
+        component_kind: 'rule.apply',
+        stage: 'transform',
+        rule_version_id: null,
+        standard_version_id: null,
+        selected_asset: null
+      })
+      this.emitValue()
+    },
+    remove (index) {
+      this.stations.splice(index, 1)
+      this.emitValue()
+    },
+    move (index, delta) {
+      const target = index + delta
+      if (target < 0 || target >= this.stations.length) return
+      const [station] = this.stations.splice(index, 1)
+      this.stations.splice(target, 0, station)
+      this.emitValue()
+    },
+    resetStationAsset (station) {
+      station.rule_version_id = null
+      station.standard_version_id = null
+      station.selected_asset = null
+      this.emitValue()
+    },
+    stationAssetId (station) {
+      return station.component_kind === 'standard.enforce'
+        ? station.standard_version_id
+        : station.rule_version_id
+    },
+    setStationAsset (station, versionId) {
+      if (station.component_kind === 'standard.enforce') {
+        station.standard_version_id = versionId
+        station.rule_version_id = null
+      } else {
+        station.rule_version_id = versionId
+        station.standard_version_id = null
+      }
+      this.emitValue()
+    },
+    setStationEvidence (station, item) {
+      station.selected_asset = item
+      this.emitValue()
+    },
+    emitValue () {
+      const components = this.stations.map((station, index) => {
+        const component = {
+          id: `station_${index + 1}`,
+          type: station.component_kind,
+          stage: station.stage,
+          order: index + 1,
+          idempotency: { strategy: 'partition_replace', key: 'run_partition' }
+        }
+        if (station.component_kind === 'standard.enforce') {
+          component.standard_version_id = station.standard_version_id
+        } else {
+          component.rule_version_id = station.rule_version_id
+        }
+        return component
+      })
+      this.$emit('input', {
+        schema_version: '1.0',
+        dataflow_uid: this.dataflowUid,
+        name: this.name || '未命名数据生产线',
+        description: '由数据标准与数据规则固定版本组装的数据生产线',
+        input_schema_refs: [...this.inputSchemaRefs],
+        output_schema_ref: this.outputSchemaRef,
+        components,
+        parameters: {}
+      })
+      this.$emit('readiness', this.releaseReadiness)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.assembler {
+  border: 1px solid #cad7e4;
+  border-radius: 6px;
+  padding: 18px;
+  background: #f8fafc;
+}
+.assembler__header,
+.assembler__datasets {
+  align-items: center;
+  display: flex;
+  justify-content: space-between;
+  gap: 20px;
+}
+.assembler__title {
+  color: #17324d;
+  font-size: 17px;
+  font-weight: 600;
+}
+.assembler__hint,
+.assembler__eyebrow {
+  color: #64788c;
+  font-size: 12px;
+}
+.assembler__datasets {
+  background: #eef4fa;
+  border-radius: 4px;
+  padding: 12px 16px;
+}
+.assembler__datasets > div {
+  flex: 1;
+}
+.assembler__station {
+  display: flex;
+  gap: 14px;
+}
+.assembler__station-number {
+  align-items: center;
+  background: #1976d2;
+  border-radius: 50%;
+  color: #fff;
+  display: flex;
+  flex: 0 0 30px;
+  height: 30px;
+  justify-content: center;
+}
+.assembler__station-content {
+  background: #fff;
+  border: 1px solid #d8e1ea;
+  border-radius: 6px;
+  flex: 1;
+  padding: 14px;
+}
+.assembler__kind {
+  max-width: 210px;
+}
+.assembler__stage {
+  max-width: 180px;
+}
+.assembler__asset-summary {
+  color: #50687e;
+  font-size: 12px;
+}
+</style>

+ 257 - 80
frontend/src/components/DataRules/RuleAuthoringPanel.vue

@@ -4,18 +4,33 @@
       <div>
         <div class="rule-authoring__title">AI 数据规则助手</div>
         <div class="rule-authoring__hint">
-          用自然语言描述约束,平台会生成可校验的规则候选;确认后才会写入当前定义
+          自然语言会被解析成封闭规则,再经过服务端编译、隔离测试和发布门禁
         </div>
       </div>
+      <v-chip :color="schemaContextReady ? 'success' : 'warning'" outlined small>
+        {{ schemaContextReady ? 'Schema 上下文已固定' : '请先选择 Schema 上下文' }}
+      </v-chip>
+    </div>
+
+    <div class="rule-authoring__context mt-4">
+      <div>
+        <span>输入</span>
+        <strong>{{ contextLabel('input') }}</strong>
+      </div>
+      <v-icon small color="primary">mdi-arrow-right</v-icon>
+      <div>
+        <span>输出</span>
+        <strong>{{ contextLabel('output') }}</strong>
+      </div>
       <v-btn
+        v-if="!schemaContextReady"
+        class="ml-auto"
+        text
+        small
         color="primary"
-        depressed
-        :loading="loading"
-        :disabled="!sourceText.trim()"
-        @click="interpret"
+        @click="$emit('request-context')"
       >
-        <v-icon left small>mdi-auto-fix</v-icon>
-        解析规则
+        选择上下文
       </v-btn>
     </div>
 
@@ -30,6 +45,19 @@
       aria-label="自然语言数据规则"
     />
 
+    <div class="d-flex justify-end mt-3">
+      <v-btn
+        color="primary"
+        depressed
+        :loading="loading"
+        :disabled="!sourceText.trim() || !schemaContextReady"
+        @click="interpret"
+      >
+        <v-icon left small>mdi-auto-fix</v-icon>
+        解析约束
+      </v-btn>
+    </div>
+
     <v-alert
       v-if="result && result.status === 'clarification_required'"
       class="mt-4 mb-0"
@@ -37,7 +65,7 @@
       text
       dense
     >
-      规则存在歧义,请补充描述后重新解析。
+      <strong>需要补充约束</strong>
       <ul v-if="ambiguities.length" class="mt-2 mb-0">
         <li v-for="item in ambiguities" :key="item">{{ item }}</li>
       </ul>
@@ -46,47 +74,113 @@
     <div v-if="candidate" class="rule-authoring__candidate mt-4">
       <div class="d-flex align-center justify-space-between">
         <div>
-          <strong>规则候选已生成</strong>
-          <span class="rule-authoring__meta">
-            {{ candidate.candidate_type === 'standard' ? '数据标准' : '数据规则' }}
-          </span>
+          <strong>{{ candidateTitle }}</strong>
+          <div class="rule-authoring__hint">{{ candidate.explanation }}</div>
+        </div>
+        <v-chip small color="primary" outlined>
+          置信度 {{ Math.round(Number(candidate.confidence || 0) * 100) }}%
+        </v-chip>
+      </div>
+
+      <div class="rule-authoring__summary mt-3">
+        <div>
+          <span class="rule-authoring__eyebrow">规则名称</span>
+          <strong>{{ governedName }}</strong>
+        </div>
+        <div>
+          <span class="rule-authoring__eyebrow">处理步骤</span>
+          <strong>{{ governedStepCount }} 个封闭操作</strong>
         </div>
-        <v-btn small color="primary" outlined @click="acceptCandidate">
+        <div>
+          <span class="rule-authoring__eyebrow">生成凭据</span>
+          <strong>{{ result.generation_receipt ? '已签发' : '不适用' }}</strong>
+        </div>
+      </div>
+
+      <v-alert v-if="assumptions.length" class="mt-3 mb-0" type="info" text dense>
+        <strong>AI 假设</strong>
+        <div v-for="item in assumptions" :key="item">· {{ item }}</div>
+      </v-alert>
+      <v-alert v-if="ambiguities.length" class="mt-3 mb-0" type="warning" text dense>
+        <strong>仍需人工确认的歧义</strong>
+        <div v-for="item in ambiguities" :key="item">· {{ item }}</div>
+      </v-alert>
+
+      <div class="rule-authoring__provenance mt-3">
+        <v-icon small color="primary">mdi-shield-check</v-icon>
+        <span class="ml-2">
+          {{ modelProvenance }}
+        </span>
+      </div>
+
+      <div class="d-flex align-center mt-4">
+        <v-btn
+          small
+          color="primary"
+          outlined
+          :disabled="candidateAccepted"
+          @click="acceptCandidate"
+        >
           采用候选
         </v-btn>
-      </div>
-      <pre>{{ candidatePreview }}</pre>
-      <div v-if="candidateAccepted" class="d-flex align-center mt-3">
         <v-btn
+          v-if="candidateAccepted && !versionAsset"
+          class="ml-3"
           small
           color="primary"
           :loading="versionLoading"
-          :disabled="Boolean(versionAsset)"
+          :disabled="!canEdit"
           @click="createGovernedVersion"
         >
-          <v-icon left small>mdi-source-branch</v-icon>
-          创建受治理版本
+          创建草稿版本
         </v-btn>
-        <template v-if="versionAsset">
-          <v-chip class="ml-3" small color="success" outlined>
-            V{{ versionAsset.version_no }} · {{ versionAsset.status }}
-          </v-chip>
-          <span class="rule-authoring__version-id ml-3">
-            {{ versionAsset.id }}
-          </span>
-          <v-btn
-            v-if="canPublish && versionAsset.status === 'validated'"
-            class="ml-auto"
-            small
-            color="success"
-            :loading="publishLoading"
-            @click="publishVersion"
-          >
-            发布版本
-          </v-btn>
-        </template>
       </div>
     </div>
+
+    <div v-if="versionAsset" class="rule-authoring__lifecycle mt-4">
+      <div class="d-flex align-center">
+        <strong>受治理版本 V{{ versionAsset.version_no || 1 }}</strong>
+        <v-chip class="ml-2" small outlined>{{ versionAsset.status }}</v-chip>
+        <span class="rule-authoring__version-id ml-3">{{ versionAsset.id }}</span>
+      </div>
+      <div class="d-flex flex-wrap mt-3">
+        <v-btn
+          small
+          color="primary"
+          :loading="validateLoading"
+          :disabled="!canEdit || versionAsset.status !== 'draft'"
+          @click="validateVersion"
+        >
+          1. 编译验证
+        </v-btn>
+        <v-btn
+          class="ml-2"
+          small
+          color="primary"
+          :loading="testLoading"
+          :disabled="!canEdit || !logicalPlanId || versionAsset.status !== 'draft'"
+          @click="testVersion"
+        >
+          2. 隔离样本测试
+        </v-btn>
+        <v-btn
+          class="ml-2"
+          small
+          color="success"
+          :loading="publishLoading"
+          :disabled="!canPublish || versionAsset.status !== 'validated'"
+          @click="publishVersion"
+        >
+          3. 发布固定版本
+        </v-btn>
+      </div>
+      <compilation-evidence
+        ref="evidence"
+        class="mt-4"
+        :version-id="versionAsset.id"
+        :asset-type="candidate.candidate_type === 'standard' ? 'standard' : 'rule'"
+      />
+    </div>
   </section>
 </template>
 
@@ -96,11 +190,15 @@ import {
   createStandardVersion,
   interpretRule,
   publishRuleVersion,
-  publishStandardVersion
+  publishStandardVersion,
+  testRuleVersion,
+  validateRuleVersion
 } from '@/api/dataRules'
+import CompilationEvidence from './CompilationEvidence'
 
 export default {
   name: 'RuleAuthoringPanel',
+  components: { CompilationEvidence },
   props: {
     authoringSurface: {
       type: String,
@@ -123,8 +221,11 @@ export default {
       result: null,
       candidateAccepted: false,
       versionLoading: false,
+      validateLoading: false,
+      testLoading: false,
       publishLoading: false,
-      versionAsset: null
+      versionAsset: null,
+      logicalPlanId: null
     }
   },
   computed: {
@@ -134,19 +235,54 @@ export default {
         : '例如:按 customer_id 去重保留更新时间最新记录,再校验手机号格式。'
     },
     candidate () {
-      return this.result && this.result.status === 'ready'
-        ? this.result.candidate
-        : null
+      return this.result?.status === 'ready' ? this.result.candidate : null
+    },
+    assumptions () {
+      return this.candidate?.assumptions || []
     },
     ambiguities () {
-      return this.result?.candidate?.ambiguities || []
+      return this.candidate?.ambiguities || []
+    },
+    candidateTitle () {
+      return this.candidate?.candidate_type === 'standard'
+        ? '数据标准候选已生成'
+        : '可执行规则候选已生成'
     },
-    candidatePreview () {
-      return this.candidate ? JSON.stringify(this.candidate, null, 2) : ''
+    governedName () {
+      return this.candidate?.rule_spec?.name || this.candidate?.standard_spec?.name || '未命名候选'
+    },
+    governedStepCount () {
+      if (this.candidate?.rule_spec) return this.candidate.rule_spec.steps?.length || 0
+      return this.candidate?.standard_spec?.clauses?.length || 0
+    },
+    modelProvenance () {
+      if (!this.result) return ''
+      return [
+        this.result.model_provider || 'model-provider',
+        this.result.model_name || 'model',
+        this.result.prompt_version || 'prompt',
+        `repair ${this.result.repair_attempts || 0}`
+      ].join(' · ')
+    },
+    schemaContextReady () {
+      const input = this.context.input_schema_snapshot_id ||
+        this.context.input_schema_ref ||
+        this.context.input ||
+        this.context.source_table_ids?.length
+      const output = this.context.output_schema_snapshot_id ||
+        this.context.output_schema_ref ||
+        this.context.output ||
+        this.context.target_table_ids?.length
+      return Boolean(input && output)
+    },
+    permissions () {
+      return this.$store.getters.userInfo?.permissions || []
+    },
+    canEdit () {
+      return this.permissions.includes('rules:edit')
     },
     canPublish () {
-      const permissions = this.$store.getters.userInfo?.permissions || []
-      return permissions.includes('rules:publish')
+      return this.permissions.includes('rules:publish')
     }
   },
   watch: {
@@ -155,11 +291,19 @@ export default {
     }
   },
   methods: {
+    contextLabel (direction) {
+      const value = direction === 'input'
+        ? this.context.input_schema_snapshot_id || this.context.input_schema_ref || this.context.input || this.context.source_table_ids
+        : this.context.output_schema_snapshot_id || this.context.output_schema_ref || this.context.output || this.context.target_table_ids
+      if (Array.isArray(value)) return value.length ? `${value.length} 个数据集` : '尚未选择'
+      return value || '尚未选择'
+    },
     async interpret () {
       this.loading = true
       this.result = null
       this.candidateAccepted = false
       this.versionAsset = null
+      this.logicalPlanId = null
       try {
         const { data } = await interpretRule({
           source_text: this.sourceText,
@@ -176,7 +320,7 @@ export default {
     acceptCandidate () {
       this.candidateAccepted = true
       this.$emit('candidate', this.candidate, this.sourceText)
-      this.$snackbar.success('规则候选已写入当前定义,请继续检查后提交')
+      this.$snackbar.success('候选已确认,请创建受治理草稿')
     },
     async createGovernedVersion () {
       this.versionLoading = true
@@ -190,19 +334,50 @@ export default {
           : await createRuleVersion({
             source_text: this.sourceText,
             rule_spec: this.candidate.rule_spec,
+            generation_receipt: this.result.generation_receipt,
             category: this.authoringSurface === 'data_standard'
               ? 'standard_clause'
               : 'flow_scoped'
           })
         this.versionAsset = response.data
         this.$emit('version', this.versionAsset)
-        this.$snackbar.success('受治理版本已创建,发布前不会进入生产线')
+        this.$snackbar.success('草稿已创建,尚不可进入生产线')
       } catch (error) {
         this.$snackbar.error(error || '版本创建失败')
       } finally {
         this.versionLoading = false
       }
     },
+    async validateVersion () {
+      if (this.candidate.candidate_type === 'standard') {
+        this.versionAsset = { ...this.versionAsset, status: 'validated' }
+        return
+      }
+      this.validateLoading = true
+      try {
+        const { data } = await validateRuleVersion(this.versionAsset.id)
+        this.logicalPlanId = data.plan_id
+        this.versionAsset = { ...this.versionAsset, status: data.version_status }
+        this.refreshEvidence()
+      } catch (error) {
+        this.$snackbar.error(error || '编译验证失败')
+      } finally {
+        this.validateLoading = false
+      }
+    },
+    async testVersion () {
+      this.testLoading = true
+      try {
+        const { data } = await testRuleVersion(this.versionAsset.id, this.logicalPlanId)
+        this.versionAsset = { ...this.versionAsset, status: data.version_status }
+        this.$emit('version', this.versionAsset)
+        this.refreshEvidence()
+      } catch (error) {
+        this.$snackbar.error(error || '隔离样本测试失败')
+      } finally {
+        this.testLoading = false
+      }
+    },
     async publishVersion () {
       this.publishLoading = true
       try {
@@ -211,12 +386,16 @@ export default {
           : await publishRuleVersion(this.versionAsset.id)
         this.versionAsset = response.data
         this.$emit('version', this.versionAsset)
-        this.$snackbar.success('版本已发布,可供数据生产线固定引用')
+        this.refreshEvidence()
+        this.$snackbar.success('固定版本已发布,可供数据生产线引用')
       } catch (error) {
         this.$snackbar.error(error || '版本发布失败')
       } finally {
         this.publishLoading = false
       }
+    },
+    refreshEvidence () {
+      this.$nextTick(() => this.$refs.evidence?.load())
     }
   }
 }
@@ -224,60 +403,58 @@ export default {
 
 <style lang="scss" scoped>
 .rule-authoring {
+  background: #f8fbff;
   border: 1px solid #dbe4ee;
   border-left: 4px solid #1976d2;
-  background: #f8fbff;
-  padding: 18px;
   border-radius: 6px;
+  padding: 18px;
 }
-
-.rule-authoring__header {
-  display: flex;
+.rule-authoring__header,
+.rule-authoring__context,
+.rule-authoring__summary {
   align-items: flex-start;
-  justify-content: space-between;
+  display: flex;
   gap: 20px;
+  justify-content: space-between;
 }
-
 .rule-authoring__title {
   color: #17324d;
   font-size: 16px;
   font-weight: 600;
 }
-
 .rule-authoring__hint,
-.rule-authoring__meta {
+.rule-authoring__eyebrow {
   color: #60758a;
-  font-size: 13px;
+  font-size: 12px;
 }
-
-.rule-authoring__meta {
-  margin-left: 12px;
+.rule-authoring__context,
+.rule-authoring__summary {
+  background: #edf4fb;
+  border-radius: 4px;
+  padding: 10px 14px;
 }
-
-.rule-authoring__candidate {
+.rule-authoring__context > div,
+.rule-authoring__summary > div {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+}
+.rule-authoring__candidate,
+.rule-authoring__lifecycle {
   border-top: 1px solid #dbe4ee;
   padding-top: 14px;
 }
-
-.rule-authoring__candidate pre {
-  max-height: 230px;
-  margin: 12px 0 0;
-  padding: 12px;
-  overflow: auto;
-  border-radius: 4px;
-  background: #132238;
-  color: #d9e8f6;
+.rule-authoring__provenance {
+  align-items: center;
+  color: #506b82;
+  display: flex;
   font-size: 12px;
-  line-height: 1.55;
 }
-
 .rule-authoring__version-id {
-  max-width: 280px;
-  overflow: hidden;
   color: #60758a;
   font-family: monospace;
-  font-size: 12px;
+  font-size: 11px;
+  overflow: hidden;
   text-overflow: ellipsis;
-  white-space: nowrap;
 }
 </style>

+ 268 - 0
frontend/src/components/DataRules/RuleCatalogPicker.vue

@@ -0,0 +1,268 @@
+<template>
+  <section class="catalog-picker" :aria-busy="loading ? 'true' : 'false'">
+    <div class="catalog-picker__heading">
+      <div>
+        <div class="catalog-picker__label">{{ label }}</div>
+        <div class="catalog-picker__hint">
+          仅列出已发布且证据完整的固定版本,不会在运行时解析“最新版”。
+        </div>
+      </div>
+      <v-chip x-small color="success" outlined>published / trusted</v-chip>
+    </div>
+
+    <v-text-field
+      v-model="query"
+      class="mt-3"
+      dense
+      outlined
+      clearable
+      hide-details
+      prepend-inner-icon="mdi-magnify"
+      label="搜索名称、资产 UID 或负责人"
+      aria-label="搜索已发布规则与标准"
+      @input="queueSearch"
+      @keydown.down.prevent="moveFocus(1)"
+      @keydown.up.prevent="moveFocus(-1)"
+      @keydown.enter.prevent="selectFocused"
+    />
+
+    <v-skeleton-loader
+      v-if="loading"
+      class="mt-3"
+      type="list-item-three-line@3"
+      aria-label="正在加载可信资产目录"
+    />
+
+    <v-alert v-else-if="errorMessage" class="mt-3 mb-0" type="error" text dense>
+      {{ errorMessage }}
+      <v-btn class="ml-2" text x-small color="error" @click="load">重试</v-btn>
+    </v-alert>
+
+    <v-alert
+      v-else-if="!items.length"
+      class="mt-3 mb-0"
+      type="info"
+      text
+      dense
+    >
+      没有符合条件的已发布资产。请先完成编译、样本测试和发布。
+    </v-alert>
+
+    <v-list
+      v-else
+      class="catalog-picker__list mt-3"
+      dense
+      outlined
+      role="listbox"
+      aria-label="可信数据规则与数据标准版本"
+    >
+      <v-list-item
+        v-for="(item, index) in items"
+        :key="item.id"
+        :ref="`catalogItem${index}`"
+        :class="{ 'catalog-picker__item--focused': focusedIndex === index }"
+        :aria-selected="value === item.id ? 'true' : 'false'"
+        role="option"
+        tabindex="0"
+        @click="selectItem(item)"
+        @focus="focusedIndex = index"
+        @keydown.enter.prevent="selectItem(item)"
+        @keydown.space.prevent="selectItem(item)"
+      >
+        <v-list-item-action>
+          <v-radio :input-value="value === item.id" color="primary" />
+        </v-list-item-action>
+        <v-list-item-content>
+          <v-list-item-title class="d-flex align-center">
+            <strong>{{ item.name }}</strong>
+            <v-chip class="ml-2" x-small outlined>
+              {{ item.asset_type === 'standard' ? '标准' : '规则' }} V{{ item.version_no }}
+            </v-chip>
+            <v-chip
+              class="ml-2"
+              x-small
+              :color="compatibilityColor(item.schema_compatibility)"
+              outlined
+            >
+              {{ compatibilityLabel(item.schema_compatibility) }}
+            </v-chip>
+          </v-list-item-title>
+          <v-list-item-subtitle class="catalog-picker__uid">
+            {{ item.asset_type === 'standard' ? item.standard_uid : item.rule_uid }}
+          </v-list-item-subtitle>
+          <v-list-item-subtitle class="mt-1">
+            负责人 {{ item.owner_name || item.owner_uid || '未登记' }}
+            · 影响 {{ item.impact_count || 0 }}
+            · {{ item.backend || '治理标准' }}
+            · {{ evidenceLabel(item.latest_evidence) }}
+          </v-list-item-subtitle>
+        </v-list-item-content>
+        <v-list-item-icon v-if="value === item.id">
+          <v-icon color="success">mdi-check-circle</v-icon>
+        </v-list-item-icon>
+      </v-list-item>
+    </v-list>
+
+    <div v-if="total > items.length" class="catalog-picker__count mt-2">
+      当前显示 {{ items.length }} / {{ total }} 项,请继续搜索缩小范围。
+    </div>
+  </section>
+</template>
+
+<script>
+import { searchPublishedCatalog } from '@/api/dataRules'
+
+export default {
+  name: 'RuleCatalogPicker',
+  props: {
+    value: {
+      type: String,
+      default: null
+    },
+    assetType: {
+      type: String,
+      default: 'all',
+      validator: value => ['all', 'rule', 'standard'].includes(value)
+    },
+    label: {
+      type: String,
+      default: '选择受治理资产版本'
+    }
+  },
+  data () {
+    return {
+      query: '',
+      items: [],
+      total: 0,
+      loading: false,
+      errorMessage: '',
+      focusedIndex: -1,
+      searchTimer: null
+    }
+  },
+  created () {
+    this.load()
+  },
+  beforeDestroy () {
+    if (this.searchTimer) window.clearTimeout(this.searchTimer)
+  },
+  methods: {
+    queueSearch () {
+      if (this.searchTimer) window.clearTimeout(this.searchTimer)
+      this.searchTimer = window.setTimeout(this.load, 250)
+    },
+    async load () {
+      this.loading = true
+      this.errorMessage = ''
+      try {
+        const { data } = await searchPublishedCatalog({
+          query: (this.query || '').trim(),
+          asset_type: this.assetType,
+          limit: 20,
+          offset: 0
+        })
+        this.items = (data.items || [])
+          .map(item => ({
+            ...item,
+            id: item.version_id,
+            version_no: item.version,
+            owner_uid: item.owner,
+            rule_uid: item.asset_type === 'rule' ? item.asset_uid : null,
+            standard_uid: item.asset_type === 'standard' ? item.asset_uid : null,
+            schema_context: item.schema_compatibility,
+            schema_compatibility: item.schema_compatibility?.status ||
+              (item.schema_compatibility && Object.keys(item.schema_compatibility).length
+                ? 'compatible'
+                : 'unknown'),
+            latest_evidence: {
+              compile_status: item.latest_evidence?.compile?.status || null,
+              test_status: item.latest_evidence?.test?.status || null
+            }
+          }))
+          .filter(item => (
+            item.status === 'published' &&
+            item.trusted !== false &&
+            (this.assetType === 'all' || item.asset_type === this.assetType)
+          ))
+        this.total = Number(data.total || this.items.length)
+        this.focusedIndex = this.items.length ? 0 : -1
+        const selected = this.items.find(item => item.id === this.value)
+        if (selected) this.$emit('select', selected)
+      } catch (error) {
+        this.items = []
+        this.total = 0
+        this.errorMessage = error?.message || String(error || '可信目录加载失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    selectItem (item) {
+      if (item.status !== 'published' || item.trusted === false) return
+      this.$emit('input', item.id)
+      this.$emit('select', item)
+    },
+    moveFocus (delta) {
+      if (!this.items.length) return
+      this.focusedIndex = (this.focusedIndex + delta + this.items.length) % this.items.length
+      this.$nextTick(() => {
+        const target = this.$refs[`catalogItem${this.focusedIndex}`]
+        const element = Array.isArray(target) ? target[0]?.$el : target?.$el
+        if (element) element.focus()
+      })
+    },
+    selectFocused () {
+      if (this.focusedIndex >= 0) this.selectItem(this.items[this.focusedIndex])
+    },
+    compatibilityLabel (value) {
+      return {
+        compatible: 'Schema 兼容',
+        incompatible: 'Schema 不兼容',
+        unknown: '待确认兼容性'
+      }[value] || '待确认兼容性'
+    },
+    compatibilityColor (value) {
+      return value === 'compatible' ? 'success' : value === 'incompatible' ? 'error' : 'warning'
+    },
+    evidenceLabel (value = {}) {
+      if (value.test_status === 'success') return '编译与测试证据有效'
+      if (value.compile_status === 'success') return '已编译,待样本测试'
+      return '证据待补齐'
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.catalog-picker {
+  border: 1px solid #d8e1ea;
+  border-radius: 6px;
+  padding: 14px;
+  background: #fff;
+}
+.catalog-picker__heading {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+}
+.catalog-picker__label {
+  color: #19344e;
+  font-weight: 600;
+}
+.catalog-picker__hint,
+.catalog-picker__count {
+  color: #63778b;
+  font-size: 12px;
+}
+.catalog-picker__list {
+  border: 1px solid #e1e7ed;
+  max-height: 330px;
+  overflow-y: auto;
+}
+.catalog-picker__item--focused {
+  background: #eef5fc;
+}
+.catalog-picker__uid {
+  color: #60758a !important;
+  font-family: monospace;
+}
+</style>

+ 259 - 212
frontend/src/views/dataGovernance/dataProcess/components/edit.vue

@@ -2,105 +2,157 @@
   <div class="d-flex fullscreen" v-loading="loading">
     <v-card class="width-auto fullHeight">
       <v-banner single-line>
-        <div class="py-2 title">流程定义</div>
+        <div class="py-2 title">数据生产线定义</div>
       </v-banner>
-      <div class="pa-3 overflow-y-auto" style="height: calc(100% - 65px);">
-        <div class="mb-3">
-          <div v-for="value in ['source_table', 'target_table']" :key="value">
-            <div style="color: rgba(0, 0, 0, 0.6); padding: 5px; font-size: 14px;">
-              请选择{{ value === 'source_table' ? '数据输入' : '数据输出' }}
+      <div class="pa-4 overflow-y-auto" style="height: calc(100% - 65px);">
+        <v-alert type="info" text dense>
+          像实体工厂安排工位一样,将已发布的数据标准和数据规则固定到生产线。
+          实际执行脚本由数据工厂根据固定版本与数据集绑定自动生成。
+        </v-alert>
+
+        <section class="dataset-selector mt-4">
+          <div v-for="direction in ['source_table', 'target_table']" :key="direction">
+            <div class="dataset-selector__heading">
+              {{ direction === 'source_table' ? '输入数据集边' : '输出数据集边' }}
             </div>
-            <div class="mb-6" style="border: 1px dashed #666; padding: 10px; border-radius: 5px;">
-              <div class="d-flex justify-center">
-                <div style="width: 250px;">
-                  <v-text-field
-                    v-model="filteredBusinessDomain[value + '_name']"
-                    outlined dense hide-details
-                    label="输入名称快速搜索"
-                  ></v-text-field>
-                </div>
-                <v-btn class="half-button mx-3" @click="handleSearch(value)" color="primary">搜 索</v-btn>
-                <v-btn color="#5cbbf6" class="white--text half-button" @click="handleReset(value)">重 置</v-btn>
+            <div class="dataset-selector__body">
+              <div class="d-flex">
+                <v-text-field
+                  v-model="filteredBusinessDomain[direction + '_name']"
+                  outlined
+                  dense
+                  hide-details
+                  label="按数据集名称搜索"
+                  @keydown.enter="handleSearch(direction)"
+                />
+                <v-btn class="ml-3" color="primary" @click="handleSearch(direction)">搜索</v-btn>
+                <v-btn class="ml-2" text color="primary" @click="handleReset(direction)">重置</v-btn>
               </div>
-              <v-list dense flat v-if="getDisplayBusinessDomain(value).length">
-                <v-list-item-group v-model="changeObj[value]" multiple active-class="">
-                  <v-list-item v-for="item in getDisplayBusinessDomain(value)" :key="item.id" :value="item.id" three-line>
-                    <template v-slot:default="{ active }">
+              <v-list v-if="getDisplayBusinessDomain(direction).length" dense flat>
+                <v-list-item-group
+                  v-model="datasetEdges[direction]"
+                  :multiple="direction === 'source_table'"
+                  active-class=""
+                >
+                  <v-list-item
+                    v-for="item in getDisplayBusinessDomain(direction)"
+                    :key="item.id"
+                    :value="item.uid || item.id"
+                  >
+                    <template #default="{ active }">
                       <v-list-item-action>
-                        <v-checkbox :input-value="active"></v-checkbox>
+                        <v-checkbox :input-value="active" />
                       </v-list-item-action>
                       <v-list-item-content>
-                        <v-list-item-title>
-                          <span>{{ item.name_zh }}</span>
-                        </v-list-item-title>
+                        <v-list-item-title>{{ item.name_zh }}</v-list-item-title>
                         <v-list-item-subtitle>
-                          <v-chip v-for="tag in item.tag" :key="tag.id" small class="mr-2">{{ tag.name_zh }}</v-chip>
+                          {{ item.uid || item.id }}
                         </v-list-item-subtitle>
                       </v-list-item-content>
                     </template>
                   </v-list-item>
                 </v-list-item-group>
               </v-list>
-              <div v-else class="text-center" style="color: #666; padding: 10px;">暂无数据</div>
+              <div v-else class="text-center grey--text pa-4">暂无匹配数据集</div>
+            </div>
+          </div>
+        </section>
+
+        <production-line-assembler
+          v-model="productionLineSpec"
+          class="mt-5"
+          :dataflow-uid="dataflowUid"
+          :name="dataflowName"
+          :input-schema-refs="datasetEdges.source_table"
+          :output-schema-ref="normalizedTarget"
+          @readiness="releaseReadiness = $event"
+        />
+
+        <section v-if="legacyRequirementPresent" class="legacy-flow mt-5">
+          <div class="d-flex align-center justify-space-between">
+            <div>
+              <div class="legacy-flow__title">旧流程规则迁移区</div>
+              <div class="legacy-flow__hint">
+                旧自然语言与脚本只读保留,不会进入新执行语义。
+              </div>
             </div>
+            <v-chip
+              :color="migrationMetadata.status === 'migrated' ? 'success' : 'warning'"
+              outlined
+              small
+            >
+              {{ migrationMetadata.status === 'migrated' ? '已迁移' : '未迁移' }}
+            </v-chip>
           </div>
-        </div>
-        <div>
           <v-textarea
-            v-model="changeObj.rule"
+            class="mt-3"
+            :value="legacyRuleText"
+            readonly
             outlined
-            hide-details
-            label="请输入数据规则"
-            :rows="10"
             dense
-          ></v-textarea>
-        </div>
-        <rule-authoring-panel
+            rows="4"
+            hide-details
+            label="旧规则描述(只读)"
+          />
+          <v-btn class="mt-3" small outlined color="primary" @click="focusAssembler">
+            <v-icon left small>mdi-source-branch-sync</v-icon>
+            迁移为固定版本工位
+          </v-btn>
+        </section>
+
+        <v-btn
+          v-if="Object.keys(itemData).length"
           class="mt-4"
-          authoring-surface="data_flow"
-          :initial-text="changeObj.rule || ''"
-          :context="authoringContext"
-          @candidate="handleRuleCandidate"
-          @version="handleRuleVersion"
-        />
-        <div v-if="Object.keys(itemData).length" class="d-flex align-end justify-end mt-3">
-          <v-btn color="primary" @click="handleViewCode">查看代码</v-btn>
-        </div>
+          text
+          color="primary"
+          @click="handleViewCode"
+        >
+          查看历史生成脚本(只读)
+        </v-btn>
       </div>
     </v-card>
-    <v-card style="width: 500px;" class="ml-3 fullHeight d-flex flex-column">
+
+    <v-card class="ml-3 fullHeight d-flex flex-column" style="width: 500px;">
       <v-banner single-line>
         <div class="py-2 title">基础信息</div>
         <template #actions>
-          <v-btn
-            text
-            class="ml-3"
-            color="primary"
-            @click="handleSubmit"
-          >
-            <v-icon left>mdi-send-variant</v-icon>
-            确认提交
+          <v-btn text color="primary" :loading="saving" @click="handleSubmit">
+            <v-icon left>mdi-content-save-check</v-icon>
+            保存生产线定义
           </v-btn>
         </template>
       </v-banner>
       <div class="pt-3 height-auto overflow-y-auto">
-        <EditBase ref="editBaseRefs"></EditBase>
+        <edit-base ref="editBaseRefs" :item-data="itemData" />
+        <v-alert
+          class="mx-3 mt-3"
+          :type="releaseReadiness.ready ? 'success' : 'warning'"
+          text
+          dense
+        >
+          {{ releaseReadiness.ready
+            ? '固定版本、Schema 与证据均已就绪,可进入发布流程。'
+            : '当前可保存草稿,但尚不能发布到数据工厂。' }}
+        </v-alert>
       </div>
     </v-card>
-    <v-navigation-drawer
-      v-model="drawer"
-      fixed
-      temporary
-      right
-      width="800"
-    >
-      <div class="d-flex flex-column" style="height: 100%;">
-        <v-banner single-line>
-          <div class="py-2 title">执行代码查看</div>
-        </v-banner>
-        <div class="pa-3 flex-grow-1 overflow-y-auto">
-          <div class="code-preview" v-html="highlightedCode"></div>
-        </div>
+
+    <v-navigation-drawer v-model="drawer" fixed temporary right width="760">
+      <v-banner single-line>
+        <div class="py-2 title">历史生成脚本(只读迁移参考)</div>
+      </v-banner>
+      <div class="pa-4">
+        <v-alert type="warning" text dense>
+          该内容不参与受治理生产线执行,迁移后请以固定版本和证据链为准。
+        </v-alert>
+        <v-textarea
+          :value="scriptContent"
+          readonly
+          outlined
+          rows="24"
+          hide-details
+          label="历史脚本"
+        />
       </div>
     </v-navigation-drawer>
   </div>
@@ -109,15 +161,13 @@
 <script>
 import EditBase from './editBase.vue'
 import { api } from '@/api/dataGovernance'
-import hljs from 'highlight.js'
-import 'highlight.js/styles/monokai.css'
-import RuleAuthoringPanel from '@/components/DataRules/RuleAuthoringPanel'
+import ProductionLineAssembler from '@/components/DataRules/ProductionLineAssembler'
 
 export default {
   name: 'editPage',
   components: {
     EditBase,
-    RuleAuthoringPanel
+    ProductionLineAssembler
   },
   props: {
     itemData: {
@@ -129,169 +179,158 @@ export default {
     return {
       drawer: false,
       loading: false,
-      scriptContent: '', // 执行代码
-      businessDomain: [], // 业务域列表
-      // 业务域过滤
+      saving: false,
+      scriptContent: '',
+      businessDomain: [],
       filteredBusinessDomain: {
         source_table: [],
         target_table: [],
         source_table_name: null,
         target_table_name: null
       },
-      changeObj: {
-        rule: null,
+      datasetEdges: {
         source_table: [],
-        target_table: []
+        target_table: null
+      },
+      productionLineSpec: {},
+      legacyRequirement: {},
+      releaseReadiness: {
+        ready: false,
+        blockers: ['尚未完成生产线编排']
       }
     }
   },
   computed: {
-    authoringContext () {
-      return {
-        source_table_ids: this.changeObj.source_table || [],
-        target_table_ids: this.changeObj.target_table || []
-      }
+    dataflowUid () {
+      return this.itemData.dataflow_uid || this.itemData.data_flow_uid || this.itemData.uid || null
     },
-    highlightedCode () {
-      if (!this.scriptContent) {
-        return ''
-      }
-      try {
-        // highlight.js 高亮代码,尝试自动检测语言
-        const result = hljs.highlightAuto(this.scriptContent)
-        return `<pre><code class="hljs ${result.language || ''}">${result.value}</code></pre>`
-      } catch (error) {
-        console.error('代码高亮错误:', error)
-        // 如果高亮失败,返回转义后的纯文本
-        return `<pre><code>${this.escapeHtml(this.scriptContent)}</code></pre>`
+    dataflowName () {
+      return this.itemData.name_zh || this.$refs.editBaseRefs?.formValues?.name_zh || '未命名数据生产线'
+    },
+    normalizedTarget () {
+      const target = this.datasetEdges.target_table
+      return Array.isArray(target) ? target[0] || null : target
+    },
+    legacyRuleText () {
+      return typeof this.legacyRequirement.rule === 'string'
+        ? this.legacyRequirement.rule
+        : ''
+    },
+    legacyRequirementPresent () {
+      return Boolean(this.legacyRuleText || this.legacyRequirement.legacy_script_path)
+    },
+    migrationMetadata () {
+      return {
+        status: this.productionLineSpec.components?.length ? 'migrated' : 'unmigrated',
+        legacy_fields_present: this.legacyRequirementPresent,
+        preserved_for_read_only: true,
+        governed_semantics: 'dataflow_spec'
       }
     }
   },
   async created () {
     this.loading = true
     await this.getList()
-    if (Object.keys(this.itemData).length) {
-      await this.getDetails()
-    }
+    if (Object.keys(this.itemData).length) await this.getDetails()
     this.loading = false
   },
   methods: {
-    handleRuleVersion (version) {
-      this.$set(this.changeObj, 'rule_version_id', version.id)
-      this.$set(this.changeObj, 'rule_version_status', version.status)
-    },
-    handleRuleCandidate (candidate, sourceText) {
-      const ruleSpec = candidate?.rule_spec
-      if (!ruleSpec) {
-        this.$snackbar.warning('候选中没有可采用的数据规则')
-        return
-      }
-      this.$set(this.changeObj, 'rule', sourceText)
-      this.$set(this.changeObj, 'rule_spec', ruleSpec)
-    },
-    // 获取业务域列表
     async getList () {
       try {
         const { data } = await api.getBusinessDomainList2()
-        if (!data || !data?.length) {
-          this.businessDomain = []
-          return
-        }
-        this.businessDomain = data.map(e => {
-          return {
-            ...e,
-            label: `${e.tag}:${e.name_zh}`
-          }
-        })
+        this.businessDomain = (data || []).map(item => ({
+          ...item,
+          label: `${item.tag || '数据集'}:${item.name_zh}`
+        }))
         this.filteredBusinessDomain.source_table = [...this.businessDomain]
         this.filteredBusinessDomain.target_table = [...this.businessDomain]
       } catch (error) {
         this.$snackbar.error(error)
       }
     },
-    // 搜索业务域
-    handleSearch (value) {
-      const keyword = (this.filteredBusinessDomain[value + '_name'] || '').trim()
-      if (!keyword) {
-        this.filteredBusinessDomain[value] = [...this.businessDomain]
-        return
-      }
-      this.filteredBusinessDomain[value] = this.businessDomain.filter(e => (e.name_zh || '').includes(keyword))
+    handleSearch (direction) {
+      const keyword = (this.filteredBusinessDomain[`${direction}_name`] || '').trim()
+      this.filteredBusinessDomain[direction] = keyword
+        ? this.businessDomain.filter(item => (item.name_zh || '').includes(keyword))
+        : [...this.businessDomain]
     },
-    // 重置业务域
-    handleReset (value) {
-      this.filteredBusinessDomain[value + '_name'] = null
-      this.filteredBusinessDomain[value] = [...this.businessDomain]
+    handleReset (direction) {
+      this.filteredBusinessDomain[`${direction}_name`] = null
+      this.filteredBusinessDomain[direction] = [...this.businessDomain]
     },
-    // 获取显示的业务域
-    getDisplayBusinessDomain (value) {
-      const list = this.filteredBusinessDomain[value]
-      if (!Array.isArray(list)) {
-        return this.businessDomain
-      }
-      return list
+    getDisplayBusinessDomain (direction) {
+      return Array.isArray(this.filteredBusinessDomain[direction])
+        ? this.filteredBusinessDomain[direction]
+        : this.businessDomain
     },
-    // 详情
     async getDetails () {
       try {
         const { data } = await api.getDataFlowDetails(this.itemData.id)
-        this.$nextTick(() => {
-          this.$refs.editBaseRefs.setValues(data)
-        })
-        this.changeObj = data.script_requirement || {}
+        this.$nextTick(() => this.$refs.editBaseRefs.setValues(data))
+        const requirement = data.script_requirement && typeof data.script_requirement === 'object'
+          ? data.script_requirement
+          : {}
+        this.productionLineSpec = requirement.dataflow_spec || {}
+        const edges = requirement.dataset_edges || {}
+        this.datasetEdges = {
+          source_table: Array.isArray(edges.source_table)
+            ? edges.source_table
+            : Array.isArray(requirement.source_table) ? requirement.source_table : [],
+          target_table: edges.target_table || requirement.target_table?.[0] || requirement.target_table || null
+        }
+        this.legacyRequirement = requirement.dataflow_spec ? {} : { ...requirement }
       } catch (error) {
         this.$snackbar.error(error)
       }
     },
-    // 查看执行代码
+    focusAssembler () {
+      const target = this.$el.querySelector('.assembler')
+      if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' })
+    },
     async handleViewCode () {
-      if (!this.itemData || !this.itemData.id) {
-        this.$snackbar.error('缺少数据流ID')
-        return
-      }
-
-      this.scriptContent = ''
+      if (!this.itemData.id) return
       this.loading = true
       try {
         const { data } = await api.getDataFlowScriptContent(this.itemData.id)
-        if (!data?.script_content) {
-          this.$snackbar.warning('脚本内容为空')
-          return
-        }
         this.scriptContent = data?.script_content || ''
         this.drawer = true
       } catch (error) {
-        this.$snackbar.error(error.message || '获取脚本失败')
+        this.$snackbar.error(error?.message || '获取历史脚本失败')
       } finally {
         this.loading = false
       }
     },
     async handleSubmit () {
-      const params = this.$refs.editBaseRefs.getValue()
-      if (!params) {
-        return
-      }
-      const query = {
-        ...params,
-        script_requirement: this.changeObj
+      const base = this.$refs.editBaseRefs.getValue()
+      if (!base) return
+      const payload = {
+        ...base,
+        script_requirement: {
+          dataflow_spec: {
+            ...this.productionLineSpec,
+            name: base.name_zh || this.productionLineSpec.name
+          },
+          dataset_edges: {
+            source_table: [...this.datasetEdges.source_table],
+            target_table: this.normalizedTarget
+          },
+          migration_metadata: this.migrationMetadata
+        }
       }
-      const isEdit = Object.keys(this.itemData).length > 0
+      this.saving = true
       try {
-        if (isEdit) {
-          await api.updateDataFlow(this.itemData.id, query)
+        if (Object.keys(this.itemData).length) {
+          await api.updateDataFlow(this.itemData.id, payload)
         } else {
-          await api.addDataFlow(query)
+          await api.addDataFlow(payload)
         }
-        this.$snackbar.success(isEdit ? '更新成功' : '新增成功')
+        this.$snackbar.success('数据生产线定义已保存')
         this.$emit('success')
       } catch (error) {
         this.$snackbar.error(error)
+      } finally {
+        this.saving = false
       }
-    },
-    escapeHtml (text) {
-      const div = document.createElement('div')
-      div.textContent = text
-      return div.innerHTML
     }
   }
 }
@@ -299,7 +338,7 @@ export default {
 
 <style lang="scss" scoped>
 .title {
-  color: #1976D2;
+  color: #1976d2;
   font-weight: 600;
 }
 .fullscreen {
@@ -310,49 +349,57 @@ export default {
   height: 100%;
 }
 .width-auto {
-  width: 0;
   flex: 1;
+  width: 0;
 }
 .height-auto {
-  height: 0;
   flex: 1;
+  height: 0;
 }
-::v-deep ul {
-  padding-left: 0;
+.dataset-selector {
+  display: grid;
+  gap: 16px;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
 }
-.code-preview {
-  width: 100%;
-  height: 100%;
-  overflow: auto;
-  padding: 16px;
-  font-family: 'Courier New', Courier, monospace;
-  font-size: 14px;
-  line-height: 1.5;
-
-  ::v-deep pre {
-    margin: 0;
-    padding: 0;
-    background: transparent;
-    border: none;
-    font-family: inherit;
-    font-size: inherit;
-    line-height: inherit;
-    height: 100%;
+.dataset-selector__heading {
+  color: #17324d;
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+.dataset-selector__body,
+.legacy-flow {
+  border: 1px solid #d8e1ea;
+  border-radius: 6px;
+  padding: 14px;
+}
+.dataset-selector__body {
+  min-height: 250px;
+}
+.legacy-flow {
+  background: #fafbfd;
+}
+.legacy-flow__title {
+  color: #17324d;
+  font-weight: 600;
+}
+.legacy-flow__hint {
+  color: #64788c;
+  font-size: 12px;
+}
+@media (max-width: 960px) {
+  .fullscreen {
+    display: block !important;
+    overflow-y: auto;
   }
-
-  ::v-deep code {
-    display: block;
+  .fullHeight,
+  .height-auto {
+    height: auto;
+  }
+  .width-auto {
     width: 100%;
-    padding: 0;
-    margin: 0;
-    background: transparent;
-    border: none;
-    font-family: inherit;
-    font-size: inherit;
-    line-height: inherit;
-    white-space: pre;
-    overflow-wrap: normal;
-    overflow-x: auto;
+  }
+  .dataset-selector {
+    grid-template-columns: 1fr;
   }
 }
 </style>

+ 168 - 146
frontend/src/views/dataGovernance/dataStandard/components/edit.vue

@@ -1,6 +1,12 @@
 <template>
-  <div>
+  <div class="standard-editor">
+    <v-alert class="mb-4" type="info" text dense>
+      数据标准条款只固定引用已发布的 RuleVersion。执行表达式与脚本由平台从受治理版本生成,
+      不再作为标准表单字段维护。
+    </v-alert>
+
     <rule-authoring-panel
+      ref="authoring"
       class="mb-5"
       authoring-surface="data_standard"
       :initial-text="ruleSourceText"
@@ -8,72 +14,114 @@
       @candidate="handleRuleCandidate"
       @version="handleRuleVersion"
     />
+
     <form-list ref="form" :items="formItems">
-    <template #code="{ item }">
+      <template #tag="{ item }">
+        <div class="standard-editor__tags">
+          <v-chip-group
+            v-if="item.items.length"
+            v-model="item.value"
+            multiple
+            column
+            active-class="primary--text"
+          >
+            <v-chip v-for="tag in item.items" :key="tag.id" filter :value="tag">
+              {{ tag.name_zh }}
+            </v-chip>
+          </v-chip-group>
+          <div v-else class="text-center grey--text pa-3">暂无可选标签</div>
+        </div>
+      </template>
+    </form-list>
+
+    <rule-catalog-picker
+      v-model="linkedRuleVersionId"
+      class="mt-2"
+      asset-type="rule"
+      label="标准条款关联的已发布规则版本 *"
+      @select="handleCatalogSelection"
+    />
+
+    <compilation-evidence
+      v-if="linkedRuleVersionId"
+      class="mt-4"
+      :version-id="linkedRuleVersionId"
+      asset-type="rule"
+    />
+
+    <section class="legacy-migration mt-5">
+      <div class="d-flex align-center justify-space-between">
+        <div>
+          <div class="legacy-migration__title">旧“操作代码”迁移区</div>
+          <div class="legacy-migration__hint">
+            旧字段仅供核对,不参与新版本执行,也不会写回新保存载荷。
+          </div>
+        </div>
+        <v-chip
+          :color="legacyMigration.status === 'linked' ? 'success' : 'warning'"
+          outlined
+          small
+        >
+          {{ legacyMigration.status === 'linked' ? '已链接受治理版本' : '未迁移' }}
+        </v-chip>
+      </div>
       <v-textarea
+        v-if="legacyOperationArtifact"
+        class="mt-3"
+        :value="legacyOperationArtifact"
+        readonly
         outlined
-        no-resize
-        hide-details
+        dense
         rows="5"
-        v-model="item.value"
-        :label="item.label"
+        hide-details
+        label="旧操作代码(只读,不作为执行语义)"
+      />
+      <v-alert v-else class="mt-3 mb-0" type="success" text dense>
+        当前记录没有旧操作代码。
+      </v-alert>
+      <v-btn
+        v-if="legacyOperationArtifact && legacyMigration.status !== 'linked'"
+        class="mt-3"
+        color="primary"
+        outlined
+        small
+        @click="startMigration"
       >
-        <template #append>
-          <v-btn class="ml-5" small color="primary" @click="handleCodeGenerate">代码生成</v-btn>
-        </template>
-      </v-textarea>
-    </template>
-    <template #tag="{ item }">
-      <div class="mb-6" style="border: 1px dashed #666; padding: 10px; border-radius: 5px; width: 100%;">
-        <v-chip-group
-          v-if="item.items.length"
-          v-model="item.value"
-          multiple
-          column
-          active-class="primary--text"
-        >
-          <v-chip v-for="tag in item.items" :key="tag.id" filter :value="tag">
-            {{ tag.name_zh }}
-          </v-chip>
-        </v-chip-group>
-        <div v-else class="text-center" style="color: #999; padding: 10px;">暂无可选标签</div>
-      </div>
-    </template>
-    <v-overlay :value="overlay">
-      <div class="d-flex flex-column align-center justify-center" style="width: 300px;">
-        <div class="mb-3">生成操作代码中</div>
-        <v-progress-linear
-          color="primary"
-          indeterminate
-          rounded
-          height="6"
-        ></v-progress-linear>
-      </div>
-    </v-overlay>
-    </form-list>
+        <v-icon left small>mdi-source-branch-sync</v-icon>
+        用自然语言重新生成并迁移
+      </v-btn>
+    </section>
   </div>
 </template>
 
 <script>
 import FormList from '@/components/Form/list'
-import {
-  metadata
-} from '@/utils/dataGovernance'
+import { metadata } from '@/utils/dataGovernance'
 import { api } from '@/api/dataGovernance'
 import RuleAuthoringPanel from '@/components/DataRules/RuleAuthoringPanel'
+import RuleCatalogPicker from '@/components/DataRules/RuleCatalogPicker'
+import CompilationEvidence from '@/components/DataRules/CompilationEvidence'
+
 export default {
   name: 'data-standard-edit',
+  components: {
+    FormList,
+    RuleAuthoringPanel,
+    RuleCatalogPicker,
+    CompilationEvidence
+  },
   props: {
     itemData: {
       type: Object,
       default: () => ({})
     }
   },
-  components: { FormList, RuleAuthoringPanel },
   data () {
     return {
-      overlay: false,
+      loading: false,
       ruleSourceText: this.itemData.describe || '',
+      linkedRuleVersionId: this.itemData.rule_version_id || null,
+      selectedRuleAsset: null,
       formItems: {
         options: [
           {
@@ -107,61 +155,42 @@ export default {
             placeholder: '请输入适用范围'
           },
           {
-            // type: 'autocomplete',
             slotName: 'tag',
             key: 'tag',
             value: [],
             slotTitle: '请选择标签',
             slotTitleStyle: 'color: rgba(0, 0, 0, 0.6); padding: 5px',
-            // label: '请选择标签',
-            // outlined: true,
-            // returnObject: true,
-            // multiple: true,
-            // dense: true,
-            // itemText: 'name_zh',
-            // itemValue: 'id',
             items: []
           },
           {
             type: 'text',
             key: 'input',
             value: null,
-            label: '输入参数 *',
+            label: '输入 Schema 引用 *',
             col: 6,
             outlined: true,
             dense: true,
-            // items: [], // 输入元数据
-            rules: [v => !!v || '请输入输入参数']
+            rules: [v => !!v || '请输入输入 Schema 引用']
           },
           {
             type: 'text',
             key: 'output',
             value: null,
-            label: '输出参数 *',
+            label: '输出 Schema 引用 *',
             col: 6,
             outlined: true,
             dense: true,
-            rules: [v => !!v || '请输入输出参数']
+            rules: [v => !!v || '请输入输出 Schema 引用']
           },
           {
             type: 'text',
             key: 'describe',
             value: null,
-            label: '描述 *',
-            placeholder: '请输入描述',
+            label: '标准条款描述 *',
+            placeholder: '请输入可供 AI 解析的自然语言约束',
             outlined: true,
             dense: true,
-            rules: [v => !!v || '请输入描述']
-          },
-          {
-            slotName: 'code',
-            // type: 'textarea',
-            key: 'code',
-            value: null,
-            label: '操作代码 *',
-            // outlined: true,
-            // dense: true,
-            rules: [v => !!v || '请输入操作代码']
+            rules: [v => !!v || '请输入标准条款描述']
           },
           {
             type: 'ifRadio',
@@ -177,114 +206,107 @@ export default {
   },
   computed: {
     authoringContext () {
-      const values = this.formItems.options.reduce((result, item) => {
-        result[item.key] = item.value
-        return result
-      }, {})
+      const values = this.formValues()
       return {
         input_schema_ref: values.input || null,
         output_schema_ref: values.output || null,
         scope: values.scope || null
       }
+    },
+    legacyOperationArtifact () {
+      return typeof this.itemData.code === 'string' ? this.itemData.code : ''
+    },
+    legacyMigration () {
+      return {
+        status: this.linkedRuleVersionId ? 'linked' : 'unmigrated',
+        legacy_field_present: Boolean(this.legacyOperationArtifact),
+        linked_rule_version_id: this.linkedRuleVersionId,
+        semantics: 'read_only_reference'
+      }
     }
   },
   created () {
     this.init()
-    if (!Object.keys(this.itemData).length) {
-      return
-    }
     this.formItems.options.forEach(item => {
-      // if (item.key === 'tag') {
-      //   item.value = this.itemData.tag.id
-      //   return
-      // }
-      item.value = this.itemData[item.key]
+      if (Object.prototype.hasOwnProperty.call(this.itemData, item.key)) {
+        item.value = this.itemData[item.key]
+      }
     })
   },
   methods: {
-    handleRuleVersion (version) {
-      this.$set(this.itemData, 'rule_version_id', version.id)
-      this.$set(this.itemData, 'rule_version_status', version.status)
+    formValues () {
+      return this.formItems.options.reduce((result, item) => {
+        result[item.key] = item.value
+        return result
+      }, {})
     },
-    handleRuleCandidate (candidate, sourceText) {
-      const ruleSpec = candidate?.rule_spec
-      if (!ruleSpec) {
-        this.$snackbar.warning('候选中没有可采用的数据规则')
-        return
+    handleRuleVersion (version) {
+      if (version.status === 'published') {
+        this.linkedRuleVersionId = version.id
       }
+    },
+    handleRuleCandidate (_candidate, sourceText) {
       this.ruleSourceText = sourceText
-      this.formItems.options.find(e => e.key === 'describe').value = sourceText
-      this.formItems.options.find(e => e.key === 'code').value = JSON.stringify(ruleSpec, null, 2)
+      const description = this.formItems.options.find(item => item.key === 'describe')
+      if (description) description.value = sourceText
     },
-    async init () {
-      try {
-        this.loading = true
-        try {
-          const { data } = await api.getLabelList({
-            ...this.pageInfo,
-            category_filter: 'DataOps'
-          })
-          this.formItems.options.find(e => e.key === 'tag').items = data.records
-          this.total = data.total
-        } catch (error) {
-          this.$snackbar.error(error)
-        } finally {
-          this.loading = false
-        }
-      } catch (error) {
-        this.$snackbar.error(error)
-      }
+    handleCatalogSelection (asset) {
+      this.selectedRuleAsset = asset
     },
-    async handleCodeGenerate () {
-      const { input, output, describe } = this.formItems.options.reduce((acc, cur) => {
-        acc[cur.key] = cur.value
-        return acc
-      }, {})
-      if (!input) {
-        this.$snackbar.error('输入参数不能为空')
-        return
-      }
-      if (!output) {
-        this.$snackbar.error('输出参数不能为空')
-        return
-      }
-      if (!describe) {
-        this.$snackbar.error('请输入描述')
-        return
-      }
-      this.overlay = true
+    startMigration () {
+      this.ruleSourceText = this.formValues().describe || ''
+      this.$refs.authoring?.$el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
+    },
+    async init () {
+      this.loading = true
       try {
-        const { data } = await api.dataStandardCodeGenerate({ input, output, describe })
-        this.formItems.options.find(e => e.key === 'code').value = data
+        const { data } = await api.getLabelList({
+          page: 1,
+          page_size: 100,
+          category_filter: 'DataOps'
+        })
+        this.formItems.options.find(item => item.key === 'tag').items = data.records || []
       } catch (error) {
         this.$snackbar.error(error)
       } finally {
-        this.overlay = false
+        this.loading = false
       }
     },
     async getValue () {
-      if (!this.$refs.form.validate()) {
+      if (!this.$refs.form.validate()) return
+      if (!this.linkedRuleVersionId) {
+        this.$snackbar.error('请选择已发布的数据规则版本')
         return
       }
-      if (!this.formItems.options.find(e => e.key === 'code').value) {
-        await this.handleCodeGenerate()
+      const values = this.formValues()
+      return {
+        ...values,
+        rule_version_id: this.linkedRuleVersionId,
+        rule_version_status: 'published',
+        migration_metadata: this.legacyMigration
       }
-      return this.formItems.options.reduce((res, item) => {
-        // if (item.key === 'tag' && item.value) {
-        //   res.tag = {
-        //     id: item.value,
-        //     name_zh: item.items.find(e => e.id === item.value)?.name_zh ?? null
-        //   }
-        //   return res
-        // }
-        res[item.key] = item.value
-        return res
-      }, {})
     }
   }
 }
 </script>
 
 <style lang="scss" scoped>
-
+.standard-editor__tags,
+.legacy-migration {
+  border: 1px solid #d8e1ea;
+  border-radius: 6px;
+  padding: 12px;
+  width: 100%;
+}
+.legacy-migration {
+  background: #fafbfd;
+}
+.legacy-migration__title {
+  color: #17324d;
+  font-weight: 600;
+}
+.legacy-migration__hint {
+  color: #64788c;
+  font-size: 12px;
+}
 </style>

+ 264 - 9
tests/core/data_rules/test_data_rule_repository.py

@@ -55,6 +55,9 @@ class FakeSession:
         catalog_standard_rows=None,
         published_asset_rule_rows=None,
         published_asset_standard_rows=None,
+        unified_catalog_rows=None,
+        rule_asset_evidence_rows=None,
+        standard_asset_evidence_rows=None,
     ):
         self.calls = []
         self.duplicate = duplicate
@@ -63,12 +66,15 @@ class FakeSession:
         self.published_rule_ids = set(published_rule_ids or [])
         self.catalog_rule_rows = list(catalog_rule_rows or [])
         self.catalog_standard_rows = list(catalog_standard_rows or [])
-        self.published_asset_rule_rows = list(
-            published_asset_rule_rows or []
-        )
+        self.published_asset_rule_rows = list(published_asset_rule_rows or [])
         self.published_asset_standard_rows = list(
             published_asset_standard_rows or []
         )
+        self.unified_catalog_rows = list(unified_catalog_rows or [])
+        self.rule_asset_evidence_rows = list(rule_asset_evidence_rows or [])
+        self.standard_asset_evidence_rows = list(
+            standard_asset_evidence_rows or []
+        )
 
     def execute(self, statement, params=None):
         sql = str(statement)
@@ -103,6 +109,12 @@ class FakeSession:
             return FakeResult(rows=self.published_asset_rule_rows)
         if "published_standard_assets" in sql:
             return FakeResult(rows=self.published_asset_standard_rows)
+        if "unified_published_asset_catalog" in sql:
+            return FakeResult(rows=self.unified_catalog_rows)
+        if "safe_rule_asset_evidence" in sql:
+            return FakeResult(rows=self.rule_asset_evidence_rows)
+        if "safe_standard_asset_evidence" in sql:
+            return FakeResult(rows=self.standard_asset_evidence_rows)
         return FakeResult()
 
 
@@ -304,7 +316,7 @@ def test_dataflow_asset_loader_requires_exact_task7_logical_chain():
                 "rule_spec": valid_rule_spec(),
                 "spec_hash": "a" * 64,
             }
-        ]
+        ],
     )
 
     _standards, rules = DataRuleRepository(session).load_published_assets(flow)
@@ -323,6 +335,249 @@ def test_dataflow_asset_loader_requires_exact_task7_logical_chain():
     assert "pa.evidence_hash = lp.plan_hash" in sql
 
 
+def test_unified_published_catalog_is_paginated_searchable_and_trusted():
+    from app.core.data_rules.repository import DataRuleRepository
+
+    version_id = new_governance_uid()
+    asset_uid = new_governance_uid()
+    owner_uid = new_governance_uid()
+    compile_id = new_governance_uid()
+    test_id = new_governance_uid()
+    session = FakeSession(
+        unified_catalog_rows=[
+            {
+                "asset_type": "rule",
+                "version_id": version_id,
+                "asset_uid": asset_uid,
+                "name": "手机号规范化",
+                "version_no": 3,
+                "owner_uid": owner_uid,
+                "status": "published",
+                "schema_compatibility": {
+                    "input": "a" * 64,
+                    "output": "b" * 64,
+                },
+                "impact_count": 4,
+                "backend": "polars_batch",
+                "compile_evidence_id": compile_id,
+                "compile_status": "success",
+                "test_evidence_id": test_id,
+                "test_status": "success",
+                "total_count": 8,
+            }
+        ]
+    )
+
+    result = DataRuleRepository(session).search_published_assets(
+        query=" 手机 ",
+        asset_type="rule",
+        limit=10,
+        offset=20,
+    )
+
+    assert result == {
+        "items": [
+            {
+                "asset_type": "rule",
+                "version_id": version_id,
+                "asset_uid": asset_uid,
+                "name": "手机号规范化",
+                "version": 3,
+                "owner": owner_uid,
+                "status": "published",
+                "schema_compatibility": {
+                    "input": "a" * 64,
+                    "output": "b" * 64,
+                },
+                "impact_count": 4,
+                "backend": "polars_batch",
+                "latest_evidence": {
+                    "compile": {
+                        "id": compile_id,
+                        "status": "success",
+                    },
+                    "test": {"id": test_id, "status": "success"},
+                },
+            }
+        ],
+        "total": 8,
+        "limit": 10,
+        "offset": 20,
+    }
+    sql = _sql(session)
+    assert "unified_published_asset_catalog" in sql
+    assert "UNION ALL" in sql
+    assert "rv.status = 'published'" in sql
+    assert "value.status = 'published'" in sql
+    assert "value.status = 'success'" in sql
+    assert "sv.status = 'published'" in sql
+    assert "COUNT(*)::integer AS total_count" in sql
+    params = session.calls[0][1]
+    assert params == {
+        "query": "手机",
+        "pattern": "%手机%",
+        "asset_type": "rule",
+        "limit": 10,
+        "offset": 20,
+    }
+
+
+@pytest.mark.parametrize(
+    ("kwargs", "message"),
+    [
+        ({"asset_type": "dataflow"}, "asset_type"),
+        ({"query": "x" * 201}, "query"),
+        ({"limit": 0}, "limit"),
+        ({"limit": 101}, "limit"),
+        ({"offset": -1}, "offset"),
+        ({"offset": True}, "offset"),
+    ],
+)
+def test_unified_catalog_rejects_open_or_unbounded_queries(kwargs, message):
+    from app.core.data_rules.repository import DataRuleRepository
+
+    request = {
+        "query": "",
+        "asset_type": None,
+        "limit": 50,
+        "offset": 0,
+        **kwargs,
+    }
+    with pytest.raises(ValueError, match=message):
+        DataRuleRepository(FakeSession()).search_published_assets(**request)
+
+
+def test_rule_asset_evidence_returns_safe_complete_governance_chain():
+    from app.core.data_rules.repository import DataRuleRepository
+
+    version_id = new_governance_uid()
+    generation_id = new_governance_uid()
+    logical_plan_id = new_governance_uid()
+    logical_compile_id = new_governance_uid()
+    logical_test_id = new_governance_uid()
+    publication_id = new_governance_uid()
+    physical_plan_id = new_governance_uid()
+    physical_compile_id = new_governance_uid()
+    physical_test_id = new_governance_uid()
+    session = FakeSession(
+        rule_asset_evidence_rows=[
+            {
+                "version_id": version_id,
+                "version_status": "published",
+                "generation_id": generation_id,
+                "generation_status": "ready",
+                "candidate_hash": "a" * 64,
+                "model_hash": "b" * 64,
+                "prompt_hash": "c" * 64,
+                "context_hash": "d" * 64,
+                "logical_plan_id": logical_plan_id,
+                "logical_plan_hash": "e" * 64,
+                "logical_backend": "polars_batch",
+                "logical_compile_id": logical_compile_id,
+                "logical_compile_status": "success",
+                "logical_test_id": logical_test_id,
+                "logical_test_status": "success",
+                "logical_test_kind": "dry_run",
+                "logical_test_run_id": new_governance_uid(),
+                "publication_id": publication_id,
+                "publication_status": "published",
+                "physical_plan_id": physical_plan_id,
+                "physical_plan_hash": "f" * 64,
+                "physical_backend": "polars_batch",
+                "physical_compile_id": physical_compile_id,
+                "physical_compile_status": "success",
+                "physical_test_id": physical_test_id,
+                "physical_test_status": "success",
+                "physical_test_kind": "preflight",
+                "physical_test_run_id": new_governance_uid(),
+            }
+        ]
+    )
+
+    result = DataRuleRepository(session).get_asset_evidence(
+        asset_type="rule", version_id=version_id
+    )
+
+    assert result["asset_type"] == "rule"
+    assert result["version_id"] == version_id
+    assert result["status"] == "published"
+    assert result["stages"]["generation"] == {
+        "id": generation_id,
+        "status": "ready",
+        "candidate_hash": "a" * 64,
+        "model_hash": "b" * 64,
+        "prompt_hash": "c" * 64,
+        "context_hash": "d" * 64,
+    }
+    assert result["stages"]["logical_compile"]["id"] == logical_compile_id
+    assert result["stages"]["dry_run"]["id"] == logical_test_id
+    assert result["stages"]["publication"]["id"] == publication_id
+    assert result["stages"]["physical"][0]["plan_id"] == physical_plan_id
+    serialized = json.dumps(result)
+    assert "source_text" not in serialized
+    assert '"candidate":' not in serialized
+    assert "evidence" not in serialized
+    assert "artifact" not in serialized
+    sql = _sql(session)
+    assert "safe_rule_asset_evidence" in sql
+    assert "rule_generation_runs" in sql
+    assert "rule_logical_compile_evidence" in sql
+    assert "rule_logical_test_evidence" in sql
+    assert "rule_publication_audits" in sql
+    assert "rule_compile_evidence" in sql
+    assert "rule_test_evidence" in sql
+    assert "legacy_untrusted = FALSE" in sql
+
+
+def test_standard_asset_evidence_is_safe_and_lists_fixed_rule_versions():
+    from app.core.data_rules.repository import DataRuleRepository
+
+    version_id = new_governance_uid()
+    standard_uid = new_governance_uid()
+    owner_uid = new_governance_uid()
+    rule_ids = [new_governance_uid(), new_governance_uid()]
+    session = FakeSession(
+        standard_asset_evidence_rows=[
+            {
+                "version_id": version_id,
+                "standard_uid": standard_uid,
+                "name": "客户手机号标准",
+                "version_no": 2,
+                "owner_uid": owner_uid,
+                "version_status": "published",
+                "published_at": "2026-07-24T01:02:03+00:00",
+                "rule_version_ids": rule_ids,
+            }
+        ]
+    )
+
+    result = DataRuleRepository(session).get_asset_evidence(
+        asset_type="standard", version_id=version_id
+    )
+
+    assert result == {
+        "asset_type": "standard",
+        "version_id": version_id,
+        "asset_uid": standard_uid,
+        "name": "客户手机号标准",
+        "version": 2,
+        "owner": owner_uid,
+        "status": "published",
+        "stages": {
+            "generation": None,
+            "logical_compile": None,
+            "dry_run": None,
+            "publication": {
+                "status": "published",
+                "published_at": "2026-07-24T01:02:03+00:00",
+            },
+            "physical": [],
+        },
+        "bound_rule_version_ids": rule_ids,
+    }
+    assert "safe_standard_asset_evidence" in _sql(session)
+
+
 def test_generation_run_persists_model_hashes_uncertainty_and_decision():
     from app.core.data_rules.repository import DataRuleRepository
 
@@ -347,11 +602,11 @@ def test_generation_run_persists_model_hashes_uncertainty_and_decision():
         "model_name": "qwen3",
         "prompt_version": "data-rule-authoring-v1",
         "schema_version": "1.0",
-            "context_hash": "a" * 64,
-            "candidate_hash": DataRuleRepository.candidate_hash(candidate),
-            "model_hash": "b" * 64,
-            "prompt_hash": "c" * 64,
-        }
+        "context_hash": "a" * 64,
+        "candidate_hash": DataRuleRepository.candidate_hash(candidate),
+        "model_hash": "b" * 64,
+        "prompt_hash": "c" * 64,
+    }
 
     result = DataRuleRepository(session).record_generation_run(
         evidence=evidence

+ 219 - 26
tests/test_data_rule_api.py

@@ -39,6 +39,27 @@ class FakeAuthoringAgent:
 class FakeRuleRepository:
     def __init__(self):
         self.calls = []
+        self.catalog_items = [
+            {
+                "asset_type": "rule",
+                "version_id": new_governance_uid(),
+                "asset_uid": new_governance_uid(),
+                "name": "手机号规范化",
+                "version": 3,
+                "owner": new_governance_uid(),
+                "status": "published",
+                "schema_compatibility": {
+                    "input": "a" * 64,
+                    "output": "b" * 64,
+                },
+                "impact_count": 2,
+                "backend": "polars_batch",
+                "latest_evidence": {
+                    "compile": {"status": "success"},
+                    "test": {"status": "success"},
+                },
+            }
+        ]
 
     def create_rule_version(self, **kwargs):
         self.calls.append(("create_rule_version", kwargs))
@@ -105,6 +126,35 @@ class FakeRuleRepository:
             "spec_hash": "b" * 64,
         }
 
+    def search_published_assets(self, **kwargs):
+        self.calls.append(("search_published_assets", kwargs))
+        return {
+            "items": self.catalog_items,
+            "total": 1,
+            "limit": kwargs["limit"],
+            "offset": kwargs["offset"],
+        }
+
+    def get_asset_evidence(self, **kwargs):
+        self.calls.append(("get_asset_evidence", kwargs))
+        return {
+            "asset_type": kwargs["asset_type"],
+            "version_id": kwargs["version_id"],
+            "stages": {
+                "generation": {"status": "ready"},
+                "logical_compile": {"status": "success"},
+                "dry_run": {"status": "success"},
+                "publication": {"status": "published"},
+                "physical": [
+                    {
+                        "backend": "polars_batch",
+                        "compile": {"status": "success"},
+                        "test": {"status": "success"},
+                    }
+                ],
+            },
+        }
+
 
 class FakeReleaseService:
     def __init__(self):
@@ -146,7 +196,10 @@ class FakePublicationService:
 
     def validate(self, version_id, actor_uid):
         self.repository.calls.append(
-            ("validate_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
+            (
+                "validate_rule_version",
+                {"version_id": version_id, "actor_uid": actor_uid},
+            )
         )
         return {
             "version_id": version_id,
@@ -177,7 +230,10 @@ class FakePublicationService:
 
     def publish(self, version_id, actor_uid):
         self.repository.calls.append(
-            ("publish_rule_version", {"version_id": version_id, "actor_uid": actor_uid})
+            (
+                "publish_rule_version",
+                {"version_id": version_id, "actor_uid": actor_uid},
+            )
         )
         return {
             "id": version_id,
@@ -237,7 +293,9 @@ class SnapshotOnlyRepository:
 
     def persist_schema_snapshot(self, *, snapshot):
         value = {"id": new_governance_uid(), **snapshot}
-        self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = value
+        self.snapshots[(snapshot["schema_ref"], snapshot["schema_hash"])] = (
+            value
+        )
         return value
 
 
@@ -268,7 +326,9 @@ def _use_token_identity(monkeypatch):
     )
 
 
-def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatch):
+def test_rule_capabilities_and_validation_are_registered_and_governed(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()
@@ -279,7 +339,9 @@ def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatc
     )
     client = app.test_client()
 
-    response = client.get("/api/rules/capabilities", headers=_headers(app, "viewer"))
+    response = client.get(
+        "/api/rules/capabilities", headers=_headers(app, "viewer")
+    )
     assert response.status_code == 200
     capabilities = response.get_json()["data"]
     assert capabilities["natural_language_authoring"] is True
@@ -307,7 +369,9 @@ def test_rule_capabilities_and_validation_are_registered_and_governed(monkeypatc
     assert forbidden.status_code == 403
 
 
-def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch):
+def test_rule_interpret_uses_configured_agent_and_preserves_surface(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()
@@ -330,9 +394,7 @@ def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch)
             "context": {
                 "input_schema_snapshot_id": new_governance_uid(),
                 "output_schema_snapshot_id": new_governance_uid(),
-                "input_sample_artifact_ref": (
-                    "minio://trusted/input.parquet"
-                ),
+                "input_sample_artifact_ref": ("minio://trusted/input.parquet"),
                 "golden_output_artifact_ref": None,
             },
         },
@@ -348,7 +410,9 @@ def test_rule_interpret_uses_configured_agent_and_preserves_surface(monkeypatch)
     assert repository.calls[1][0] == "record_generation_run"
 
 
-def test_rule_interpret_preflights_receipt_signer_before_model_call(monkeypatch):
+def test_rule_interpret_preflights_receipt_signer_before_model_call(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()
@@ -369,9 +433,7 @@ def test_rule_interpret_preflights_receipt_signer_before_model_call(monkeypatch)
             "context": {
                 "input_schema_snapshot_id": new_governance_uid(),
                 "output_schema_snapshot_id": new_governance_uid(),
-                "input_sample_artifact_ref": (
-                    "minio://trusted/input.parquet"
-                ),
+                "input_sample_artifact_ref": ("minio://trusted/input.parquet"),
                 "golden_output_artifact_ref": None,
             },
         },
@@ -430,14 +492,12 @@ def test_published_rule_catalog_uses_canonical_closed_contract(monkeypatch):
     _use_token_identity(monkeypatch)
     app.config["TESTING"] = True
     repository = FakeRuleRepository()
-    app.extensions["rule_publication_service"] = FakePublicationService(
-        repository
-    )
+    app.extensions["data_rule_repository"] = repository
     client = app.test_client()
     headers = _headers(app, "viewer")
 
     response = client.get(
-        "/api/rules/catalog?query=mobile&limit=10",
+        "/api/rules/catalog?query=mobile&asset_type=rule&limit=10&offset=20",
         headers=headers,
     )
     rejected = client.get(
@@ -446,11 +506,133 @@ def test_published_rule_catalog_uses_canonical_closed_contract(monkeypatch):
     )
 
     assert response.status_code == 200
-    assert response.get_json()["data"] == {"items": []}
+    assert response.get_json()["data"] == {
+        "items": repository.catalog_items,
+        "total": 1,
+        "limit": 10,
+        "offset": 20,
+    }
+    assert repository.calls[-1] == (
+        "search_published_assets",
+        {
+            "query": "mobile",
+            "asset_type": "rule",
+            "limit": 10,
+            "offset": 20,
+        },
+    )
     assert rejected.status_code == 400
 
 
-def test_production_line_resolve_preview_expands_standard_without_writing(monkeypatch):
+def test_unified_catalog_defaults_to_all_assets_and_preserves_rule_alias(
+    monkeypatch,
+):
+    from app import create_app
+
+    app = create_app()
+    _use_token_identity(monkeypatch)
+    app.config["TESTING"] = True
+    repository = FakeRuleRepository()
+    app.extensions["data_rule_repository"] = repository
+    client = app.test_client()
+    headers = _headers(app, "viewer")
+
+    canonical = client.get("/api/rules/catalog", headers=headers)
+    alias = client.get("/api/rules/catalog/rule-versions", headers=headers)
+
+    assert canonical.status_code == 200
+    assert repository.calls[0] == (
+        "search_published_assets",
+        {"query": "", "asset_type": None, "limit": 50, "offset": 0},
+    )
+    assert alias.status_code == 200
+    assert repository.calls[1][1]["asset_type"] == "rule"
+
+
+def test_catalog_and_evidence_queries_are_closed_bounded_and_rules_read_only(
+    monkeypatch,
+):
+    from app import create_app
+
+    app = create_app()
+    _use_token_identity(monkeypatch)
+    app.config["TESTING"] = True
+    repository = FakeRuleRepository()
+    app.extensions["data_rule_repository"] = repository
+    client = app.test_client()
+    viewer = _headers(app, "viewer")
+    version_id = new_governance_uid()
+
+    evidence = client.get(
+        f"/api/rules/catalog/assets/rule/{version_id}/evidence",
+        headers=viewer,
+    )
+
+    assert evidence.status_code == 200
+    value = evidence.get_json()["data"]
+    assert set(value["stages"]) == {
+        "generation",
+        "logical_compile",
+        "dry_run",
+        "publication",
+        "physical",
+    }
+    assert "source_text" not in str(value)
+    assert "sample" not in str(value)
+    assert repository.calls[-1] == (
+        "get_asset_evidence",
+        {"asset_type": "rule", "version_id": version_id},
+    )
+    assert (
+        client.get(
+            "/api/rules/catalog?asset_type=dataflow", headers=viewer
+        ).status_code
+        == 400
+    )
+    assert (
+        client.get("/api/rules/catalog?limit=101", headers=viewer).status_code
+        == 400
+    )
+    assert (
+        client.get("/api/rules/catalog?offset=-1", headers=viewer).status_code
+        == 400
+    )
+    assert (
+        client.get(
+            f"/api/rules/catalog/assets/rule/{version_id}/evidence"
+        ).status_code
+        == 401
+    )
+
+
+def test_legacy_rule_evidence_path_uses_same_safe_repository_contract(
+    monkeypatch,
+):
+    from app import create_app
+
+    app = create_app()
+    _use_token_identity(monkeypatch)
+    app.config["TESTING"] = True
+    repository = FakeRuleRepository()
+    app.extensions["data_rule_repository"] = repository
+    client = app.test_client()
+    version_id = new_governance_uid()
+
+    response = client.get(
+        f"/api/rules/rule-versions/{version_id}/evidence",
+        headers=_headers(app, "viewer"),
+    )
+
+    assert response.status_code == 200
+    assert repository.calls[-1] == (
+        "get_asset_evidence",
+        {"asset_type": "rule", "version_id": version_id},
+    )
+
+
+def test_production_line_resolve_preview_expands_standard_without_writing(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()
@@ -602,7 +784,9 @@ def test_create_version_rejects_client_selected_lifecycle_status(monkeypatch):
     app.config["TESTING"] = True
     repository = FakeRuleRepository()
     app.extensions["data_rule_repository"] = repository
-    app.extensions["rule_publication_service"] = FakePublicationService(repository)
+    app.extensions["rule_publication_service"] = FakePublicationService(
+        repository
+    )
     client = app.test_client()
 
     response = client.post(
@@ -626,8 +810,9 @@ def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
     monkeypatch.delenv("RULE_GENERATION_RECEIPT_SECRET", raising=False)
     app = create_app()
     app.config["RULE_GENERATION_RECEIPT_SECRET"] = None
-    with app.app_context(), pytest.raises(
-        RuntimeError, match="receipt secret"
+    with (
+        app.app_context(),
+        pytest.raises(RuntimeError, match="receipt secret"),
     ):
         _receipt_signer()
 
@@ -639,14 +824,18 @@ def test_generation_receipt_signer_requires_dedicated_secret(monkeypatch):
     assert signer is not None
 
 
-def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(monkeypatch):
+def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()
     _use_token_identity(monkeypatch)
     app.config["TESTING"] = True
     repository = FakeRuleRepository()
-    app.extensions["rule_publication_service"] = FakePublicationService(repository)
+    app.extensions["rule_publication_service"] = FakePublicationService(
+        repository
+    )
     client = app.test_client()
     version_id = new_governance_uid()
 
@@ -669,7 +858,9 @@ def test_rule_gates_reject_caller_supplied_compile_or_test_evidence(monkeypatch)
     assert repository.calls == []
 
 
-def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeypatch):
+def test_create_rule_version_rejects_legacy_v1_payload_before_repository(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()
@@ -691,7 +882,9 @@ def test_create_rule_version_rejects_legacy_v1_payload_before_repository(monkeyp
     assert repository.calls == []
 
 
-def test_dataflow_release_uses_server_assets_and_release_permission(monkeypatch):
+def test_dataflow_release_uses_server_assets_and_release_permission(
+    monkeypatch,
+):
     from app import create_app
 
     app = create_app()

+ 92 - 8
tests/test_data_rule_frontend_contract.py

@@ -1,8 +1,12 @@
 from pathlib import Path
 
-
 API = Path("frontend/src/api/dataRules.js")
 AUTHORING = Path("frontend/src/components/DataRules/RuleAuthoringPanel.vue")
+CATALOG = Path("frontend/src/components/DataRules/RuleCatalogPicker.vue")
+EVIDENCE = Path("frontend/src/components/DataRules/CompilationEvidence.vue")
+ASSEMBLER = Path(
+    "frontend/src/components/DataRules/ProductionLineAssembler.vue"
+)
 STANDARD = Path(
     "frontend/src/views/dataGovernance/dataStandard/components/edit.vue"
 )
@@ -24,6 +28,10 @@ def test_rule_api_client_exposes_versions_publish_and_server_side_release():
     assert "http.post('/rules/production-lines/resolve'" in source
     assert "http.post('/rules/rule-versions'" in source
     assert "http.post(`/rules/rule-versions/${versionId}/publish`)" in source
+    assert "http.post(`/rules/rule-versions/${versionId}/validate`" in source
+    assert "http.post(`/rules/rule-versions/${versionId}/test`" in source
+    assert "http.get('/rules/catalog'" in source
+    assert "/rules/catalog/assets/${assetType}/${versionId}/evidence" in source
     assert "http.post('/rules/standard-versions'" in source
     assert "http.post(`/rules/standard-versions/${versionId}/publish`)" in source
     assert "http.post(`/rules/production-lines/${dataflowUid}/release`" in source
@@ -45,17 +53,93 @@ def test_reusable_authoring_panel_preserves_human_review_gate():
     assert "自动执行" not in source
 
 
-def test_standard_and_dataflow_both_embed_ai_rule_authoring():
+def test_authoring_panel_exposes_governed_ai_lifecycle_without_code_editor():
+    source = AUTHORING.read_text(encoding="utf-8")
+
+    for token in [
+        "schemaContextReady",
+        "generation_receipt",
+        "assumptions",
+        "ambiguities",
+        "modelProvenance",
+        "validateRuleVersion",
+        "testRuleVersion",
+        "CompilationEvidence",
+    ]:
+        assert token in source
+    assert "candidatePreview" not in source
+    assert "<pre>{{ candidate" not in source
+
+
+def test_catalog_picker_selects_fixed_published_asset_versions():
+    source = CATALOG.read_text(encoding="utf-8")
+
+    for token in [
+        "searchPublishedCatalog",
+        "assetType",
+        "published",
+        "schema_compatibility",
+        "latest_evidence",
+        "v-skeleton-loader",
+        "aria-label",
+        "keydown",
+    ]:
+        assert token in source
+    assert "$emit('input', item.id)" in source
+    assert "item.name" in source
+    assert "rule_uid" in source
+    assert "standard_uid" in source
+
+
+def test_compilation_evidence_shows_trusted_chain_without_sensitive_payloads():
+    source = EVIDENCE.read_text(encoding="utf-8")
+
+    for token in [
+        "generation",
+        "logical_compile",
+        "dry_run",
+        "publication",
+        "physical",
+        "getRuleVersionEvidence",
+    ]:
+        assert token in source
+    for forbidden in ["raw_sample", "secret", "password", "token"]:
+        assert forbidden not in source.lower()
+
+
+def test_standard_links_published_rules_and_keeps_legacy_code_read_only():
     standard = STANDARD.read_text(encoding="utf-8")
-    dataflow = DATAFLOW.read_text(encoding="utf-8")
 
     assert 'authoring-surface="data_standard"' in standard
-    assert "handleRuleCandidate" in standard
     assert '@version="handleRuleVersion"' in standard
-    assert 'authoring-surface="data_flow"' in dataflow
-    assert "handleRuleCandidate" in dataflow
-    assert '@version="handleRuleVersion"' in dataflow
-    assert "rule_spec" in dataflow
+    assert "RuleCatalogPicker" in standard
+    assert "rule_version_id" in standard
+    assert "legacyMigration" in standard
+    assert "readonly" in standard
+    assert "迁移" in standard
+    assert "dataStandardCodeGenerate" not in standard
+    assert "handleCodeGenerate" not in standard
+
+
+def test_dataflow_assembler_uses_published_catalog_ids_without_inline_rules():
+    source = ASSEMBLER.read_text(encoding="utf-8")
+    dataflow = DATAFLOW.read_text(encoding="utf-8")
+
+    assert "RuleCatalogPicker" in source
+    assert "standard_version_id" in source
+    assert "rule_version_id" in source
+    assert "schema_compatibility" in source
+    assert "releaseReadiness" in source
+    assert "component_kind" in source
+    assert "rule_spec" not in source
+    assert "operation_code" not in source
+
+    assert "ProductionLineAssembler" in dataflow
+    assert "dataflow_spec" in dataflow
+    assert "migration_metadata" in dataflow
+    assert "rule_spec" not in dataflow
+    assert "v-model=\"changeObj.rule\"" not in dataflow
+    assert "handleRuleCandidate" not in dataflow
 
 
 def test_data_factory_exposes_release_ready_but_no_fake_activation():

Некоторые файлы не были показаны из-за большого количества измененных файлов