From e9eeb2e41d35037c1ec0af9723187c8d7ef6988c Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 11 May 2026 06:32:38 +0000 Subject: [PATCH] feat(audit): connect rule center to live asset APIs --- web/src/assets/styles/views/audit-view.css | 465 ++++- web/src/services/agentAssets.js | 116 ++ web/src/views/AuditView.vue | 392 +++- web/src/views/scripts/AuditView.js | 1965 ++++++++++++-------- 4 files changed, 2099 insertions(+), 839 deletions(-) create mode 100644 web/src/services/agentAssets.js diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css index cd98751..712f149 100644 --- a/web/src/assets/styles/views/audit-view.css +++ b/web/src/assets/styles/views/audit-view.css @@ -27,9 +27,11 @@ } .skill-list { - display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr); + display: flex; + flex-direction: column; + min-height: 0; padding: 18px 20px; + overflow: hidden; } .status-tabs { @@ -73,13 +75,15 @@ .filter-set { display: flex; + align-items: center; gap: 10px; + flex: 1 1 auto; flex-wrap: wrap; } .search-filter { - width: 260px; - min-height: 36px; + width: 280px; + min-height: 38px; display: inline-flex; align-items: center; gap: 8px; @@ -109,23 +113,150 @@ color: #94a3b8; } -.filter-btn, +.search-filter:focus-within { + border-color: rgba(16, 185, 129, 0.48); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); +} + +.picker-trigger, +.ghost-filter-btn, .create-btn, .row-action { - min-height: 36px; + min-height: 38px; border-radius: 8px; font-size: 13px; font-weight: 760; } -.filter-btn { +.picker-filter { + position: relative; +} + +.picker-trigger { + min-width: 124px; display: inline-flex; align-items: center; - gap: 6px; - padding: 0 12px; + justify-content: space-between; + gap: 8px; + padding: 0 34px 0 12px; border: 1px solid #d7e0ea; background: #fff; color: #334155; + white-space: nowrap; +} + +.picker-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.picker-trigger .mdi { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: #64748b; + pointer-events: none; +} + +.picker-trigger:hover, +.picker-filter.open .picker-trigger { + border-color: rgba(16, 185, 129, 0.34); + background: #f6fffb; + color: #0f9f78; +} + +.picker-popover { + position: absolute; + top: calc(100% + 8px); + left: 0; + width: 224px; + z-index: 40; + display: grid; + gap: 14px; + padding: 16px; + border: 1px solid #d7e0ea; + border-radius: 12px; + background: #fff; + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16); +} + +.picker-popover header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.picker-popover header strong { + color: #0f172a; + font-size: 15px; +} + +.picker-popover header button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: #64748b; +} + +.picker-popover header button:hover { + background: #f1f5f9; + color: #0f172a; +} + +.picker-option-list { + display: grid; + gap: 8px; + max-height: 240px; + overflow-y: auto; +} + +.picker-option { + min-height: 36px; + display: inline-flex; + align-items: center; + padding: 0 12px; + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #334155; + font-size: 13px; + font-weight: 750; + text-align: left; +} + +.picker-option:hover, +.picker-option.active { + border-color: rgba(16, 185, 129, 0.32); + background: rgba(16, 185, 129, 0.08); + color: #059669; +} + +.toolbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.ghost-filter-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0 12px; + border: 1px solid #d7e0ea; + background: #fff; + color: #475569; +} + +.ghost-filter-btn:hover { + border-color: rgba(16, 185, 129, 0.28); + color: #059669; } .create-btn { @@ -139,6 +270,13 @@ box-shadow: 0 8px 18px rgba(5, 150, 105, 0.18); } +.create-btn:disabled { + background: #cbd5e1; + color: #f8fafc; + box-shadow: none; + cursor: not-allowed; +} + .hint { display: inline-flex; align-items: center; @@ -152,13 +290,113 @@ color: #94a3b8; } +.active-filter-strip { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.active-filter-chip { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 0 10px; + border-radius: 999px; + background: rgba(16, 185, 129, 0.1); + color: #047857; + font-size: 12px; + font-weight: 800; +} + .table-wrap { + flex: 1 1 auto; + position: relative; min-height: 0; overflow: auto; border: 1px solid #edf2f7; border-radius: 12px; } +.table-state, +.detail-inline-state { + min-height: 220px; + display: grid; + place-items: center; + gap: 12px; + padding: 28px 24px; + text-align: center; + color: #64748b; +} + +.detail-inline-state { + min-height: 180px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + text-align: left; +} + +.table-state i, +.detail-inline-state i { + font-size: 28px; + color: #94a3b8; +} + +.table-state.error i, +.detail-inline-state.error i { + color: #dc2626; +} + +.table-state.empty i { + color: #0ea5e9; +} + +.table-state p, +.detail-inline-state p { + margin-top: 6px; + font-size: 13px; + line-height: 1.6; +} + +.detail-inline-state strong { + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.detail-inline-state > div { + flex: 1 1 auto; +} + +.state-action { + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 14px; + border: 1px solid rgba(16, 185, 129, 0.28); + border-radius: 8px; + background: #fff; + color: #059669; + font-size: 13px; + font-weight: 760; +} + +.list-foot { + display: flex; + align-items: center; + justify-content: flex-end; + padding-top: 12px; +} + +.page-summary { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + table { width: 100%; min-width: 1120px; @@ -268,6 +506,16 @@ tbody tr.spotlight { color: #6366f1; } +.status-pill.danger { + background: #fee2e2; + color: #dc2626; +} + +.status-pill.disabled { + background: #e2e8f0; + color: #475569; +} + .row-action { padding: 0 12px; border: 1px solid rgba(16, 185, 129, 0.32); @@ -275,6 +523,10 @@ tbody tr.spotlight { color: #059669; } +.row-action:hover { + background: rgba(16, 185, 129, 0.08); +} + .detail-scroll { height: 100%; overflow: auto; @@ -285,6 +537,8 @@ tbody tr.spotlight { .detail-hero { display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + align-items: start; gap: 10px; padding: 16px 20px; } @@ -336,6 +590,34 @@ tbody tr.spotlight { margin-top: 10px; } +.review-note-block { + display: grid; + gap: 6px; + margin-top: 12px; + padding: 12px 14px; + border: 1px solid rgba(16, 185, 129, 0.16); + border-radius: 12px; + background: linear-gradient(180deg, #f8fffc, #ffffff); +} + +.review-note-block strong { + color: #0f172a; + font-size: 13px; + font-weight: 850; +} + +.review-note-block p, +.review-note-block span { + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + +.review-note-block.muted { + border-color: #e2e8f0; + background: #f8fafc; +} + .hero-review-meta span { display: inline-flex; align-items: center; @@ -491,6 +773,13 @@ tbody tr.spotlight { resize: vertical; } +.field input[readonly], +.field textarea[readonly], +.prompt-block textarea[readonly] { + background: #f8fafc; + color: #334155; +} + .markdown-card { min-height: 620px; display: grid; @@ -511,6 +800,39 @@ tbody tr.spotlight { white-space: pre; } +.markdown-editor.disabled { + background: #f8fafc; + color: #475569; +} + +.subtle-banner, +.editor-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.subtle-banner { + min-height: 38px; + margin-bottom: 10px; + padding: 0 12px; + border: 1px solid #e0f2fe; + border-radius: 10px; + background: #f0f9ff; + color: #0369a1; + font-size: 12px; + font-weight: 700; +} + +.editor-foot { + margin-top: 12px; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + .skill-review-side { align-content: start; padding-right: 8px; @@ -637,6 +959,21 @@ tbody tr.spotlight { line-height: 1.5; } +.empty-side-note { + min-height: 120px; + display: grid; + place-items: center; + gap: 8px; + color: #64748b; + font-size: 13px; + text-align: center; +} + +.empty-side-note i { + font-size: 24px; + color: #94a3b8; +} + .modal-backdrop { position: fixed; inset: 0; @@ -830,6 +1167,26 @@ tbody tr.spotlight { color: #ea580c; } +.test-state.danger, +.tool-state.danger { + background: #fee2e2; + color: #dc2626; +} + +.review-action-strip { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 16px; +} + +.action-help { + margin-top: 12px; + color: #64748b; + font-size: 12px; + line-height: 1.6; +} + .tag-list { display: flex; gap: 8px; @@ -919,6 +1276,16 @@ tbody tr.spotlight { color: #334155; } +.minor-action.success-action { + border-color: rgba(5, 150, 105, 0.26); + color: #059669; +} + +.minor-action.danger-action { + border-color: rgba(220, 38, 38, 0.2); + color: #dc2626; +} + .major-action { border: 1px solid #059669; background: #059669; @@ -926,8 +1293,50 @@ tbody tr.spotlight { box-shadow: 0 4px 12px rgba(5, 150, 105, .16); } +.back-action:hover, +.minor-action:hover, +.major-action:hover, +.mini-btn:hover { + transform: translateY(-1px); +} + +.back-action:disabled, +.minor-action:disabled, +.major-action:disabled, +.mini-btn:disabled { + opacity: 0.52; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.detail-meta-actions { + align-items: center; +} + +.footer-note { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.mini-btn { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 12px; + border: 1px solid #d7e0ea; + border-radius: 8px; + background: #fff; + color: #334155; + font-size: 13px; + font-weight: 760; +} + .mini-btn.primary { - border-color: transparent; + border-color: #059669; background: #059669; color: #fff; } @@ -950,6 +1359,14 @@ tbody tr.spotlight { .detail-grid.skill-md-detail-grid { grid-template-columns: 1fr; } + + .skill-review-side { + padding-right: 0; + } + + .review-card { + position: static; + } } @media (max-width: 860px) { @@ -963,7 +1380,9 @@ tbody tr.spotlight { .list-toolbar, .card-head, .detail-actions, - .detail-action-group { + .detail-action-group, + .toolbar-actions, + .detail-inline-state { flex-direction: column; align-items: stretch; } @@ -973,12 +1392,36 @@ tbody tr.spotlight { overflow-x: auto; } + .search-filter, + .picker-trigger, + .picker-filter, + .toolbar-actions > * { + width: 100%; + } + + .picker-popover { + width: min(100vw - 64px, 320px); + } + .hero-stats, .form-grid, .contract-grid { grid-template-columns: 1fr; } + .review-action-strip, + .modal-actions { + flex-direction: column; + } + + .version-modal-summary { + grid-template-columns: 1fr; + } + + .version-modal-summary i { + transform: rotate(90deg); + } + .field.span-2 { grid-column: span 1; } diff --git a/web/src/services/agentAssets.js b/web/src/services/agentAssets.js new file mode 100644 index 0000000..96dc7de --- /dev/null +++ b/web/src/services/agentAssets.js @@ -0,0 +1,116 @@ +import { apiRequest } from './api.js' + +const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user' + +function readActorName() { + if (typeof window === 'undefined') { + return 'system' + } + + const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY) + if (!raw) { + return 'system' + } + + try { + const payload = JSON.parse(raw) + return String(payload?.name || payload?.username || 'system').trim() || 'system' + } catch { + return 'system' + } +} + +function buildWriteHeaders(options = {}) { + const actor = String(options.actor || readActorName()).trim() || 'system' + const headers = { + 'x-actor': actor + } + + if (options.requestId) { + headers['x-request-id'] = String(options.requestId).trim() + } + + return headers +} + +function buildQuery(params = {}) { + const search = new URLSearchParams() + + if (params.assetType) { + search.set('asset_type', params.assetType) + } + + if (params.status) { + search.set('status', params.status) + } + + if (params.domain) { + search.set('domain', params.domain) + } + + if (params.keyword) { + search.set('keyword', params.keyword) + } + + if (params.limit) { + search.set('limit', String(params.limit)) + } + + if (params.agent) { + search.set('agent', params.agent) + } + + if (params.source) { + search.set('source', params.source) + } + + const query = search.toString() + return query ? `?${query}` : '' +} + +export function fetchAgentAssets(params = {}) { + return apiRequest(`/agent-assets${buildQuery(params)}`) +} + +export function fetchAgentAssetDetail(assetId) { + return apiRequest(`/agent-assets/${assetId}`) +} + +export function fetchAgentAssetVersions(assetId, limit = 5) { + return apiRequest(`/agent-assets/${assetId}/versions${buildQuery({ limit })}`) +} + +export function updateAgentAsset(assetId, payload, options = {}) { + return apiRequest(`/agent-assets/${assetId}`, { + method: 'PATCH', + body: JSON.stringify(payload), + headers: buildWriteHeaders(options) + }) +} + +export function createAgentAssetVersion(assetId, payload, options = {}) { + return apiRequest(`/agent-assets/${assetId}/versions`, { + method: 'POST', + body: JSON.stringify(payload), + headers: buildWriteHeaders(options) + }) +} + +export function createAgentAssetReview(assetId, payload, options = {}) { + return apiRequest(`/agent-assets/${assetId}/reviews`, { + method: 'POST', + body: JSON.stringify(payload), + headers: buildWriteHeaders(options) + }) +} + +export function activateAgentAsset(assetId, options = {}) { + return apiRequest(`/agent-assets/${assetId}/activate`, { + method: 'POST', + headers: buildWriteHeaders(options) + }) +} + +export function fetchAgentRuns(params = {}) { + return apiRequest(`/agent-runs${buildQuery(params)}`) +} diff --git a/web/src/views/AuditView.vue b/web/src/views/AuditView.vue index 8126b2a..110f2c7 100644 --- a/web/src/views/AuditView.vue +++ b/web/src/views/AuditView.vue @@ -7,59 +7,144 @@
{{ selectedSkill.typeLabel }}

{{ selectedSkill.name }}

-

{{ selectedSkill.summary }}

-
+

{{ selectedSkill.summary || '当前资产尚未补充说明。' }}

+ +
+ + + {{ selectedSkill.code }} + + + + 负责人:{{ selectedSkill.owner }} + 审核人:{{ selectedSkill.reviewer }} {{ selectedSkill.status }} - {{ selectedSkill.status === '已上线' ? '审核通过,可上线' : '待审核通过后上线' }} + + {{ selectedSkill.reviewStatusLabel }} + +
+ +
+ 上线约束 +

{{ activateBlockedReason || '当前规则版本审核通过后可正式上线。' }}

+ + 审核时间:{{ selectedSkill.reviewTimeLabel }} + + +
+
+ +
+
+ 资产编码 + {{ selectedSkill.code }} +
+
+ 业务域 + {{ selectedSkill.category }} +
+
+ {{ selectedSkillIsRule ? '当前展示版本' : '当前版本' }} + {{ selectedSkill.displayVersion || selectedSkill.version }} +
+
+ 最近更新 + {{ selectedSkill.updatedAt }}
-
+
+ +
+ 资产详情加载失败 +

{{ detailError }}

+
+ +
+ +
+ +
+ 正在加载资产详情 +

列表数据已就绪,正在补充版本、审核和运行信息。

+
+
+ +

Markdown 规则内容

-

管理员直接编辑该规则对应的 .md 审查规则文件内容。

+

当前展示版本:{{ selectedSkill.displayVersion }},保存后会生成新的版本快照。

-
+
+ + 正在刷新规则详情... +
+ + +
+ 版本说明:{{ selectedSkill.currentVersionChangeNote }} + 最近保存:{{ selectedSkill.updatedAt }} +
+ +
+ 只读模式 +

当前账号没有规则编辑权限,Markdown 仅可查看。

+
-
+

{{ selectedSkill.configTitle }}

{{ selectedSkill.configDesc }}

- + {{ selectedSkill.version }}
@@ -74,23 +159,29 @@
-
+
{{ section.title }} {{ section.intent }}
- +
-
+

{{ selectedSkill.outputTitle }}

{{ selectedSkill.outputDesc }}

- + + {{ selectedSkill.type === 'rules' ? selectedSkill.reviewStatusLabel : selectedSkill.publishState }} +
@@ -100,6 +191,7 @@
  • {{ rule }}
  • +

    {{ selectedSkill.checkListTitle }}

    @@ -111,6 +203,40 @@
    + +
    + + + +
    + +

    + {{ activateBlockedReason }} +

    @@ -119,29 +245,34 @@

    版本信息

    -

    最近 5 个规则版本

    +

    最近 5 个规则版本,仅切换当前展示内容。

    -
    +
    + +
    + + 暂无版本历史 +
    @@ -165,8 +296,8 @@

    {{ selectedSkill.toolDesc }}

    -
    -
    +
    +
    {{ tool.name }} {{ tool.scope }} @@ -174,6 +305,10 @@ {{ tool.mode }}
    +
    + + 暂无依赖信息 +
    @@ -183,13 +318,17 @@

    {{ selectedSkill.historyDesc }}

    -
    -
    +
    +
    {{ item.version }} {{ item.note }} {{ item.time }}
    +
    + + 暂无版本记录 +
    @@ -207,25 +346,36 @@
    - -
    - - -
    + +
    + {{ selectedSkill.publishMeta }} +
    @@ -252,21 +402,161 @@ :placeholder="searchPlaceholder" /> - + +
    + +
    + + +
    + +
    + + +
    +
    + +
    + + +
    -

    {{ hintText }}

    +
    + + {{ token }} + +
    +
    - +
    + +

    正在加载{{ activeTabLabel }}资产...

    +
    + +
    + +

    {{ errorMessage }}

    + +
    + +
    + +

    没有匹配的资产数据

    +
    + +
    @@ -286,7 +576,7 @@ v-for="skill in visibleSkills" :key="skill.id" :class="{ spotlight: skill.spotlight }" - @click="selectedSkill = skill" + @click="openAssetDetail(skill)" >
    {{ tableColumns.name }}
    @@ -306,14 +596,18 @@
    {{ skill.hitRate }} {{ skill.updatedAt }} -
    + + @@ -323,14 +617,14 @@

    切换规则版本

    -

    切换后编辑器会加载该版本的 .md 内容,当前未保存内容不会自动发布。

    +

    切换后编辑器只会替换当前展示内容,不会直接回滚后端当前版本。

    - 当前版本 - {{ selectedSkill?.version }} + 当前展示版本 + {{ selectedSkill?.displayVersion }}
    diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index d8c1c4f..1ed72c6 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -1,122 +1,796 @@ -import { computed, ref, watch } from 'vue' +import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' + +import { useSystemState } from '../../composables/useSystemState.js' +import { useToast } from '../../composables/useToast.js' +import { + activateAgentAsset, + createAgentAssetReview, + createAgentAssetVersion, + fetchAgentAssetDetail, + fetchAgentAssets, + fetchAgentRuns +} from '../../services/agentAssets.js' +import { isManagerUser } from '../../utils/accessControl.js' const TYPE_META = { rules: { - createButtonLabel: '新建规则', - hintText: '规则对应报销审查 .md 文件,点击任意行查看规则内容、审核状态与版本。', - filters: ['按规则场景筛选', '按风险等级筛选', '按负责人筛选'], + assetType: 'rule', + label: '规则', + typeLabel: '规则', + createButtonLabel: '规则已接入', + hintText: '规则列表已接到真实资产 API,可查看 Markdown、版本、审核状态和上线约束。', + searchPlaceholder: '搜索规则名称、编码或负责人', tableColumns: { - name: '规则文件', - category: '规则分类', + name: '规则名称', + category: '业务域', owner: '负责人', - scope: '适用范围', - runtime: '执行节点', - version: '版本', - metric: '命中率' + scope: '适用场景', + runtime: '风险等级', + version: '当前版本', + metric: '审核状态' } }, skills: { - createButtonLabel: '新建技能', - hintText: '技能用于承载可复用的审批、问答、解释和数据处理能力。', - filters: ['按技能场景筛选', '按调用方式筛选', '按负责人筛选'], + assetType: 'skill', + label: '技能', + typeLabel: '技能', + createButtonLabel: '技能已接入', + hintText: '技能页签已接到真实资产 API,可查看输入、输出、依赖和场景信息。', + searchPlaceholder: '搜索技能名称、编码或负责人', tableColumns: { name: '技能名称', - category: '技能分类', + category: '业务域', owner: '负责人', - scope: '适用范围', - runtime: '调用方式', - version: '版本', - metric: '命中率' + scope: '适用场景', + runtime: '输入摘要', + version: '当前版本', + metric: '输出摘要' } }, mcp: { - createButtonLabel: '接入 MCP', - hintText: 'MCP 管理外部服务连接,如发票验真、预算、银行流水、OCR 与差旅平台。', - filters: ['按服务类型筛选', '按权限筛选', '按状态筛选'], + assetType: 'mcp', + label: 'MCP', + typeLabel: 'MCP', + createButtonLabel: 'MCP 已接入', + hintText: 'MCP 页签已接到真实资产 API,可查看服务地址、鉴权方式、超时和降级策略。', + searchPlaceholder: '搜索 MCP 名称、编码或负责人', tableColumns: { name: 'MCP 服务', - category: '服务类型', + category: '业务域', owner: '维护人', - scope: '服务范围', - runtime: '调用方式', - version: '协议', - metric: '可用率' + scope: '适用场景', + runtime: '调用地址', + version: '当前版本', + metric: '超时配置' } }, - schedules: { - createButtonLabel: '新建任务', - hintText: '任务用于每日风险检查、知识积累、报销报账和账款信息统计。', - filters: ['按运行频率筛选', '按产出类型筛选', '按告警级别筛选'], + tasks: { + assetType: 'task', + label: '任务', + typeLabel: '任务', + createButtonLabel: '任务已接入', + hintText: '任务页签已接到真实资产 API,可查看调度周期、执行 Agent 和最近执行结果。', + searchPlaceholder: '搜索任务名称、编码或负责人', tableColumns: { name: '任务名称', - category: '任务类型', + category: '业务域', owner: '负责人', - scope: '统计范围', - runtime: '运行计划', - version: '调度', - metric: '成功率' + scope: '适用场景', + runtime: '调度周期', + version: '当前版本', + metric: '执行 Agent' } } } -function buildAsset({ - type, - typeLabel, - id, - short, - name, - summary, - category, - owner, - reviewer = '待分配', - scope, - model, - version, - status, - statusTone, - hitRate, - updatedAt, - badgeTone, - triggerMode, - spotlight = false, - fields, - sections, - rules, - checks, - triggers, - tools, - history, - titles, - publish, - markdownContent = '' -}) { +const BADGE_TONES = { + rules: 'emerald', + skills: 'blue', + mcp: 'amber', + tasks: 'violet' +} + +const STATUS_META = { + draft: { label: '草稿中', tone: 'draft' }, + review: { label: '待审核', tone: 'warning' }, + active: { label: '已上线', tone: 'success' }, + disabled: { label: '已停用', tone: 'disabled' } +} + +const REVIEW_META = { + approved: { label: '已通过', tone: 'success' }, + pending: { label: '待审核', tone: 'warning' }, + rejected: { label: '已驳回', tone: 'danger' } +} + +const DOMAIN_LABELS = { + expense: '报销', + ar: '应收', + ap: '应付', + knowledge: '知识', + system: '系统' +} + +const SCENARIO_LABELS = { + expense: '报销', + risk_check: '风险检查', + duplicate_expense: '重复报销', + explain: '规则解释', + invoice_anomaly: '票据异常', + accounts_payable: '应付', + accounts_receivable: '应收', + approval_required: '需审批', + query: '查询', + summary: '汇总', + system: '系统', + schedule: '调度', + rule_center: '规则中心', + review_digest: '待审摘要', + aging_summary: '账龄汇总', + invoice_validation: '发票验真' +} + +const DETAIL_TITLES = { + rules: { + configTitle: '规则元信息', + configDesc: '展示规则编码、版本、业务域和当前审核 / 上线状态。', + detailTitle: '规则版本说明', + detailDesc: '规则正文由 Markdown 驱动,保存后会生成新的版本快照。', + outputTitle: '审核与上线', + outputDesc: '规则上线受审核状态控制,未审核通过的版本会被后端拦截。', + ruleListTitle: '上线要求', + checkListTitle: '当前状态', + triggerTitle: '适用场景', + triggerDesc: '当前规则注册到的业务场景', + toolTitle: '关联信息', + toolDesc: '规则当前审核、保存和版本快照信息', + historyTitle: '版本历史', + historyDesc: '最近 5 个规则版本', + publishTitle: '上线控制', + publishDesc: '正式上线会调用后端激活接口,审核未通过时会被拦截。' + }, + skills: { + configTitle: '技能配置', + configDesc: '展示技能编码、输入摘要、版本和业务域。', + detailTitle: '技能结构', + detailDesc: '按输入、输出和依赖组织技能定义。', + outputTitle: '输出契约', + outputDesc: '技能详情重点展示输入参数、输出参数和依赖能力。', + ruleListTitle: '输出要求', + checkListTitle: '当前快照', + triggerTitle: '适用场景', + triggerDesc: '当前技能注册到的场景标签', + toolTitle: '依赖能力', + toolDesc: '技能当前依赖的数据库或其他能力', + historyTitle: '版本历史', + historyDesc: '最近版本记录', + publishTitle: '发布状态', + publishDesc: '技能当前状态由资产中心统一管理。' + }, + mcp: { + configTitle: 'MCP 连接配置', + configDesc: '展示服务地址、超时和调用方式。', + detailTitle: '服务协议', + detailDesc: '按服务类型、鉴权方式和降级策略组织外部服务信息。', + outputTitle: '调用约束', + outputDesc: 'MCP 详情重点展示鉴权方式、返回策略和最近调用状态。', + ruleListTitle: '调用约束', + checkListTitle: '最近状态', + triggerTitle: '适用场景', + triggerDesc: '当前 MCP 覆盖的业务场景', + toolTitle: '运行信息', + toolDesc: '结合 AgentRun 中的 ToolCall 还原最近一次调用状态', + historyTitle: '版本历史', + historyDesc: '最近版本记录', + publishTitle: '服务状态', + publishDesc: 'MCP 资产已接入规则中心,但真实外部调用仍以后续链路集成为准。' + }, + tasks: { + configTitle: '任务配置', + configDesc: '展示调度周期、执行 Agent 和任务编码。', + detailTitle: '任务结构', + detailDesc: '按调度计划、目标场景和运行结果组织任务信息。', + outputTitle: '运行要求', + outputDesc: '任务详情重点展示调度 Agent、最近运行结果和运行日志入口。', + ruleListTitle: '运行要求', + checkListTitle: '最近执行', + triggerTitle: '适用场景', + triggerDesc: '当前任务覆盖的业务场景', + toolTitle: '最近调用', + toolDesc: '根据 AgentRun 中的最近执行记录回显任务运行情况', + historyTitle: '版本历史', + historyDesc: '最近版本记录', + publishTitle: '调度状态', + publishDesc: '任务资产已接入规则中心,后续 Day 4 运行时会继续消费这些配置。' + } +} + +const STATUS_OPTIONS = [ + { value: '', label: '全部状态' }, + { value: 'draft', label: '草稿中' }, + { value: 'review', label: '待审核' }, + { value: 'active', label: '已上线' }, + { value: 'disabled', label: '已停用' } +] + +function normalizeText(value) { + return String(value || '').trim() +} + +function makeShort(value) { + const text = normalizeText(value).replace(/\s+/g, '') + if (!text) { + return 'AG' + } + return text.slice(0, 2).toUpperCase() +} + +function formatDateTime(value) { + if (!value) { + return '未记录' + } + + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return String(value) + } + + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }) + .format(date) + .replace(/\//g, '-') +} + +function resolveDomainLabel(value) { + return DOMAIN_LABELS[value] || normalizeText(value) || '未分类' +} + +function resolveStatusMeta(value) { + return STATUS_META[value] || { label: normalizeText(value) || '未知状态', tone: 'draft' } +} + +function resolveReviewMeta(value) { + return REVIEW_META[value] || { label: '暂无审核', tone: 'draft' } +} + +function formatScenarioList(items) { + if (!Array.isArray(items) || !items.length) { + return '未配置场景' + } + + return items + .map((item) => SCENARIO_LABELS[item] || item) + .filter(Boolean) + .join(' / ') +} + +function buildHistory(recentVersions = []) { + return recentVersions.map((item) => ({ + version: item.version, + note: item.change_note || '无版本说明', + time: formatDateTime(item.created_at), + content: item.content, + contentType: item.content_type, + createdBy: item.created_by, + isCurrent: Boolean(item.is_current) + })) +} + +function resolveTypeKey(assetType) { + if (assetType === 'rule') { + return 'rules' + } + if (assetType === 'skill') { + return 'skills' + } + if (assetType === 'mcp') { + return 'mcp' + } + return 'tasks' +} + +function formatSeverity(value) { + const severity = normalizeText(value).toLowerCase() + if (severity === 'high') { + return '高风险' + } + if (severity === 'medium') { + return '中风险' + } + if (severity === 'low') { + return '低风险' + } + return '未配置' +} + +function formatInputSummary(items) { + if (!Array.isArray(items) || !items.length) { + return '无输入' + } + return `${items.length} 项输入` +} + +function formatOutputSummary(items) { + if (!Array.isArray(items) || !items.length) { + return '无输出' + } + return `${items.length} 项输出` +} + +function formatTaskRisk(scenarios) { + if (Array.isArray(scenarios) && scenarios.includes('risk_check')) { + return '高风险' + } + if ( + Array.isArray(scenarios) && + (scenarios.includes('accounts_receivable') || scenarios.includes('accounts_payable')) + ) { + return '中风险' + } + return '常规' +} + +function findLatestTaskRun(runs, assetId) { + return runs.find((item) => item.task_id === assetId) || null +} + +function findLatestMcpCall(runs, assetCode) { + const expectedToolName = normalizeText(assetCode).replace(/^mcp\./, '') + + for (const run of runs) { + for (const toolCall of run.tool_calls || []) { + const toolName = normalizeText(toolCall.tool_name) + if ( + toolName === expectedToolName || + toolName.endsWith(expectedToolName) || + expectedToolName.endsWith(toolName) + ) { + return { + run, + toolCall + } + } + } + } + + return null +} + +function buildRowRuntime(asset, typeKey) { + if (typeKey === 'rules') { + return formatSeverity(asset.config_json?.severity) + } + if (typeKey === 'skills') { + return formatInputSummary(asset.config_json?.input_schema) + } + if (typeKey === 'mcp') { + return normalizeText(asset.config_json?.endpoint) || '未配置地址' + } + return normalizeText(asset.config_json?.cron) || '未配置调度' +} + +function buildRowMetric(asset, typeKey) { + if (typeKey === 'rules') { + return asset.reviewer ? `审核人:${asset.reviewer}` : '待分配审核人' + } + if (typeKey === 'skills') { + return '进入详情查看输出' + } + if (typeKey === 'mcp') { + return asset.config_json?.timeout_ms ? `${asset.config_json.timeout_ms} ms` : '未配置超时' + } + return normalizeText(asset.config_json?.agent) || '未配置 Agent' +} + +function buildListItem(asset) { + const typeKey = resolveTypeKey(asset.asset_type) + const statusMeta = resolveStatusMeta(asset.status) + return { - type, - typeLabel, - id, - short, - name, - summary, - category, - owner, - reviewer, - scope, - model, - version, - status, - statusTone, - hitRate, - updatedAt, - badgeTone, - triggerMode, - spotlight, - markdownContent, - fields, - promptSections: sections, - outputRules: rules, - tests: checks, - triggers, - tools, + id: asset.id, + type: typeKey, + typeLabel: TYPE_META[typeKey].typeLabel, + short: makeShort(asset.name), + name: asset.name, + code: asset.code, + summary: asset.description, + category: resolveDomainLabel(asset.domain), + owner: asset.owner, + reviewer: asset.reviewer || '待分配', + scope: formatScenarioList(asset.scenario_json), + model: buildRowRuntime(asset, typeKey), + version: asset.current_version || '-', + status: statusMeta.label, + statusValue: asset.status, + statusTone: statusMeta.tone, + hitRate: buildRowMetric(asset, typeKey), + updatedAt: formatDateTime(asset.updated_at), + badgeTone: BADGE_TONES[typeKey], + spotlight: asset.status === 'active', + domainValue: asset.domain + } +} + +function buildRuleFields(detail) { + return [ + { label: '规则编码', value: detail.code }, + { label: '业务域', value: resolveDomainLabel(detail.domain) }, + { label: '适用场景', value: formatScenarioList(detail.scenario_json) }, + { label: '当前版本', value: detail.current_version || '-' } + ] +} + +function buildSkillFields(detail) { + const content = detail.current_version_content || {} + return [ + { label: '技能编码', value: detail.code }, + { label: '业务域', value: resolveDomainLabel(detail.domain) }, + { + label: '输入参数', + value: Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('、') : '未配置' + }, + { + label: '输出参数', + value: Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('、') : '未配置' + } + ] +} + +function buildMcpFields(detail, latestCall) { + const content = detail.current_version_content || {} + return [ + { label: '服务编码', value: detail.code }, + { label: '调用地址', value: normalizeText(detail.config_json?.endpoint) || '未配置' }, + { label: '鉴权方式', value: normalizeText(content.auth_mode) || '未配置' }, + { + label: '最近调用', + value: latestCall ? `${latestCall.toolCall.status} / ${formatDateTime(latestCall.run.started_at)}` : '暂无调用记录' + } + ] +} + +function buildTaskFields(detail, latestRun) { + const content = detail.current_version_content || {} + return [ + { label: '任务编码', value: detail.code }, + { label: 'Cron', value: normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置' }, + { label: '执行 Agent', value: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置' }, + { label: '风险等级', value: formatTaskRisk(detail.scenario_json) }, + { + label: '最近执行', + value: latestRun ? formatDateTime(latestRun.started_at) : '暂无执行记录' + } + ] +} + +function buildFields(detail, typeKey, latestRun, latestCall) { + if (typeKey === 'rules') { + return buildRuleFields(detail) + } + if (typeKey === 'skills') { + return buildSkillFields(detail) + } + if (typeKey === 'mcp') { + return buildMcpFields(detail, latestCall) + } + return buildTaskFields(detail, latestRun) +} + +function buildPromptSections(detail, typeKey, latestRun, latestCall) { + const content = detail.current_version_content || {} + + if (typeKey === 'skills') { + return [ + { + title: '输入参数', + intent: '技能入口', + content: Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('\n') : '未配置输入参数。' + }, + { + title: '输出参数', + intent: '技能产出', + content: Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('\n') : '未配置输出参数。' + }, + { + title: '依赖能力', + intent: '外部依赖', + content: + Array.isArray(content.dependencies) && content.dependencies.length + ? content.dependencies.join('\n') + : '当前技能未声明外部依赖。' + } + ] + } + + if (typeKey === 'mcp') { + return [ + { + title: '服务类型', + intent: '协议说明', + content: normalizeText(content.service_type) || '未配置服务类型。' + }, + { + title: '鉴权方式', + intent: '安全要求', + content: normalizeText(content.auth_mode) || '未配置鉴权方式。' + }, + { + title: '降级策略', + intent: '失败处理', + content: normalizeText(content.degrade_strategy) || '未配置降级策略。' + } + ] + } + + return [ + { + title: '任务场景', + intent: '调度目标', + content: formatScenarioList(detail.scenario_json) + }, + { + title: '执行 Agent', + intent: '运行主体', + content: normalizeText(content.target_agent) || normalizeText(detail.config_json?.agent) || '未配置执行 Agent。' + }, + { + title: '最近执行结果', + intent: '运行反馈', + content: latestRun?.result_summary || latestRun?.error_message || '暂无执行记录。' + } + ] +} + +function buildOutputRules(detail, typeKey, latestRun, latestCall) { + const content = detail.current_version_content || {} + + if (typeKey === 'rules') { + return [ + '规则 Markdown 保存后会生成新版本。', + '未审核通过的规则版本不能正式上线。', + '版本切换当前只影响前端展示内容,不会直接回滚后端版本。' + ] + } + + if (typeKey === 'skills') { + return [ + `输入参数:${Array.isArray(content.inputs) && content.inputs.length ? content.inputs.join('、') : '未配置'}`, + `输出参数:${Array.isArray(content.outputs) && content.outputs.length ? content.outputs.join('、') : '未配置'}`, + `依赖能力:${Array.isArray(content.dependencies) && content.dependencies.length ? content.dependencies.join('、') : '未声明'}` + ] + } + + if (typeKey === 'mcp') { + return [ + `服务地址:${normalizeText(detail.config_json?.endpoint) || '未配置'}`, + `鉴权方式:${normalizeText(content.auth_mode) || '未配置'}`, + `降级策略:${normalizeText(content.degrade_strategy) || '未配置'}` + ] + } + + return [ + `调度周期:${normalizeText(detail.config_json?.cron) || normalizeText(content.schedule) || '未配置'}`, + `执行 Agent:${normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置'}`, + `风险等级:${formatTaskRisk(detail.scenario_json)}`, + `最近执行结果:${latestRun?.status || '暂无执行记录'}` + ] +} + +function buildTests(detail, typeKey, latestRun, latestCall) { + if (typeKey === 'rules') { + const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) + return [ + { + name: '审核状态', + input: detail.latest_review?.version || detail.current_version || '暂无版本', + result: reviewMeta.label, + tone: reviewMeta.tone + }, + { + name: '上线状态', + input: detail.current_version || '暂无版本', + result: resolveStatusMeta(detail.status).label, + tone: resolveStatusMeta(detail.status).tone + } + ] + } + + if (typeKey === 'skills') { + const content = detail.current_version_content || {} + return [ + { + name: '输入数量', + input: detail.current_version || '暂无版本', + result: `${content.inputs?.length || 0} 项`, + tone: 'success' + }, + { + name: '输出数量', + input: detail.current_version || '暂无版本', + result: `${content.outputs?.length || 0} 项`, + tone: 'success' + } + ] + } + + if (typeKey === 'mcp') { + return [ + { + name: '最近调用状态', + input: latestCall?.run?.run_id || '暂无调用', + result: latestCall?.toolCall?.status || '未记录', + tone: latestCall?.toolCall?.status === 'failed' ? 'danger' : 'success' + }, + { + name: '最近调用耗时', + input: latestCall?.toolCall?.tool_name || '暂无调用', + result: + typeof latestCall?.toolCall?.duration_ms === 'number' + ? `${latestCall.toolCall.duration_ms} ms` + : '未记录', + tone: 'success' + } + ] + } + + return [ + { + name: '最近运行状态', + input: latestRun?.run_id || '暂无运行', + result: latestRun?.status || '未记录', + tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'success' + }, + { + name: '结果摘要', + input: latestRun?.agent || normalizeText(detail.config_json?.agent) || '未配置', + result: latestRun?.result_summary || '暂无摘要', + tone: 'success' + } + ] +} + +function buildTools(detail, typeKey, latestRun, latestCall) { + const content = detail.current_version_content || {} + + if (typeKey === 'skills') { + return (content.dependencies || []).map((item) => ({ + name: item, + scope: '技能依赖', + mode: '读取', + tone: 'safe' + })) + } + + if (typeKey === 'mcp') { + return [ + { + name: normalizeText(content.service_type) || '未配置服务类型', + scope: '服务类型', + mode: 'MCP', + tone: 'active' + }, + { + name: normalizeText(content.auth_mode) || '未配置鉴权方式', + scope: '鉴权', + mode: '安全', + tone: 'safe' + }, + { + name: latestCall?.run?.run_id || '暂无调用记录', + scope: '最近 Run', + mode: latestCall?.toolCall?.status || '未执行', + tone: latestCall?.toolCall?.status === 'failed' ? 'danger' : 'active' + } + ] + } + + return [ + { + name: normalizeText(detail.config_json?.agent) || normalizeText(content.target_agent) || '未配置 Agent', + scope: '执行 Agent', + mode: '调度', + tone: 'active' + }, + { + name: latestRun?.run_id || '暂无执行记录', + scope: '最近 Run', + mode: latestRun?.status || '未执行', + tone: latestRun?.status === 'failed' || latestRun?.status === 'blocked' ? 'danger' : 'active' + }, + { + name: latestRun?.permission_level || '未记录', + scope: '权限级别', + mode: 'Trace', + tone: 'safe' + } + ] +} + +function buildPublishDescription(detail, typeKey) { + if (typeKey === 'rules') { + if (detail.status === 'active') { + return '当前规则版本已经上线,仍可继续保存新版本并重新走审核。' + } + return '当前规则需要先完成审核,再调用上线接口正式激活。' + } + + return DETAIL_TITLES[typeKey].publishDesc +} + +function buildDetailViewModel(detail, runs) { + const typeKey = resolveTypeKey(detail.asset_type) + const latestRun = typeKey === 'tasks' ? findLatestTaskRun(runs, detail.id) : null + const latestCall = typeKey === 'mcp' ? findLatestMcpCall(runs, detail.code) : null + const statusMeta = resolveStatusMeta(detail.status) + const reviewMeta = resolveReviewMeta(detail.latest_review?.review_status) + const history = buildHistory(detail.recent_versions || []) + const previewVersion = history.find((item) => item.isCurrent) || history[0] || null + const previewMarkdown = + detail.current_version_content_type === 'markdown' + ? String(previewVersion?.content ?? detail.current_version_content ?? '') + : '' + const titles = DETAIL_TITLES[typeKey] + + return { + id: detail.id, + type: typeKey, + typeLabel: TYPE_META[typeKey].typeLabel, + short: makeShort(detail.name), + name: detail.name, + code: detail.code, + summary: detail.description, + owner: detail.owner, + reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', + category: resolveDomainLabel(detail.domain), + scope: formatScenarioList(detail.scenario_json), + version: detail.current_version || '-', + currentVersion: detail.current_version || '-', + displayVersion: previewVersion?.version || detail.current_version || '-', + status: statusMeta.label, + statusValue: detail.status, + statusTone: statusMeta.tone, + hitRate: buildRowMetric(detail, typeKey), + updatedAt: formatDateTime(detail.updated_at), + badgeTone: BADGE_TONES[typeKey], + markdownContent: previewMarkdown, + currentVersionContentType: detail.current_version_content_type, + currentVersionChangeNote: detail.current_version_change_note || '无版本说明', + reviewStatusLabel: reviewMeta.label, + reviewStatusTone: reviewMeta.tone, + reviewStatusValue: detail.latest_review?.review_status || '', + reviewTimeLabel: formatDateTime(detail.latest_review?.reviewed_at), + reviewNote: detail.latest_review?.review_note || '', + latestRun, + latestCall, + fields: buildFields(detail, typeKey, latestRun, latestCall), + promptSections: + typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall), + outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall), + tests: buildTests(detail, typeKey, latestRun, latestCall), + triggers: detail.scenario_json?.length ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) : ['未配置场景'], + tools: + typeKey === 'rules' + ? [ + { + name: detail.latest_review?.reviewer || '待分配审核人', + scope: '审核负责人', + mode: reviewMeta.label, + tone: reviewMeta.tone + }, + { + name: detail.current_version || '暂无版本', + scope: '当前版本', + mode: detail.current_version_change_note || '无版本说明', + tone: 'safe' + } + ] + : buildTools(detail, typeKey, latestRun, latestCall), history, configTitle: titles.configTitle, configDesc: titles.configDesc, @@ -133,702 +807,176 @@ function buildAsset({ historyTitle: titles.historyTitle, historyDesc: titles.historyDesc, publishTitle: titles.publishTitle, - publishDesc: titles.publishDesc, - publishMeta: publish.meta, - publishState: publish.state, - heroStats: [ - { label: type === 'schedules' ? '调度计划' : '版本', value: version }, - { label: '状态', value: status, kind: 'status' }, - { label: type === 'mcp' ? '服务可用率' : type === 'schedules' ? '运行成功率' : '触发命中率', value: hitRate }, - { label: '负责人', value: owner } - ] + publishDesc: buildPublishDescription(detail, typeKey), + publishMeta: + typeKey === 'rules' + ? `最近保存:${formatDateTime(detail.updated_at)}` + : latestRun + ? `最近运行:${formatDateTime(latestRun.started_at)}` + : `最近更新:${formatDateTime(detail.updated_at)}`, + publishState: statusMeta.label, + latestReviewVersion: detail.latest_review?.version || detail.current_version || '-', + loading: false } } -const assets = [ - buildAsset({ - type: 'rules', - typeLabel: '规则', - id: 'SKL-001', - short: 'DR', - name: '重复报销识别规则.md', - summary: '识别同一发票号、金额、商户和日期组合下的重复报销风险。', - category: '报销审查规则', - owner: '财务风控组', - reviewer: '周敏', - scope: '发票与费用明细', - model: '提交时 + 夜间复核', - version: 'v1.9', - status: '已上线', - statusTone: 'success', - hitRate: '96.8%', - updatedAt: '2026-05-09 08:10', - badgeTone: 'emerald', - triggerMode: '申请提交 / 财务初审 / 夜间批扫', - spotlight: true, - fields: [ - { label: '规则名称', value: '重复报销识别规则.md' }, - { label: '文件路径', value: 'skills/reimbursement/duplicate-invoice.md' }, - { label: '规则等级', value: '高风险' }, - { label: '执行节点', value: '提交时 + 夜间复核' } - ], - markdownContent: `# 重复报销识别规则 +function incrementVersion(version) { + const normalized = normalizeText(version).replace(/^v/i, '') + const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/) -## 目标 -识别同一发票、同一商户金额组合或高度相似附件导致的重复报销风险。 + if (!match) { + return 'v1.0.0' + } -## 适用范围 -- 费用类型:差旅、办公采购、业务招待、供应商报账 -- 执行节点:申请提交、财务初审、夜间批扫 -- 风险等级:高风险 - -## 输入字段 -- invoice_code:发票代码 -- invoice_number:发票号码 -- seller_name:销售方名称 -- seller_tax_no:销售方税号 -- total_amount:价税合计金额 -- issue_date:开票日期 -- attachment_hash:附件影像哈希 -- applicant_id:申请人 -- reimbursement_id:当前报销单号 - -## 判断规则 -1. 发票代码和发票号码均一致时,直接命中高风险。 -2. 发票号码缺失时,销售方、金额、开票日期和附件哈希相似度超过 0.92,进入人工复核。 -3. 已驳回或已作废单据只作为参考证据,不直接判定重复。 -4. 同一供应商周期性账单需按账期字段排除误报。 - -## 输出 -\`\`\`json -{ - "risk_code": "duplicate_invoice", - "risk_level": "high", - "matched_reimbursement_ids": [], - "evidence": [], - "suggestion": "人工复核或要求申请人补充说明" + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + 1 + return `v${major}.${minor}.${patch}` } -\`\`\` -## 管理员备注 -- 调整阈值前需要抽样复核最近 7 日误报样例。 -- 新增排除条件必须补充测试样例。`, - sections: [ - { title: '输入字段', intent: '审查依赖', content: '发票号、销售方、金额、开票日期、附件 OCR、历史报销单号、提交人和成本中心。' }, - { title: '判断逻辑', intent: '规则主体', content: '同一发票号直接命中;发票号缺失时按销售方、金额、日期和影像哈希计算相似度。' }, - { title: '输出格式', intent: '风险结果', content: '输出重复风险标签、历史单据编号、相似字段、相似度和建议处置动作。' } - ], - rules: ['命中同一发票号时必须标记高风险。', '相似度超过 0.92 时进入人工复核。', '必须返回历史单据证据,不允许只给结论。'], - checks: [ - { name: '同发票号重复', input: '发票号完全一致', result: '通过', tone: 'success' }, - { name: '影像相似重复', input: '发票号模糊但金额日期一致', result: '通过', tone: 'success' }, - { name: '商户简称误报', input: '同商户不同日期', result: '待评审', tone: 'warning' } - ], - triggers: ['重复发票', '重复报销', '历史账本比对', '夜间风险扫描'], - tools: [ - { name: '发票 OCR MCP', scope: '票面字段识别', mode: '只读', tone: 'safe' }, - { name: '报销账本', scope: '历史单据检索', mode: '只读', tone: 'safe' }, - { name: '风险标记服务', scope: '写入审查结果', mode: '写入', tone: 'active' } - ], - history: [ - { version: 'v1.9', note: '收紧金额差异阈值', time: '05-09 08:10' }, - { version: 'v1.8', note: '加入附件哈希比对', time: '05-06 18:30' }, - { version: 'v1.7', note: '补充同供应商周期账单排除条件', time: '05-02 16:15' }, - { version: 'v1.6', note: '增加历史驳回单据参考证据', time: '04-28 11:20' }, - { version: 'v1.5', note: '上线发票号精确匹配规则', time: '04-22 09:40' } - ], - titles: { - configTitle: '规则文件配置', - configDesc: '技能以 .md 文件维护报销审查规则,配置规则路径、等级和执行节点。', - detailTitle: '规则结构', - detailDesc: '按输入字段、判断逻辑和输出格式组织审查规则。', - outputTitle: '产出与校验', - outputDesc: '确保规则能稳定输出风险标签、证据和处置建议。', - ruleListTitle: '输出要求', - checkListTitle: '测试样例', - triggerTitle: '触发规则', - triggerDesc: '当前命中策略', - toolTitle: '依赖能力', - toolDesc: '规则运行时调用', - historyTitle: '版本历史', - historyDesc: '最近变更', - publishTitle: '发布控制', - publishDesc: '规则已通过核心样例,可进入线上审查链路。', - }, - publish: { meta: '最近评审:2026-05-09 08:10', state: '可发布' } - }), - buildAsset({ - type: 'rules', - typeLabel: '规则', - id: 'SKL-002', - short: 'TS', - name: '差旅标准超额检查.md', - summary: '按城市、职级和费用类型检查差旅报销是否超过制度标准。', - category: '差旅审查规则', - owner: '制度运营组', - reviewer: '刘佳', - scope: '差旅报销', - model: '提交时触发', - version: 'v2.4', - status: '待评审', - statusTone: 'warning', - hitRate: '91.4%', - updatedAt: '2026-05-08 18:35', - badgeTone: 'amber', - triggerMode: '申请提交 / 审批中心', - fields: [ - { label: '规则名称', value: '差旅标准超额检查.md' }, - { label: '文件路径', value: 'skills/travel/standard-overrun.md' }, - { label: '规则等级', value: '中高风险' }, - { label: '执行节点', value: '提交时触发' } - ], - markdownContent: `# 差旅标准超额检查 - -## 目标 -根据公司差旅制度,检查住宿、交通、餐补等费用是否超过员工职级和城市等级对应标准。 - -## 适用范围 -- 单据类型:差旅报销、差旅借款冲销 -- 费用类型:住宿、机票、火车票、出租车、餐补 -- 执行节点:申请提交、审批中心 - -## 输入字段 -- employee_grade:员工职级 -- destination_city:目的地城市 -- city_tier:城市等级 -- expense_type:费用类型 -- expense_amount:费用金额 -- travel_days:出差天数 -- policy_refs:制度条款映射 -- exception_reason:例外说明 - -## 判断规则 -1. 按城市等级和员工职级读取报销标准上限。 -2. 单项费用超过标准时,计算超额金额和超额比例。 -3. 会议指定酒店、客户指定地点等例外原因存在时,转人工复核。 -4. 无例外说明且超额比例超过 20%,标记中高风险。 - -## 输出 -\`\`\`json -{ - "risk_code": "travel_standard_overrun", - "risk_level": "medium_high", - "over_limit_amount": 0, - "policy_refs": [], - "exception_required": true, - "suggestion": "补充例外说明或调整报销金额" +function buildReviewNote(status) { + if (status === 'approved') { + return '通过任务规则中心审核。' + } + if (status === 'rejected') { + return '在任务规则中心驳回当前版本。' + } + return '提交任务规则中心待审核。' } -\`\`\` - -## 管理员备注 -- 城市等级表每季度同步一次。 -- 制度条款更新后必须同步更新 policy_refs。`, - sections: [ - { title: '制度映射', intent: '条款来源', content: '关联城市等级、员工职级、住宿/交通/餐补标准和例外审批条款。' }, - { title: '判断逻辑', intent: '标准比对', content: '按目的地城市等级和职级读取上限,逐条计算超额金额。' }, - { title: '输出格式', intent: '审批依据', content: '输出超额金额、制度引用、例外条件和补充说明要求。' } - ], - rules: ['必须给出超额金额。', '必须引用制度条款。', '例外审批必须标记人工复核。'], - checks: [ - { name: '住宿超标', input: '一线城市住宿超 18%', result: '通过', tone: 'success' }, - { name: '会议酒店例外', input: '指定会议酒店超标', result: '评审中', tone: 'warning' } - ], - triggers: ['差旅标准', '住宿超标', '交通超标', '例外审批'], - tools: [ - { name: '制度知识库', scope: '条款引用', mode: '只读', tone: 'safe' }, - { name: '员工组织服务', scope: '职级与部门', mode: '只读', tone: 'safe' } - ], - history: [ - { version: 'v2.4', note: '新增会议酒店例外分支', time: '05-08 18:35' }, - { version: 'v2.3', note: '同步 2026 差旅制度', time: '05-06 10:20' }, - { version: 'v2.2', note: '调整新一线城市住宿上限', time: '05-01 14:05' }, - { version: 'v2.1', note: '增加餐补按天数校验', time: '04-25 17:30' }, - { version: 'v2.0', note: '重构城市等级映射表', time: '04-18 10:10' } - ], - titles: { - configTitle: '规则文件配置', - configDesc: '维护差旅制度条款映射、城市等级表和例外审批条件。', - detailTitle: '规则结构', - detailDesc: '按制度映射、判断逻辑和输出格式组织规则。', - outputTitle: '产出与校验', - outputDesc: '超额检查结果进入审批意见和员工补充说明。', - ruleListTitle: '输出要求', - checkListTitle: '测试样例', - triggerTitle: '触发规则', - triggerDesc: '当前命中策略', - toolTitle: '依赖能力', - toolDesc: '规则运行时调用', - historyTitle: '版本历史', - historyDesc: '最近变更', - publishTitle: '评审控制', - publishDesc: '当前规则仍有例外分支待评审。', - }, - publish: { meta: '最近评审:2026-05-08 18:35', state: '待评审' } - }), - buildAsset({ - type: 'rules', - typeLabel: '规则', - id: 'SKL-003', - short: 'AT', - name: '异常附件完整性检查.md', - summary: '检查报销附件是否缺失、重复、模糊或与费用类型不匹配。', - category: '附件审查规则', - owner: '财务共享组', - reviewer: '赵宁', - scope: '票据与附件', - model: '提交时触发', - version: 'v1.3', - status: '草稿中', - statusTone: 'draft', - hitRate: '84.6%', - updatedAt: '2026-05-07 16:20', - badgeTone: 'violet', - triggerMode: '申请提交 / 补件复核', - fields: [ - { label: '规则名称', value: '异常附件完整性检查.md' }, - { label: '文件路径', value: 'skills/reimbursement/attachment-integrity.md' }, - { label: '规则等级', value: '中风险' }, - { label: '执行节点', value: '提交时触发' } - ], - markdownContent: `# 异常附件完整性检查 - -## 目标 -检查报销单据附件是否完整、清晰,并确认附件类型与费用明细匹配。 - -## 适用范围 -- 单据类型:日常报销、差旅报销、供应商报账 -- 附件类型:发票、行程单、合同、审批截图、付款凭证 -- 执行节点:申请提交、补件复核 - -## 输入字段 -- reimbursement_id:报销单号 -- expense_type:费用类型 -- attachment_list:附件列表 -- attachment_ocr_text:附件 OCR 文本 -- required_attachment_types:必需附件类型 -- applicant_note:申请人说明 - -## 判断规则 -1. 必需附件缺失时,标记补件风险。 -2. 附件 OCR 置信度低于 0.75 时,标记影像不清晰。 -3. 附件类型与费用类型不匹配时,进入人工复核。 -4. 相同附件重复上传时,提示申请人清理重复附件。 - -## 输出 -\`\`\`json -{ - "risk_code": "attachment_integrity", - "risk_level": "medium", - "missing_attachments": [], - "invalid_attachments": [], - "suggestion": "补充缺失附件或重新上传清晰附件" -} -\`\`\` - -## 管理员备注 -- 必需附件清单由费用类型配置驱动。 -- 新增费用类型时必须同步补充 required_attachment_types。`, - sections: [ - { title: '附件清单', intent: '审查依赖', content: '读取费用类型对应的必需附件、已上传附件、OCR 文本和申请人说明。' }, - { title: '判断逻辑', intent: '规则主体', content: '检查缺失、模糊、重复和类型不匹配四类附件问题。' }, - { title: '输出格式', intent: '补件结果', content: '输出缺失附件、异常附件、风险等级和补件建议。' } - ], - rules: ['缺少必需附件时必须阻断提交。', '影像不清晰时允许提交但标记补件。', '重复附件只提示清理,不计入高风险。'], - checks: [ - { name: '缺少发票', input: '费用类型要求发票但未上传', result: '通过', tone: 'success' }, - { name: '附件模糊', input: 'OCR 置信度 0.62', result: '通过', tone: 'success' }, - { name: '合同附件误判', input: '合同页被识别为发票', result: '待修复', tone: 'warning' } - ], - triggers: ['附件缺失', '附件模糊', '补件复核', '类型不匹配'], - tools: [ - { name: '附件 OCR MCP', scope: '附件文字识别', mode: '只读', tone: 'safe' }, - { name: '费用类型配置', scope: '必需附件清单', mode: '只读', tone: 'safe' } - ], - history: [ - { version: 'v1.3', note: '新增附件类型与费用类型匹配检查', time: '05-07 16:20' }, - { version: 'v1.2', note: '补充 OCR 置信度阈值', time: '05-04 11:35' }, - { version: 'v1.1', note: '加入重复附件提示', time: '04-29 15:10' }, - { version: 'v1.0', note: '上线缺失附件检查', time: '04-24 09:00' }, - { version: 'v0.9', note: '完成规则草案评审', time: '04-20 17:45' } - ], - titles: { - configTitle: '规则文件配置', - configDesc: '技能以 .md 文件维护报销审查规则,配置规则路径、等级和执行节点。', - detailTitle: '规则结构', - detailDesc: '按附件清单、判断逻辑和输出格式组织规则。', - outputTitle: '产出与校验', - outputDesc: '附件检查结果进入提交拦截和补件提示。', - ruleListTitle: '输出要求', - checkListTitle: '测试样例', - triggerTitle: '触发规则', - triggerDesc: '当前命中策略', - toolTitle: '依赖能力', - toolDesc: '规则运行时调用', - historyTitle: '版本历史', - historyDesc: '最近变更', - publishTitle: '评审控制', - publishDesc: '当前规则仍有误判样例待修复。', - }, - publish: { meta: '最近评审:2026-05-07 16:20', state: '草稿中' } - }), - buildAsset({ - type: 'skills', - typeLabel: '技能', - id: 'SKL-AI-001', - short: 'AP', - name: '审批意见生成技能', - summary: '基于单据、规则命中和制度依据生成可复用的审批意见。', - category: '审批辅助技能', - owner: '审批运营组', - reviewer: '韩悦', - scope: '财务审批', - model: '审批中心按钮调用', - version: 'v1.8', - status: '已上线', - statusTone: 'success', - hitRate: '88.4%', - updatedAt: '2026-05-09 10:20', - badgeTone: 'blue', - triggerMode: '审批中心显式调用', - fields: [ - { label: '技能名称', value: '审批意见生成技能' }, - { label: '技能分类', value: '审批辅助技能' }, - { label: '调用入口', value: '审批中心 / 单据详情' }, - { label: '默认模型', value: 'GPT-5.4' } - ], - sections: [ - { title: '输入上下文', intent: '读取范围', content: '读取当前单据、规则命中结果、制度引用、附件状态和历史审批结论。' }, - { title: '生成策略', intent: '审批表达', content: '按通过、驳回、补件三类场景生成审批意见,并明确对应依据。' }, - { title: '输出格式', intent: '写回字段', content: '输出审批意见、判断依据、补件动作和可编辑的推荐话术。' } - ], - rules: ['必须引用规则命中或制度条款。', '驳回意见必须给出补充动作。', '不替代审批人最终决策。'], - checks: [ - { name: '高风险驳回意见', input: '重复发票 + 缺附件', result: '通过', tone: 'success' }, - { name: '低风险通过意见', input: '规则全部通过', result: '通过', tone: 'success' } - ], - triggers: ['生成审批意见', '通过意见', '驳回意见', '补件说明'], - tools: [ - { name: '规则命中结果', scope: '读取风险依据', mode: '只读', tone: 'safe' }, - { name: '制度知识库', scope: '条款引用', mode: '只读', tone: 'safe' }, - { name: '审批意见草稿', scope: '写入建议', mode: '写入', tone: 'active' } - ], - history: [ - { version: 'v1.8', note: '优化高风险驳回话术', time: '05-09 10:20' }, - { version: 'v1.7', note: '补充补件意见模板', time: '05-04 14:30' } - ], - titles: { - configTitle: '技能配置', - configDesc: '定义技能入口、适用范围、默认模型和上下文读取边界。', - detailTitle: '技能流程', - detailDesc: '按输入上下文、生成策略和输出格式组织技能行为。', - outputTitle: '输出契约与测试样例', - outputDesc: '确保技能在审批场景下输出稳定、可编辑且可追溯。', - ruleListTitle: '输出要求', - checkListTitle: '测试样例', - triggerTitle: '触发入口', - triggerDesc: '当前可调用场景', - toolTitle: '依赖能力', - toolDesc: '技能运行时调用', - historyTitle: '版本历史', - historyDesc: '最近变更', - publishTitle: '发布控制', - publishDesc: '当前技能已通过核心测试,可在审批中心使用。', - }, - publish: { meta: '最近评审:2026-05-09 10:20', state: '可用' } - }), - buildAsset({ - type: 'skills', - typeLabel: '技能', - id: 'SKL-AI-002', - short: 'EX', - name: '风险解释技能', - summary: '把规则命中的风险原因解释成员工可理解的补件或修正建议。', - category: '风险解释技能', - owner: '财务共享组', - reviewer: '赵宁', - scope: '员工自助', - model: '风险提示入口调用', - version: 'v1.4', - status: '待评审', - statusTone: 'warning', - hitRate: '82.1%', - updatedAt: '2026-05-08 15:10', - badgeTone: 'amber', - triggerMode: '风险拦截后调用', - fields: [ - { label: '技能名称', value: '风险解释技能' }, - { label: '技能分类', value: '风险解释技能' }, - { label: '调用入口', value: '提交拦截 / 补件提示' }, - { label: '默认模型', value: 'GPT-5.4-Mini' } - ], - sections: [ - { title: '输入上下文', intent: '读取范围', content: '读取风险标签、规则证据、附件状态、制度条款和当前流程节点。' }, - { title: '解释策略', intent: '用户表达', content: '用原因、影响、处理建议三段式解释风险,不暴露内部评分细节。' }, - { title: '输出格式', intent: '员工提示', content: '输出可执行的补件动作、重传要求或人工复核说明。' } - ], - rules: ['不展示内部风控分值。', '建议必须可执行。', '附件缺失时必须列出材料名称。'], - checks: [ - { name: '住宿超标解释', input: '酒店单晚超标 18%', result: '通过', tone: 'success' }, - { name: '附件缺失解释', input: '缺少发票附件', result: '评审中', tone: 'warning' } - ], - triggers: ['为什么被拦截', '补件说明', '风险原因', '重新提交建议'], - tools: [ - { name: '风险标签读取', scope: '异常原因', mode: '只读', tone: 'safe' }, - { name: '规则文件引用', scope: '解释依据', mode: '只读', tone: 'safe' } - ], - history: [ - { version: 'v1.4', note: '新增补件导向模板', time: '05-08 15:10' }, - { version: 'v1.3', note: '优化员工侧语气', time: '05-03 11:40' } - ], - titles: { - configTitle: '技能配置', - configDesc: '定义技能入口、适用范围、默认模型和风险解释边界。', - detailTitle: '技能流程', - detailDesc: '按输入上下文、解释策略和输出格式组织技能行为。', - outputTitle: '输出契约与测试样例', - outputDesc: '确保风险解释清晰、可执行且不过度暴露内部规则。', - ruleListTitle: '输出要求', - checkListTitle: '测试样例', - triggerTitle: '触发入口', - triggerDesc: '当前可调用场景', - toolTitle: '依赖能力', - toolDesc: '技能运行时调用', - historyTitle: '版本历史', - historyDesc: '最近变更', - publishTitle: '发布控制', - publishDesc: '当前技能仍需补充附件缺失场景样例。', - }, - publish: { meta: '最近评审:2026-05-08 15:10', state: '待评审' } - }), - buildAsset({ - type: 'mcp', - typeLabel: 'MCP', - id: 'MCP-001', - short: 'IV', - name: '发票验真 MCP', - summary: '连接税务票据验真、OCR 识别和发票状态查询能力。', - category: '票据服务', - owner: '平台集成组', - scope: '发票验真', - model: 'API 调用', - version: 'MCP 1.0', - status: '健康', - statusTone: 'success', - hitRate: '99.3%', - updatedAt: '2026-05-09 07:50', - badgeTone: 'blue', - triggerMode: '规则调用 / 批量任务调用', - fields: [ - { label: '服务名称', value: '发票验真 MCP' }, - { label: '服务地址', value: 'mcp://invoice-verification' }, - { label: '鉴权方式', value: 'API Key + IP 白名单' }, - { label: '降级策略', value: '转 OCR 本地解析并标记待验真' } - ], - sections: [ - { title: '输入参数', intent: '服务调用', content: '附件文件、发票代码、发票号码、开票日期、金额和请求来源 trace_id。' }, - { title: '调用链路', intent: '外部服务', content: '先 OCR 识别票面字段,再调用验真接口查询真伪、作废、红冲状态。' }, - { title: '返回结构', intent: '标准输出', content: '返回票据字段、验真状态、异常原因、耗时和外部服务流水号。' } - ], - rules: ['超时时间 8 秒。', '只读外部系统,不写入第三方。', '失败时必须返回可降级状态。'], - checks: [ - { name: '健康检查', input: '最近一次探活', result: '通过', tone: 'success' }, - { name: '批量验真', input: '436 张票据', result: '通过', tone: 'success' } - ], - triggers: ['发票 OCR', '验真查询', '作废红冲', '重复报销规则'], - tools: [ - { name: '税务票据平台', scope: '验真查询', mode: '外部', tone: 'active' }, - { name: '密钥管理', scope: 'API Key 加密', mode: '安全', tone: 'safe' } - ], - history: [ - { version: '07:50', note: '健康检查通过,延迟 580ms', time: '今日' }, - { version: '02:00', note: '夜间任务批量核验 436 张票据', time: '今日' } - ], - titles: { - configTitle: 'MCP 连接配置', - configDesc: '配置外部服务地址、鉴权方式、权限范围和失败降级策略。', - detailTitle: '服务协议', - detailDesc: '按输入参数、调用链路和返回结构描述 MCP 能力。', - outputTitle: '返回与检查', - outputDesc: '服务输出会被审查规则、审批中心和任务消费。', - ruleListTitle: '调用约束', - checkListTitle: '健康检查', - triggerTitle: '服务场景', - triggerDesc: '当前被哪些能力调用', - toolTitle: '外部依赖', - toolDesc: 'MCP 背后连接', - historyTitle: '调用记录', - historyDesc: '最近运行', - publishTitle: '连接状态', - publishDesc: '服务健康,可供规则与任务调用。', - }, - publish: { meta: '最近探活:2026-05-09 07:50', state: '可用' } - }), - buildAsset({ - type: 'schedules', - typeLabel: '任务', - id: 'JOB-001', - short: 'RK', - name: '每日风险巡检', - summary: '每天定时检查报销、报账、发票和账款数据,输出待处理风险队列。', - category: '风险巡检', - owner: '财务风控组', - scope: '全量待审与已付单据', - model: '每天 02:00', - version: '0 2 * * *', - status: '已调度', - statusTone: 'success', - hitRate: '100%', - updatedAt: '2026-05-09 02:18', - badgeTone: 'emerald', - triggerMode: 'Cron 定时调度', - fields: [ - { label: '任务名称', value: '每日风险巡检' }, - { label: 'Cron', value: '0 2 * * *' }, - { label: '扫描窗口', value: 'T-1 00:00 至 23:59' }, - { label: '告警阈值', value: '高风险 >= 10 或单笔 >= 50,000' } - ], - sections: [ - { title: '数据抽取', intent: '扫描范围', content: '读取昨日新增报销、报账、发票、付款流水和审批反馈。' }, - { title: '规则执行', intent: '风险判断', content: '运行重复报销、超标、作废票、异常付款等技能,并调用必要 MCP。' }, - { title: '结果写入', intent: '任务产出', content: '生成风险日报、风险工单、审计日志,并通知负责人。' } - ], - rules: ['任务失败必须告警。', '每次运行记录规则版本和扫描窗口。', '高风险工单必须进入审批中心。'], - checks: [ - { name: '今日运行', input: '扫描 2146 条记录', result: '成功', tone: 'success' }, - { name: '高风险推送', input: '19 条风险工单', result: '成功', tone: 'success' } - ], - triggers: ['每日风险检查', '发票验真', '账款核对', '风险日报'], - tools: [ - { name: '报销审查技能', scope: '规则执行', mode: '调用', tone: 'active' }, - { name: '发票验真 MCP', scope: '票据核验', mode: '调用', tone: 'active' }, - { name: '账款流水 MCP', scope: '付款核对', mode: '调用', tone: 'active' } - ], - history: [ - { version: '02:18', note: '今日运行成功,生成 19 条高风险工单', time: '今日' }, - { version: '02:00', note: '任务开始,扫描 2026-05-08 数据', time: '今日' } - ], - titles: { - configTitle: '任务配置', - configDesc: '配置运行频率、扫描范围、依赖能力和告警出口。', - detailTitle: '任务流程', - detailDesc: '从数据抽取、规则执行到结果写入描述调度链路。', - outputTitle: '产出与检查', - outputDesc: '任务产出进入风险看板、审批中心和审计留痕。', - ruleListTitle: '运行要求', - checkListTitle: '最近检查', - triggerTitle: '任务场景', - triggerDesc: '当前覆盖的日常运营事项', - toolTitle: '依赖能力', - toolDesc: '任务运行时调用', - historyTitle: '运行记录', - historyDesc: '最近执行', - publishTitle: '调度控制', - publishDesc: '任务已纳入每日自动巡检。', - }, - publish: { meta: '最近运行:2026-05-09 02:18', state: '已调度' } - }), - buildAsset({ - type: 'schedules', - typeLabel: '任务', - id: 'JOB-002', - short: 'FN', - name: '每日报销报账与账款统计', - summary: '每天统计报销、报账、付款和账款信息,更新运营总览和财务日报。', - category: '财务统计', - owner: '财务运营组', - scope: '报销、报账、账款', - model: '每天 06:00', - version: '0 6 * * *', - status: '已调度', - statusTone: 'success', - hitRate: '99.1%', - updatedAt: '2026-05-09 06:12', - badgeTone: 'blue', - triggerMode: 'Cron 定时调度', - fields: [ - { label: '任务名称', value: '每日报销报账与账款统计' }, - { label: 'Cron', value: '0 6 * * *' }, - { label: '统计口径', value: '自然日 + 财务月' }, - { label: '刷新对象', value: '运营总览、财务日报、账款看板' } - ], - sections: [ - { title: '账款同步', intent: '外部数据', content: '拉取银行流水、付款结果和供应商收款状态。' }, - { title: '指标聚合', intent: '统计口径', content: '按部门、费用类型、报账状态、付款状态和账龄聚合。' }, - { title: '日报刷新', intent: '看板产出', content: '写入财务日报快照,并刷新运营总览。' } - ], - rules: ['统计口径必须记录。', '账款同步失败时保留上一版快照并告警。', '日报刷新后写入审计日志。'], - checks: [ - { name: '日报刷新', input: '总览指标更新', result: '成功', tone: 'success' }, - { name: '账款匹配', input: '识别 8 条超期待付款', result: '成功', tone: 'success' } - ], - triggers: ['报销统计', '报账统计', '账款统计', '财务日报'], - tools: [ - { name: '账款流水 MCP', scope: '付款与流水', mode: '调用', tone: 'active' }, - { name: '报销报账数据库', scope: '统计读取', mode: '只读', tone: 'safe' } - ], - history: [ - { version: '06:12', note: '日报刷新完成,总览看板已更新', time: '今日' }, - { version: '06:04', note: '账款匹配完成,识别 8 条超期待付款', time: '今日' } - ], - titles: { - configTitle: '任务配置', - configDesc: '配置统计口径、维度、账款数据源和看板刷新策略。', - detailTitle: '任务流程', - detailDesc: '从账款同步、指标聚合到日报刷新描述调度链路。', - outputTitle: '产出与检查', - outputDesc: '统计产出用于总览、日报和账款追踪。', - ruleListTitle: '运行要求', - checkListTitle: '最近检查', - triggerTitle: '任务场景', - triggerDesc: '当前覆盖的日常运营事项', - toolTitle: '依赖能力', - toolDesc: '任务运行时调用', - historyTitle: '运行记录', - historyDesc: '最近执行', - publishTitle: '调度控制', - publishDesc: '任务已纳入每日财务运营统计。', - }, - publish: { meta: '最近运行:2026-05-09 06:12', state: '已调度' } - }) -] export default { name: 'AuditView', emits: ['detail-open-change'], - setup(props, { emit }) { - const tabs = [ - { id: 'rules', label: '规则' }, - { id: 'skills', label: '技能' }, - { id: 'mcp', label: 'MCP' }, - { id: 'schedules', label: '任务' } - ] + setup(_, { emit }) { + const { toast } = useToast() + const { currentUser } = useSystemState() + + const tabs = Object.entries(TYPE_META).map(([id, meta]) => ({ + id, + label: meta.label + })) + const activeType = ref('rules') const selectedSkill = ref(null) const versionSwitchTarget = ref(null) const keyword = ref('') + const activeFilterPopover = ref('') + const selectedDomain = ref('') + const selectedOwner = ref('') + const selectedStatus = ref('') + const loading = ref(false) + const errorMessage = ref('') + const detailLoading = ref(false) + const detailError = ref('') + const actionState = ref('') + const runLoading = ref(false) + const runs = ref([]) + const assetBuckets = ref({ + rules: [], + skills: [], + mcp: [], + tasks: [] + }) + const isAdmin = computed(() => isManagerUser(currentUser.value)) const activeMeta = computed(() => TYPE_META[activeType.value]) - const filters = computed(() => activeMeta.value.filters) + const activeTabLabel = computed(() => activeMeta.value.label) + const currentAssets = computed(() => assetBuckets.value[activeType.value] || []) + const searchPlaceholder = computed(() => activeMeta.value.searchPlaceholder) const createButtonLabel = computed(() => activeMeta.value.createButtonLabel) const hintText = computed(() => activeMeta.value.hintText) const tableColumns = computed(() => activeMeta.value.tableColumns) - const searchPlaceholder = computed(() => { - const label = tabs.find((tab) => tab.id === activeType.value)?.label || '内容' - return `搜索${label}` + const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules') + const canManageSelected = computed(() => isAdmin.value && Boolean(selectedSkill.value)) + const canEditMarkdown = computed(() => canManageSelected.value && selectedSkillIsRule.value) + const detailBusy = computed(() => Boolean(actionState.value)) + const showReviewNote = computed( + () => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel) + ) + const domainOptions = computed(() => { + const uniqueValues = [...new Set(currentAssets.value.map((item) => item.domainValue).filter(Boolean))] + return [ + { value: '', label: '全部业务域' }, + ...uniqueValues.map((value) => ({ + value, + label: resolveDomainLabel(value) + })) + ] + }) + const ownerOptions = computed(() => { + const uniqueOwners = [...new Set(currentAssets.value.map((item) => item.owner).filter(Boolean))] + return [ + { value: '', label: '全部负责人' }, + ...uniqueOwners.map((value) => ({ + value, + label: value + })) + ] + }) + const selectedDomainLabel = computed( + () => domainOptions.value.find((item) => item.value === selectedDomain.value)?.label || '业务域' + ) + const selectedOwnerLabel = computed( + () => ownerOptions.value.find((item) => item.value === selectedOwner.value)?.label || '负责人' + ) + const selectedStatusLabel = computed( + () => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态' + ) + const activeFilterTokens = computed(() => { + const tokens = [] + + if (selectedDomain.value) { + tokens.push(`业务域:${resolveDomainLabel(selectedDomain.value)}`) + } + if (selectedStatus.value) { + tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`) + } + if (selectedOwner.value) { + tokens.push(`负责人:${selectedOwner.value}`) + } + if (keyword.value.trim()) { + tokens.push(`搜索:${keyword.value.trim()}`) + } + + return tokens + }) + const canActivateSelected = computed(() => { + if (!selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { + return false + } + + return selectedSkill.value?.reviewStatusValue === 'approved' && selectedSkill.value?.statusValue !== 'active' + }) + const activateBlockedReason = computed(() => { + if (!selectedSkillIsRule.value) { + return '' + } + if (!canManageSelected.value) { + return '仅管理员可执行审核和上线。' + } + if (selectedSkill.value?.statusValue === 'active') { + return '当前规则版本已经上线。' + } + if (selectedSkill.value?.reviewStatusValue !== 'approved') { + return '当前规则版本未审核通过,不能上线。' + } + return '' }) const visibleSkills = computed(() => { const normalizedKeyword = keyword.value.trim().toLowerCase() - const scopedAssets = assets.filter((item) => item.type === activeType.value) - if (!normalizedKeyword) { - return scopedAssets - } + return currentAssets.value.filter((item) => { + const matchesKeyword = normalizedKeyword + ? [item.name, item.code, item.summary, item.owner, item.scope] + .filter(Boolean) + .some((value) => String(value).toLowerCase().includes(normalizedKeyword)) + : true + const matchesDomain = selectedDomain.value ? item.domainValue === selectedDomain.value : true + const matchesOwner = selectedOwner.value ? item.owner === selectedOwner.value : true + const matchesStatus = selectedStatus.value ? item.statusValue === selectedStatus.value : true - return scopedAssets.filter((item) => - [item.name, item.summary, item.category, item.owner, item.scope] - .filter(Boolean) - .some((value) => String(value).toLowerCase().includes(normalizedKeyword)) - ) + return matchesKeyword && matchesDomain && matchesOwner && matchesStatus + }) }) watch( @@ -839,11 +987,150 @@ export default { { immediate: true } ) - function openVersionSwitch(version) { - if (!selectedSkill.value || version.version === selectedSkill.value.version) { + watch(activeType, () => { + selectedSkill.value = null + versionSwitchTarget.value = null + resetFilters() + loadAssets({ force: true }).catch((error) => { + errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。' + }) + }) + + function resetFilters() { + keyword.value = '' + selectedDomain.value = '' + selectedOwner.value = '' + selectedStatus.value = '' + activeFilterPopover.value = '' + } + + function toggleFilterPopover(name) { + activeFilterPopover.value = activeFilterPopover.value === name ? '' : name + } + + function closeFilterPopover() { + activeFilterPopover.value = '' + } + + function selectFilter(name, value) { + if (name === 'domain') { + selectedDomain.value = value + } + if (name === 'owner') { + selectedOwner.value = value + } + if (name === 'status') { + selectedStatus.value = value + } + closeFilterPopover() + } + + function handleDocumentClick(event) { + const target = event.target + if (!(target instanceof Element)) { + closeFilterPopover() + return + } + if (!target.closest('.picker-filter')) { + closeFilterPopover() + } + } + + function resolveActor() { + return currentUser.value?.name || currentUser.value?.username || 'system' + } + + async function loadRuns(options = {}) { + if (runLoading.value && !options.force) { return } + runLoading.value = true + try { + const payload = await fetchAgentRuns({ limit: 50 }) + runs.value = Array.isArray(payload) ? payload : [] + } finally { + runLoading.value = false + } + } + + async function loadAssets(options = {}) { + loading.value = true + errorMessage.value = '' + + try { + const payload = await fetchAgentAssets({ assetType: activeMeta.value.assetType }) + assetBuckets.value = { + ...assetBuckets.value, + [activeType.value]: Array.isArray(payload) ? payload.map(buildListItem) : [] + } + } catch (error) { + assetBuckets.value = { + ...assetBuckets.value, + [activeType.value]: [] + } + errorMessage.value = error?.message || '资产数据加载失败,请稍后重试。' + if (!options.silent) { + toast(errorMessage.value) + } + } finally { + loading.value = false + } + } + + async function refreshCurrentAssets() { + await loadAssets({ force: true, silent: true }) + } + + async function loadSelectedAssetDetail(assetId) { + detailLoading.value = true + detailError.value = '' + + try { + if (!runs.value.length) { + await loadRuns() + } + const detail = await fetchAgentAssetDetail(assetId) + selectedSkill.value = buildDetailViewModel(detail, runs.value) + } catch (error) { + detailError.value = error?.message || '资产详情加载失败,请稍后重试。' + toast(detailError.value) + } finally { + detailLoading.value = false + } + } + + function openAssetDetail(asset) { + selectedSkill.value = { + ...asset, + fields: [], + promptSections: [], + outputRules: [], + tests: [], + triggers: [], + tools: [], + history: [], + markdownContent: '', + displayVersion: asset.version, + loading: true, + reviewStatusLabel: '加载中', + reviewStatusTone: 'draft' + } + versionSwitchTarget.value = null + loadSelectedAssetDetail(asset.id).catch(() => {}) + } + + function closeDetail() { + selectedSkill.value = null + detailError.value = '' + detailLoading.value = false + versionSwitchTarget.value = null + } + + function openVersionSwitch(version) { + if (!selectedSkill.value || version.version === selectedSkill.value.displayVersion) { + return + } versionSwitchTarget.value = version } @@ -856,33 +1143,153 @@ export default { return } - selectedSkill.value.version = versionSwitchTarget.value.version - selectedSkill.value.updatedAt = versionSwitchTarget.value.time - - if (versionSwitchTarget.value.markdownContent) { - selectedSkill.value.markdownContent = versionSwitchTarget.value.markdownContent - } else { - selectedSkill.value.markdownContent = `${selectedSkill.value.markdownContent}\n\n` + selectedSkill.value.displayVersion = versionSwitchTarget.value.version + if (typeof versionSwitchTarget.value.content === 'string') { + selectedSkill.value.markdownContent = versionSwitchTarget.value.content } - versionSwitchTarget.value = null } + async function saveRuleMarkdown() { + if (!selectedSkill.value || !selectedSkillIsRule.value || !canEditMarkdown.value || detailBusy.value) { + return + } + + if (!normalizeText(selectedSkill.value.markdownContent)) { + toast('规则 Markdown 内容不能为空。') + return + } + + const nextVersion = incrementVersion(selectedSkill.value.currentVersion) + actionState.value = 'save-markdown' + + try { + await createAgentAssetVersion( + selectedSkill.value.id, + { + version: nextVersion, + content: selectedSkill.value.markdownContent, + content_type: 'markdown', + change_note: '通过任务规则中心保存 Markdown 规则内容。', + created_by: resolveActor() + }, + { actor: resolveActor() } + ) + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast(`规则 Markdown 已保存为 ${nextVersion}。`) + } catch (error) { + toast(error?.message || '规则 Markdown 保存失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + + async function reviewSelectedRule(reviewStatus) { + if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { + return + } + + actionState.value = `review-${reviewStatus}` + + try { + await createAgentAssetReview( + selectedSkill.value.id, + { + version: selectedSkill.value.displayVersion || selectedSkill.value.currentVersion, + reviewer: resolveActor(), + review_status: reviewStatus, + review_note: buildReviewNote(reviewStatus) + }, + { actor: resolveActor() } + ) + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast(`当前规则版本已标记为${resolveReviewMeta(reviewStatus).label}。`) + } catch (error) { + toast(error?.message || '规则审核提交失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + + async function activateSelectedRule() { + if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { + return + } + + actionState.value = 'activate' + + try { + await activateAgentAsset(selectedSkill.value.id, { actor: resolveActor() }) + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast('规则已正式上线。') + } catch (error) { + toast(error?.message || '规则上线失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + + onMounted(() => { + document.addEventListener('click', handleDocumentClick) + loadAssets({ force: true }).catch(() => {}) + loadRuns().catch(() => {}) + }) + + onBeforeUnmount(() => { + document.removeEventListener('click', handleDocumentClick) + }) + return { tabs, - filters, activeType, - createButtonLabel, - hintText, - keyword, - searchPlaceholder, - tableColumns, + activeTabLabel, selectedSkill, versionSwitchTarget, + keyword, + createButtonLabel, + hintText, + searchPlaceholder, + tableColumns, visibleSkills, + loading, + errorMessage, + detailLoading, + detailError, + selectedDomain, + selectedOwner, + selectedStatus, + selectedDomainLabel, + selectedOwnerLabel, + selectedStatusLabel, + domainOptions, + ownerOptions, + statusOptions: STATUS_OPTIONS, + activeFilterPopover, + activeFilterTokens, + canManageSelected, + canEditMarkdown, + canActivateSelected, + activateBlockedReason, + selectedSkillIsRule, + detailBusy, + actionState, + showReviewNote, + openAssetDetail, + closeDetail, + resetFilters, + toggleFilterPopover, + selectFilter, + closeFilterPopover, openVersionSwitch, cancelVersionSwitch, - confirmVersionSwitch + confirmVersionSwitch, + saveRuleMarkdown, + reviewSelectedRule, + activateSelectedRule, + loadAssets } } }