test(admin): harden Muse admin governance checks
This commit is contained in:
parent
e068e0efff
commit
e3e330317f
@ -281,8 +281,7 @@ paths:
|
||||
required: [commandId]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键,前端生成 UUID
|
||||
licenseType:
|
||||
type: string
|
||||
@ -333,8 +332,7 @@ paths:
|
||||
required: [commandId]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键,前端生成 UUID
|
||||
versionId:
|
||||
type: integer
|
||||
@ -401,8 +399,7 @@ paths:
|
||||
format: int64
|
||||
description: 目标作品 ID
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
responses:
|
||||
'200':
|
||||
@ -443,8 +440,7 @@ paths:
|
||||
required: [assetId, targetOwner, targetAction, returnUrl, commandId]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 来源资产 ID
|
||||
targetOwner:
|
||||
type: string
|
||||
@ -467,8 +463,7 @@ paths:
|
||||
format: uri
|
||||
description: 完成后返回市场的 URL
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
responses:
|
||||
'200':
|
||||
@ -547,8 +542,7 @@ paths:
|
||||
required: [commandId, expectedStatus, reason]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
@ -669,8 +663,7 @@ paths:
|
||||
format: int64
|
||||
description: 发布检查结果 ID(消费检查快照)
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
responses:
|
||||
'200':
|
||||
@ -705,8 +698,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -716,8 +708,7 @@ paths:
|
||||
required: [commandId, expectedStatus, reason]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
@ -841,8 +832,7 @@ paths:
|
||||
required: [assetId, appealType, reason, commandId]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 申诉对象资产 ID
|
||||
appealType:
|
||||
type: string
|
||||
@ -858,8 +848,7 @@ paths:
|
||||
type: string
|
||||
description: 证据材料标识列表
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
responses:
|
||||
'200':
|
||||
@ -894,8 +883,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -917,8 +905,7 @@ paths:
|
||||
type: boolean
|
||||
description: 是否已确认隐私处理
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
responses:
|
||||
'200':
|
||||
@ -948,8 +935,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -962,8 +948,7 @@ paths:
|
||||
type: string
|
||||
description: 撤回原因
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
@ -1106,12 +1091,11 @@ paths:
|
||||
required: [commandId, actionType]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
actionType:
|
||||
type: string
|
||||
enum: [delist, recall, revoke_license, stop_generation]
|
||||
enum: [delist, recall, review_rejection, revoke_license, stop_generation]
|
||||
description: 拟执行的治理动作类型
|
||||
scope:
|
||||
type: string
|
||||
@ -1156,16 +1140,14 @@ paths:
|
||||
required: [commandId, expectedStatus, impactPreviewId, reason, scope]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
enum: [listed]
|
||||
description: 期望当前上架状态,避免盲下架
|
||||
impactPreviewId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 关联的治理影响预览 ID
|
||||
reason:
|
||||
type: string
|
||||
@ -1212,16 +1194,14 @@ paths:
|
||||
required: [commandId, expectedStatus, impactPreviewId, reason, basis, scope]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
enum: [listed, delisted]
|
||||
description: 期望当前治理状态,避免盲召回
|
||||
impactPreviewId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 关联的治理影响预览 ID
|
||||
reason:
|
||||
type: string
|
||||
@ -1292,8 +1272,7 @@ paths:
|
||||
in: query
|
||||
description: 发布者 ID
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 发布申请分页列表
|
||||
@ -1334,8 +1313,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -1345,16 +1323,14 @@ paths:
|
||||
required: [commandId, expectedStatus, validationResultId, note]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
enum: [pending, reviewing, needs_supplement]
|
||||
description: 期望当前审核状态,避免盲通过
|
||||
validationResultId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 审核校验结果引用 ID
|
||||
note:
|
||||
type: string
|
||||
@ -1395,8 +1371,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -1406,16 +1381,14 @@ paths:
|
||||
required: [commandId, expectedStatus, validationResultId, reason]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
enum: [pending, reviewing, needs_supplement]
|
||||
description: 期望当前审核状态,避免盲驳回
|
||||
validationResultId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 审核校验结果引用 ID
|
||||
reason:
|
||||
type: string
|
||||
@ -1482,14 +1455,12 @@ paths:
|
||||
in: query
|
||||
description: 申诉人 ID
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
- name: assetId
|
||||
in: query
|
||||
description: 关联资产 ID
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 申诉分页列表
|
||||
@ -1530,8 +1501,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 申诉详情
|
||||
@ -1567,8 +1537,7 @@ paths:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -1578,8 +1547,7 @@ paths:
|
||||
required: [commandId, expectedStatus, impactPreviewId, resolution, reason]
|
||||
properties:
|
||||
commandId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 幂等键
|
||||
expectedStatus:
|
||||
type: string
|
||||
@ -1599,8 +1567,7 @@ paths:
|
||||
type: string
|
||||
description: 处理证据标签
|
||||
impactPreviewId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 恢复时的关联影响预览 ID
|
||||
requireSupplement:
|
||||
type: boolean
|
||||
@ -1647,8 +1614,7 @@ components:
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 市场资产 ID
|
||||
|
||||
schemas:
|
||||
@ -1662,8 +1628,7 @@ components:
|
||||
required: [assetId, name, assetType, publisherName, version, listingStatus]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
maxLength: 200
|
||||
@ -1806,8 +1771,7 @@ components:
|
||||
required: [assetId, name, assetType, publisherName, version, listingStatus, licenseInfo]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
maxLength: 200
|
||||
@ -1817,8 +1781,7 @@ components:
|
||||
publisherName:
|
||||
type: string
|
||||
publisherId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
versionId:
|
||||
@ -2074,8 +2037,7 @@ components:
|
||||
format: int64
|
||||
description: 授权记录 ID
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [active, expired, revoked]
|
||||
@ -2100,8 +2062,7 @@ components:
|
||||
format: int64
|
||||
description: 安装记录 ID
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
versionId:
|
||||
type: integer
|
||||
format: int64
|
||||
@ -2135,8 +2096,7 @@ components:
|
||||
format: int64
|
||||
description: 来源授权快照 ID
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
sourceVersion:
|
||||
type: integer
|
||||
description: 来源资产版本
|
||||
@ -2207,8 +2167,7 @@ components:
|
||||
targetAction:
|
||||
type: string
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
targetWorkId:
|
||||
type: integer
|
||||
format: int64
|
||||
@ -2354,8 +2313,7 @@ components:
|
||||
required: [requestId, status]
|
||||
properties:
|
||||
requestId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 发布申请 ID
|
||||
status:
|
||||
type: string
|
||||
@ -2368,8 +2326,7 @@ components:
|
||||
required: [assetId, assetName, assetType, status]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
assetName:
|
||||
type: string
|
||||
assetType:
|
||||
@ -2400,8 +2357,7 @@ components:
|
||||
description: 治理影响范围
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
governanceResult:
|
||||
$ref: '#/components/schemas/GovernanceResultSummary'
|
||||
affectedAuthorizations:
|
||||
@ -2547,8 +2503,7 @@ components:
|
||||
required: [appealId, status, processingDeadline]
|
||||
properties:
|
||||
appealId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, reviewing]
|
||||
@ -2569,16 +2524,14 @@ components:
|
||||
required: [assetId, name, assetType, publisherName, listingStatus, reviewStatus]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
assetType:
|
||||
type: string
|
||||
enum: [work, agent, knowledge_base]
|
||||
publisherId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
publisherName:
|
||||
type: string
|
||||
version:
|
||||
@ -2610,16 +2563,14 @@ components:
|
||||
required: [assetId, name, assetType, listingStatus, reviewStatus]
|
||||
properties:
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
assetType:
|
||||
type: string
|
||||
enum: [work, agent, knowledge_base]
|
||||
publisherId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
publisherName:
|
||||
type: string
|
||||
version:
|
||||
@ -2665,8 +2616,7 @@ components:
|
||||
reason:
|
||||
type: string
|
||||
operatorId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
operatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@ -2683,12 +2633,11 @@ components:
|
||||
required: [previewId, actionType, scope, affectedCounts]
|
||||
properties:
|
||||
previewId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
description: 预览 ID(关联到后续下架/召回操作)
|
||||
actionType:
|
||||
type: string
|
||||
enum: [delist, recall, revoke_license, stop_generation]
|
||||
enum: [delist, recall, review_rejection, revoke_license, stop_generation]
|
||||
scope:
|
||||
type: string
|
||||
affectedCounts:
|
||||
@ -2732,19 +2681,16 @@ components:
|
||||
required: [requestId, assetId, assetName, assetType, status]
|
||||
properties:
|
||||
requestId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
assetName:
|
||||
type: string
|
||||
assetType:
|
||||
type: string
|
||||
enum: [work, agent, knowledge_base]
|
||||
publisherId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
publisherName:
|
||||
type: string
|
||||
version:
|
||||
@ -2775,8 +2721,7 @@ components:
|
||||
required: [appealId, appealType, status, assetId]
|
||||
properties:
|
||||
appealId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
appealType:
|
||||
type: string
|
||||
enum: [review_rejection, delist, recall, license_revocation, usage_impact]
|
||||
@ -2784,21 +2729,18 @@ components:
|
||||
type: string
|
||||
enum: [pending, reviewing, supplementing, maintained, restored, closed]
|
||||
appellantId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
appellantName:
|
||||
type: string
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
assetName:
|
||||
type: string
|
||||
submittedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
processorId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
|
||||
AdminAppealDetail:
|
||||
type: object
|
||||
@ -2806,8 +2748,7 @@ components:
|
||||
required: [appealId, appealType, status, assetId, reason]
|
||||
properties:
|
||||
appealId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
appealType:
|
||||
type: string
|
||||
enum: [review_rejection, delist, recall, license_revocation, usage_impact]
|
||||
@ -2815,13 +2756,11 @@ components:
|
||||
type: string
|
||||
enum: [pending, reviewing, supplementing, maintained, restored, closed]
|
||||
appellantId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
appellantName:
|
||||
type: string
|
||||
assetId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
assetName:
|
||||
type: string
|
||||
assetVersion:
|
||||
@ -2856,8 +2795,7 @@ components:
|
||||
action:
|
||||
type: string
|
||||
processorId:
|
||||
type: integer
|
||||
format: int64
|
||||
type: string
|
||||
processedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
@ -964,6 +964,9 @@ components:
|
||||
ValidationResult:
|
||||
type: object
|
||||
properties:
|
||||
validationResultId:
|
||||
type: string
|
||||
description: 校验结果引用 ID;发布、激活等治理命令必须引用同一草稿或目标版本的校验结果。
|
||||
valid:
|
||||
type: boolean
|
||||
description: 是否校验通过
|
||||
@ -1499,6 +1502,9 @@ components:
|
||||
properties:
|
||||
impactPreviewId:
|
||||
type: string
|
||||
validationResultId:
|
||||
type: string
|
||||
description: 功能链路激活前校验结果引用 ID;必须与 impactPreviewId、chainKey、targetVersion 同源。
|
||||
chainKey:
|
||||
type: string
|
||||
targetVersion:
|
||||
|
||||
@ -63,9 +63,9 @@
|
||||
|
||||
## 关键工程实践与避坑
|
||||
|
||||
### 1. `muse-admin/` 被根仓库 `.gitignore` 忽略
|
||||
### 1. `muse-admin/` 已整仓纳管,但仍要排除构建产物
|
||||
|
||||
根 `.gitignore` 中标注 `muse-admin/` 是独立子仓库,但当前 `muse-admin` 目录没有独立 `.git`,`git -C muse-admin rev-parse --show-toplevel` 返回根仓库。
|
||||
当前 `muse-admin` 目录已经作为本仓工作区的一部分被统一纳管,不再按“独立子仓库”理解;但它仍然需要继续排除 `dist/`、`dist.zip`、`node_modules/`、`test-results/` 这类构建和验证产物,避免误入提交。
|
||||
|
||||
因此本次按 P3 计划要求,用 `git add -f` 精确加入 P3 相关源码和测试文件:
|
||||
|
||||
@ -194,6 +194,30 @@ pnpm --dir muse-admin --filter @vben/web-antd run build
|
||||
- 全量 `test:unit`:48 个测试文件、336 个测试通过;仍有既有 `use-sortable.test.ts` nested `vi.mock` 警告。
|
||||
- `web-antd` build 退出码 0,并已清理生成的 `test-results/`、`dist/` 和 `dist.zip`,不纳入提交。
|
||||
|
||||
子代理评审修复后最终复核:
|
||||
|
||||
```bash
|
||||
pnpm --dir muse-admin --filter @vben/web-antd run typecheck
|
||||
pnpm --dir muse-admin test:unit
|
||||
pnpm --dir muse-admin --filter @vben/web-antd run build
|
||||
pnpm --dir muse-admin exec vitest run --dom apps/web-antd/src/api/muse/__tests__/client.spec.ts apps/web-antd/src/api/muse/account/__tests__/index.spec.ts apps/web-antd/src/api/muse/ai/__tests__/jobs.spec.ts apps/web-antd/src/api/muse/audit/__tests__/index.spec.ts apps/web-antd/src/api/muse/governance/__tests__/governance-api.test.ts apps/web-antd/src/api/muse/jobs/__tests__/index.spec.ts apps/web-antd/src/api/muse/knowledge/__tests__/index.test.ts apps/web-antd/src/api/muse/market/__tests__/index.test.ts apps/web-antd/src/api/muse/newapi/__tests__/index.spec.ts apps/web-antd/src/router/routes/__tests__/muse.test.ts apps/web-antd/src/views/muse/__tests__/governance-pages.test.ts apps/web-antd/src/views/muse/ai/__tests__/index.test.ts apps/web-antd/src/views/muse/governance/meta-schema/__tests__/detail.test.ts apps/web-antd/src/views/muse/knowledge/__tests__/index.test.ts apps/web-antd/src/views/muse/market/__tests__/index.test.ts
|
||||
pnpm --dir muse-admin exec playwright test -c playwright.config.ts apps/web-antd/e2e/muse-admin-governance.spec.ts
|
||||
pnpm --dir muse-admin exec oxlint apps/web-antd/e2e/muse-admin-governance.spec.ts apps/web-antd/src/api/muse apps/web-antd/src/views/muse apps/web-antd/src/router/routes/__tests__/muse.test.ts apps/web-antd/src/router/routes/modules/muse.ts
|
||||
git diff --check -- muse-admin docs/api-contracts docs/memorys .gitignore
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- 子代理 review 发现的 Critical 已修复:MetaSchema save/validate/preview 旧响应不能回写新 schema 或新 draft;FunctionChain 预览票据绑定 `chainKey + targetVersion`;Market 预览票据绑定 asset/appeal/action/scope/generation。
|
||||
- Market 不再把 `stop_new_acquire` 影响预览票据复用到 `full_delist` 提交;`review_rejection` 已同步到 OpenAPI 和前端 action mapping。
|
||||
- E2E 增强为 9 个 Chromium 用例通过,覆盖 retry 只发 `commandId`、cancel/source retry 保留 `reason`、跨资产 preview 不可复用、同资产切换治理范围/动作/申诉后必须重新生成 preview、错误动作重新预览不能提交召回、核心页面真实路由和运行时 console/pageerror 门禁。
|
||||
- P3 定向 Vitest:7 个测试文件、14 个测试通过;market API 已覆盖 `assetId/requestId/appealId` 路径参数编码。
|
||||
- 全量 `test:unit`:50 个测试文件、345 个测试通过;仍有既有 `use-sortable.test.ts` nested `vi.mock` 警告。
|
||||
- `web-antd` typecheck 退出码 0。
|
||||
- `web-antd` build 退出码 0,并已清理生成的 `test-results/`、`apps/web-antd/dist/` 和 `apps/web-antd/dist.zip`,不纳入提交。
|
||||
- P3 定向 oxlint:37 个文件,0 warning、0 error。
|
||||
- `git diff --check` 通过。
|
||||
|
||||
## 提交与 Hook 说明
|
||||
|
||||
本次 P3 相关提交:
|
||||
@ -213,6 +237,7 @@ pnpm --dir muse-admin --filter @vben/web-antd run build
|
||||
- 修复 E2E 暴露的 Ant Design Vue 组件运行时解析问题,Muse 页面改为显式导入实际使用的 Ant 组件。
|
||||
- 审查修复后,E2E 对未处理的 Muse 管理端 API 不再返回空分页兜底,避免错误 URL 或漏 mock 仍然绿;同时修正 New-API 真实运行时双 `/admin-api` 前缀问题。
|
||||
- 再次审查后,删除 Account/Jobs/Audit 的重复 route 注册,避免 specific route 抢先匹配导致 unified mock 失效;同步修正 E2E mock 字段契约漂移。
|
||||
- 子代理评审后继续修复治理命令串票风险:MetaSchema 草稿票据加 save/validate/preview 请求序号;FunctionChain 和 Market 影响预览返回时比对发起时上下文;Market OpenAPI 关键治理 ID/引用统一为 string;E2E 开启 console/pageerror/未 mock API 失败门禁。
|
||||
|
||||
提交前发现暂存区已有其他会话 staged 的 `P1-execution-prompt.md` 和 `P3-execution-prompt.md`,已仅执行 `git restore --staged`,没有改动文件内容。
|
||||
|
||||
|
||||
@ -18,4 +18,4 @@ VITE_INJECT_APP_LOADING=true
|
||||
# 默认登录用户名
|
||||
VITE_APP_DEFAULT_USERNAME=admin
|
||||
# 默认登录密码
|
||||
VITE_APP_DEFAULT_PASSWORD=admin123
|
||||
VITE_APP_DEFAULT_PASSWORD=
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
VITE_BASE=/
|
||||
|
||||
# 请求路径
|
||||
VITE_BASE_URL=http://127.0.0.1:48080
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=http://127.0.0.1:48080/admin-api
|
||||
# 请求路径,生产环境由部署站点决定,保持空值避免拼接出 //admin-api
|
||||
VITE_BASE_URL=
|
||||
# 接口地址,沿用相对路径占位,由反向代理或同源部署接管
|
||||
VITE_GLOB_API_URL=/admin-api
|
||||
# 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
|
||||
VITE_UPLOAD_TYPE=server
|
||||
|
||||
@ -23,4 +23,4 @@ VITE_INJECT_APP_LOADING=true
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# 验证码的开关
|
||||
VITE_APP_CAPTCHA_ENABLE=true
|
||||
VITE_APP_CAPTCHA_ENABLE=true
|
||||
|
||||
@ -15,6 +15,13 @@ const pageResult = (list: unknown[]) => ({
|
||||
});
|
||||
|
||||
const unhandledMuseAdminApiPathsByPage = new WeakMap<Page, Set<string>>();
|
||||
const museAdminPayloadsByPage = new WeakMap<Page, Record<string, unknown>[]>();
|
||||
const runtimeIssuesByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
const isIgnoredRuntimeIssue = (message: string) =>
|
||||
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/i.test(
|
||||
message,
|
||||
);
|
||||
|
||||
const expectNoUnhandledMuseAdminApis = (page: Page) => {
|
||||
const unhandledPaths = [
|
||||
@ -26,6 +33,23 @@ const expectNoUnhandledMuseAdminApis = (page: Page) => {
|
||||
).toEqual([]);
|
||||
};
|
||||
|
||||
const expectNoRuntimeIssues = (page: Page) => {
|
||||
const runtimeIssues = (runtimeIssuesByPage.get(page) ?? []).filter(
|
||||
(message) => !isIgnoredRuntimeIssue(message),
|
||||
);
|
||||
|
||||
expect(
|
||||
runtimeIssues,
|
||||
`Browser runtime issues:\n${runtimeIssues.join('\n')}`,
|
||||
).toEqual([]);
|
||||
};
|
||||
|
||||
const recordRuntimeIssue = (page: Page, message: string) => {
|
||||
if (!isIgnoredRuntimeIssue(message)) {
|
||||
runtimeIssuesByPage.get(page)?.push(message);
|
||||
}
|
||||
};
|
||||
|
||||
const routeCommonResult = async (
|
||||
page: Page,
|
||||
patterns: string[],
|
||||
@ -44,10 +68,20 @@ const routeCommonResult = async (
|
||||
const routeMuseAdminApis = async (page: Page) => {
|
||||
const unhandledMuseAdminApiPaths = new Set<string>();
|
||||
unhandledMuseAdminApiPathsByPage.set(page, unhandledMuseAdminApiPaths);
|
||||
museAdminPayloadsByPage.set(page, []);
|
||||
|
||||
await page.route('**/admin-api/muse/**', (route) => {
|
||||
await page.route('**/admin-api/muse/**', async (route) => {
|
||||
const { pathname } = new URL(route.request().url());
|
||||
const path = pathname.replace('/admin-api/muse', '');
|
||||
const method = route.request().method();
|
||||
|
||||
if (method !== 'GET') {
|
||||
museAdminPayloadsByPage.get(page)?.push({
|
||||
method,
|
||||
path,
|
||||
payload: route.request().postDataJSON(),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/governance/meta-schemas') {
|
||||
return route.fulfill({
|
||||
@ -71,6 +105,28 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/governance/meta-schemas/story.draft') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult({
|
||||
activeVersion: 1,
|
||||
description: 'E2E 元结构详情',
|
||||
displayName: '故事草稿结构',
|
||||
fields: [
|
||||
{
|
||||
fieldKey: 'title',
|
||||
fieldType: 'string',
|
||||
label: '标题',
|
||||
required: true,
|
||||
scope: 'work',
|
||||
},
|
||||
],
|
||||
schemaKey: 'story.draft',
|
||||
status: 'active',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/governance/function-chains') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
@ -282,8 +338,22 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
pageResult([
|
||||
{
|
||||
assetId: 'asset-e2e',
|
||||
assetType: 'agent',
|
||||
bindCount: 1,
|
||||
installCount: 1,
|
||||
name: '市场资产',
|
||||
listingStatus: 'listed',
|
||||
publisherName: '发布者',
|
||||
reviewStatus: 'approved',
|
||||
},
|
||||
{
|
||||
assetId: 'asset-two',
|
||||
assetType: 'agent',
|
||||
bindCount: 0,
|
||||
installCount: 0,
|
||||
name: '另一市场资产',
|
||||
listingStatus: 'listed',
|
||||
publisherName: '发布者',
|
||||
reviewStatus: 'approved',
|
||||
},
|
||||
]),
|
||||
@ -291,6 +361,38 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'POST' &&
|
||||
path.startsWith('/market/assets/') &&
|
||||
path.endsWith('/governance-impact')
|
||||
) {
|
||||
const [, , , assetId] = path.split('/');
|
||||
const payload = route.request().postDataJSON() as {
|
||||
actionType?: string;
|
||||
scope?: string;
|
||||
};
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult({
|
||||
actionType: payload.actionType,
|
||||
affectedCounts: {},
|
||||
previewId: `preview-${assetId}-${payload.actionType}-${payload.scope}`,
|
||||
scope: payload.scope,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'POST' &&
|
||||
path.startsWith('/market/assets/') &&
|
||||
path.endsWith('/delist')
|
||||
) {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult(null),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/market/appeals') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
@ -306,6 +408,16 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
reason: '申诉材料',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
appealId: 'appeal-two',
|
||||
assetId: 'asset-two',
|
||||
appealType: 'delist',
|
||||
appellantName: '另一申诉人',
|
||||
assetName: '另一市场资产',
|
||||
processorId: 'admin-e2e',
|
||||
reason: '另一申诉材料',
|
||||
status: 'pending',
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
@ -401,6 +513,17 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
{
|
||||
createdAt: '2026-05-25T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
actionPolicy: 'allowed',
|
||||
jobId: 1002,
|
||||
owner: 'market',
|
||||
retryCount: 0,
|
||||
status: 'queued',
|
||||
type: 'asset-recall',
|
||||
},
|
||||
{
|
||||
createdAt: '2026-05-25T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
actionPolicy: 'allowed',
|
||||
jobId: 1001,
|
||||
owner: 'market',
|
||||
retryCount: 1,
|
||||
@ -431,6 +554,43 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/jobs/1001') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult({
|
||||
createdAt: '2026-05-25T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
errorMessage: '来源状态需重验',
|
||||
jobId: 1001,
|
||||
owner: 'market',
|
||||
retryable: true,
|
||||
status: 'failed',
|
||||
type: 'source-projection',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/jobs/1001/retry') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult({ jobId: 1001, status: 'queued' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/jobs/1002/cancel') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult({ jobId: 1002, status: 'cancelled' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === '/source-events/2001/retry') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
json: commonResult({ jobId: 1003, status: 'queued' }),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/audit/api-logs') {
|
||||
return route.fulfill({
|
||||
contentType: 'application/json',
|
||||
@ -486,6 +646,25 @@ const routeMuseAdminApis = async (page: Page) => {
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
runtimeIssuesByPage.set(page, []);
|
||||
|
||||
page.on('console', (message) => {
|
||||
const text = message.text();
|
||||
if (message.type() === 'error') {
|
||||
recordRuntimeIssue(page, `console.error: ${text}`);
|
||||
}
|
||||
if (
|
||||
message.type() === 'warning' &&
|
||||
/Failed to resolve component/i.test(text)
|
||||
) {
|
||||
recordRuntimeIssue(page, `console.warning: ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
recordRuntimeIssue(page, `pageerror: ${error.message}`);
|
||||
});
|
||||
|
||||
await page.addInitScript((accessStoreKey) => {
|
||||
localStorage.setItem(
|
||||
accessStoreKey,
|
||||
@ -521,6 +700,16 @@ test.beforeEach(async ({ page }) => {
|
||||
sort: 10,
|
||||
visible: true,
|
||||
},
|
||||
{
|
||||
component: 'muse/governance/meta-schema/detail',
|
||||
componentName: 'MuseMetaSchemaDetail',
|
||||
id: 111,
|
||||
name: '元结构编辑',
|
||||
parentId: 100,
|
||||
path: 'governance/meta-schemas/:schemaKey',
|
||||
sort: 11,
|
||||
visible: false,
|
||||
},
|
||||
{
|
||||
component: 'muse/governance/function-orch/index',
|
||||
componentName: 'MuseFunctionOrchestration',
|
||||
@ -657,6 +846,11 @@ test.beforeEach(async ({ page }) => {
|
||||
await routeMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
expectNoRuntimeIssues(page);
|
||||
});
|
||||
|
||||
test('管理员可进入账号、任务和审计治理页面并看到边界文案', async ({
|
||||
page,
|
||||
}) => {
|
||||
@ -689,10 +883,292 @@ test('管理员可进入账号、任务和审计治理页面并看到边界文
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('任务重试只发送当前 OpenAPI 支持的 commandId', async ({ page }) => {
|
||||
await page.goto('/muse/jobs');
|
||||
await page
|
||||
.getByRole('row', { name: /1001/ })
|
||||
.getByRole('button', { name: '重试' })
|
||||
.click();
|
||||
await page.getByLabel('owner 和当前操作者权限已重验').check();
|
||||
await page.getByLabel('来源状态、市场状态和版本已重验').check();
|
||||
await page.getByLabel('授权快照和运行权限包已重验').check();
|
||||
await page
|
||||
.getByLabel('幂等键、retry group 和旧上下文复用风险已重验')
|
||||
.check();
|
||||
await page
|
||||
.locator('.ant-modal textarea')
|
||||
.fill('人工复核留痕,不发送到 retry 接口');
|
||||
await page.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const retryPayload = museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) => item.method === 'POST' && item.path === '/jobs/1001/retry',
|
||||
)?.payload;
|
||||
|
||||
expect(retryPayload).toEqual({
|
||||
commandId: expect.stringMatching(/^job-retry-/),
|
||||
});
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('任务取消和来源传播重试提交 commandId 与 reason', async ({ page }) => {
|
||||
await page.goto('/muse/jobs');
|
||||
await page
|
||||
.getByRole('row', { name: /1002/ })
|
||||
.getByRole('button', { name: '取消' })
|
||||
.click();
|
||||
const jobActionDialog = page.getByRole('dialog', { name: '任务治理操作' });
|
||||
await jobActionDialog.locator('textarea').fill('上游召回,停止排队任务');
|
||||
await jobActionDialog.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
await page.getByRole('tab', { name: '来源事件' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: /2001/ })
|
||||
.getByRole('button', { name: '重试传播' })
|
||||
.click();
|
||||
const sourceRetryDialog = page.getByRole('dialog', { name: '重试来源传播' });
|
||||
await sourceRetryDialog.locator('textarea').fill('来源状态已复核,重新传播');
|
||||
await sourceRetryDialog.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const payloads = museAdminPayloadsByPage.get(page) ?? [];
|
||||
const cancelPayload = payloads.find(
|
||||
(item) => item.method === 'POST' && item.path === '/jobs/1002/cancel',
|
||||
)?.payload;
|
||||
const sourceRetryPayload = payloads.find(
|
||||
(item) =>
|
||||
item.method === 'POST' && item.path === '/source-events/2001/retry',
|
||||
)?.payload;
|
||||
|
||||
expect(cancelPayload).toEqual({
|
||||
commandId: expect.stringMatching(/^job-cancel-/),
|
||||
reason: '上游召回,停止排队任务',
|
||||
});
|
||||
expect(sourceRetryPayload).toEqual({
|
||||
commandId: expect.stringMatching(/^source-retry-/),
|
||||
reason: '来源状态已复核,重新传播',
|
||||
});
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('市场下架不能复用其他资产的影响预览票据', async ({ page }) => {
|
||||
await page.goto('/muse/market');
|
||||
await page.getByRole('tab', { name: '资产治理' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: /^市场资产/ })
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
await page.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) =>
|
||||
item.method === 'POST' &&
|
||||
item.path === '/market/assets/asset-e2e/governance-impact',
|
||||
)?.payload,
|
||||
)
|
||||
.toEqual(
|
||||
expect.objectContaining({
|
||||
actionType: 'delist',
|
||||
commandId: expect.stringMatching(/^market-impact-preview-/),
|
||||
scope: 'stop_new_acquire',
|
||||
}),
|
||||
);
|
||||
await page.locator('.ant-modal-footer .ant-btn-default').click();
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: /另一市场资产/ })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
await expect(page.getByPlaceholder('先执行影响预览后自动带入')).toHaveValue('');
|
||||
await page
|
||||
.locator('.ant-modal textarea')
|
||||
.fill('尝试复用上一资产影响预览');
|
||||
await page.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const delistPayload = museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) =>
|
||||
item.method === 'POST' && item.path === '/market/assets/asset-two/delist',
|
||||
)?.payload;
|
||||
|
||||
expect(delistPayload).toBeUndefined();
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('市场下架切换范围后必须重新生成影响预览', async ({ page }) => {
|
||||
await page.goto('/muse/market');
|
||||
await page.getByRole('tab', { name: '资产治理' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: /^市场资产/ })
|
||||
.getByRole('button')
|
||||
.nth(1)
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
|
||||
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
|
||||
|
||||
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
|
||||
await expect(previewInput).toHaveValue(
|
||||
'preview-asset-e2e-delist-stop_new_acquire',
|
||||
);
|
||||
|
||||
await dialog.locator('.ant-select-selector').click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText('停止新绑定')
|
||||
.click();
|
||||
|
||||
await expect(previewInput).toHaveValue('');
|
||||
await dialog.locator('textarea').fill('切换范围后不能复用旧影响预览');
|
||||
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const delistPayload = museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) =>
|
||||
item.method === 'POST' && item.path === '/market/assets/asset-e2e/delist',
|
||||
)?.payload;
|
||||
|
||||
expect(delistPayload).toBeUndefined();
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('市场召回切换动作后必须重新生成影响预览', async ({ page }) => {
|
||||
await page.goto('/muse/market');
|
||||
await page.getByRole('tab', { name: '资产治理' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: /^市场资产/ })
|
||||
.getByRole('button')
|
||||
.nth(2)
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
|
||||
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
|
||||
|
||||
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
|
||||
await expect(previewInput).toHaveValue(
|
||||
'preview-asset-e2e-recall-full_recall',
|
||||
);
|
||||
|
||||
await dialog.locator('.ant-select-selector').first().click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText('下架', { exact: true })
|
||||
.click();
|
||||
|
||||
await expect(previewInput).toHaveValue('');
|
||||
await dialog.getByPlaceholder('法律、合规或安全依据').fill('合规召回依据');
|
||||
await dialog.locator('textarea').fill('切换动作后不能复用旧影响预览');
|
||||
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const recallPayload = museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) =>
|
||||
item.method === 'POST' && item.path === '/market/assets/asset-e2e/recall',
|
||||
)?.payload;
|
||||
|
||||
expect(recallPayload).toBeUndefined();
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('市场召回切到下架并重新生成预览后不能提交召回', async ({ page }) => {
|
||||
await page.goto('/muse/market');
|
||||
await page.getByRole('tab', { name: '资产治理' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: /^市场资产/ })
|
||||
.getByRole('button')
|
||||
.nth(2)
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
|
||||
const previewInput = dialog.getByPlaceholder('先执行影响预览后自动带入');
|
||||
|
||||
await dialog.locator('.ant-select-selector').first().click();
|
||||
await page
|
||||
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
|
||||
.getByText('下架', { exact: true })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
|
||||
await expect(previewInput).toHaveValue(
|
||||
'preview-asset-e2e-delist-full_recall',
|
||||
);
|
||||
|
||||
await dialog.getByPlaceholder('法律、合规或安全依据').fill('合规召回依据');
|
||||
await dialog.locator('textarea').fill('尝试用下架预览提交召回');
|
||||
await dialog.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const recallPayload = museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) =>
|
||||
item.method === 'POST' && item.path === '/market/assets/asset-e2e/recall',
|
||||
)?.payload;
|
||||
|
||||
expect(recallPayload).toBeUndefined();
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('市场申诉切换 appealId 后必须重新生成影响预览', async ({ page }) => {
|
||||
await page.goto('/muse/market');
|
||||
await page.getByRole('tab', { name: '申诉处理' }).click();
|
||||
await page
|
||||
.getByRole('row', { name: /^市场资产\s+delist\s+申诉人/ })
|
||||
.getByRole('button', { name: /处\s*理/ })
|
||||
.click();
|
||||
|
||||
const firstDialog = page.getByRole('dialog', { name: '市场治理:市场资产' });
|
||||
const firstPreviewInput =
|
||||
firstDialog.getByPlaceholder('先执行影响预览后自动带入');
|
||||
|
||||
await firstDialog.getByRole('button', { name: '生成当前对象影响预览' }).click();
|
||||
await expect(firstPreviewInput).toHaveValue(
|
||||
'preview-asset-e2e-delist-stop_new_acquire',
|
||||
);
|
||||
await firstDialog.locator('.ant-modal-footer .ant-btn-default').click();
|
||||
|
||||
await page
|
||||
.getByRole('row', { name: /^另一市场资产\s+delist\s+另一申诉人/ })
|
||||
.getByRole('button', { name: /处\s*理/ })
|
||||
.click();
|
||||
const secondDialog = page.getByRole('dialog', {
|
||||
name: '市场治理:另一市场资产',
|
||||
});
|
||||
await expect(
|
||||
secondDialog.getByPlaceholder('先执行影响预览后自动带入'),
|
||||
).toHaveValue('');
|
||||
await secondDialog.locator('textarea').fill('切换申诉后不能复用旧影响预览');
|
||||
await secondDialog.locator('.ant-modal-footer .ant-btn-primary').click();
|
||||
|
||||
const resolvePayload = museAdminPayloadsByPage
|
||||
.get(page)
|
||||
?.find(
|
||||
(item) =>
|
||||
item.method === 'POST' &&
|
||||
item.path === '/market/appeals/appeal-two/resolve',
|
||||
)?.payload;
|
||||
|
||||
expect(resolvePayload).toBeUndefined();
|
||||
expectNoUnhandledMuseAdminApis(page);
|
||||
});
|
||||
|
||||
test('管理员可进入核心治理配置页面并看到系统边界', async ({ page }) => {
|
||||
await page.goto('/muse/governance/meta-schemas');
|
||||
await expect(page.getByText('元结构定义').first()).toBeVisible();
|
||||
await expect(page.getByText('story.draft')).toBeVisible();
|
||||
await page.getByRole('button', { name: /查看详情/ }).click();
|
||||
await expect(page).toHaveURL(/\/muse\/governance\/meta-schemas\/story\.draft$/);
|
||||
await expect(page.getByText('草稿 JSON 编辑')).toBeVisible();
|
||||
await page.goto('/muse/governance/meta-schemas');
|
||||
await page.getByRole('button', { name: /新建草稿/ }).click();
|
||||
await expect(page).toHaveURL(/\/muse\/governance\/meta-schemas\/__create__\?mode=create$/);
|
||||
await expect(page.getByPlaceholder('例如 work.story')).toBeVisible();
|
||||
|
||||
await page.goto('/muse/governance/function-orchestration');
|
||||
await expect(
|
||||
|
||||
@ -39,6 +39,10 @@ describe('muse admin api client', () => {
|
||||
await museAdminApi.post('/jobs/1/retry', body);
|
||||
await museAdminApi.patch('/knowledge/global-kbs/kb-1', body);
|
||||
await museAdminApi.delete('/knowledge/global-kbs/kb-1/documents/doc-1');
|
||||
await museAdminApi.deleteBody(
|
||||
'/knowledge/global-kbs/kb-1/documents/doc-1',
|
||||
body,
|
||||
);
|
||||
|
||||
expect(requestClientMock.post).toHaveBeenCalledWith(
|
||||
'/muse/jobs/1/retry',
|
||||
@ -60,5 +64,13 @@ describe('muse admin api client', () => {
|
||||
params: undefined,
|
||||
},
|
||||
);
|
||||
expect(requestClientMock.request).toHaveBeenCalledWith(
|
||||
'/muse/knowledge/global-kbs/kb-1/documents/doc-1',
|
||||
{
|
||||
data: body,
|
||||
headers: { 'X-API-Version': '1' },
|
||||
method: 'DELETE',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
cancelJobApi,
|
||||
retryJobApi,
|
||||
retrySourceEventApi,
|
||||
} from '../../jobs';
|
||||
|
||||
const { museAdminApiMock } = vi.hoisted(() => ({
|
||||
museAdminApiMock: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('#/api/muse/client', () => ({
|
||||
museAdminApi: museAdminApiMock,
|
||||
}));
|
||||
|
||||
describe('muse ai jobs api command contract', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('重试任务只提交 commandId,原因归因由重试前检查链路负责', async () => {
|
||||
await retryJobApi('job/1', {
|
||||
commandId: 'cmd-retry',
|
||||
reason: '不应发送到任务重试接口',
|
||||
});
|
||||
|
||||
expect(museAdminApiMock.post).toHaveBeenCalledWith('/jobs/job%2F1/retry', {
|
||||
commandId: 'cmd-retry',
|
||||
});
|
||||
expect(museAdminApiMock.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('取消任务和来源事件重试必须保留 reason 审计合同', async () => {
|
||||
await cancelJobApi('job/1', {
|
||||
commandId: 'cmd-cancel',
|
||||
reason: '管理员取消长时间运行任务',
|
||||
});
|
||||
await retrySourceEventApi('event/1', {
|
||||
commandId: 'cmd-source-retry',
|
||||
reason: '来源状态传播补偿',
|
||||
});
|
||||
|
||||
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/jobs/job%2F1/cancel',
|
||||
{
|
||||
commandId: 'cmd-cancel',
|
||||
reason: '管理员取消长时间运行任务',
|
||||
},
|
||||
);
|
||||
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/source-events/event%2F1/retry',
|
||||
{
|
||||
commandId: 'cmd-source-retry',
|
||||
reason: '来源状态传播补偿',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -60,6 +60,14 @@ export const museAdminApi = {
|
||||
headers: MUSE_API_HEADERS,
|
||||
params,
|
||||
}),
|
||||
|
||||
/** DELETE JSON body 接口,仅用于 OpenAPI 明确要求 requestBody 的治理命令。 */
|
||||
deleteBody: <T>(url: string, data?: MuseRequestBody) =>
|
||||
requestClient.request<T>(toMuseUrl(url), {
|
||||
data,
|
||||
headers: MUSE_API_HEADERS,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
export type { MuseQueryParams, MuseRequestBody };
|
||||
|
||||
@ -173,6 +173,8 @@ export interface SlotChangeDetail {
|
||||
export interface FunctionChainImpactPreview {
|
||||
/** 影响预览记录 ID。 */
|
||||
impactPreviewId?: string;
|
||||
/** 激活前校验结果引用 ID,必须与当前影响预览同源。 */
|
||||
validationResultId?: string;
|
||||
/** 功能链路业务标识。 */
|
||||
chainKey?: string;
|
||||
/** 目标版本号。 */
|
||||
|
||||
@ -369,6 +369,8 @@ export interface CompatibilityResult {
|
||||
|
||||
/** 校验结果。 */
|
||||
export interface ValidationResult {
|
||||
/** 校验结果引用 ID,发布和激活命令必须引用同一草稿的校验结果。 */
|
||||
validationResultId?: string;
|
||||
/** 是否校验通过。 */
|
||||
valid?: boolean;
|
||||
/** 校验错误列表。 */
|
||||
|
||||
@ -38,19 +38,19 @@ describe('muse jobs api', () => {
|
||||
expect(museAdminApiMock.get).toHaveBeenNthCalledWith(2, '/jobs/job%2F1');
|
||||
});
|
||||
|
||||
it('提交任务重试和取消命令', async () => {
|
||||
it('提交任务重试和取消命令,并按合同只让 retry 外发 commandId', async () => {
|
||||
await retryJobApi('42', { commandId: 'cmd-retry', reason: '重验通过' });
|
||||
await cancelJobApi('42', { commandId: 'cmd-cancel', reason: '来源召回' });
|
||||
|
||||
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/jobs/42/retry',
|
||||
expect.objectContaining({ commandId: 'cmd-retry' }),
|
||||
{ commandId: 'cmd-retry' },
|
||||
);
|
||||
expect(museAdminApiMock.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/jobs/42/cancel',
|
||||
expect.objectContaining({ commandId: 'cmd-cancel' }),
|
||||
{ commandId: 'cmd-cancel', reason: '来源召回' },
|
||||
);
|
||||
});
|
||||
|
||||
@ -77,7 +77,7 @@ describe('muse jobs api', () => {
|
||||
);
|
||||
expect(museAdminApiMock.post).toHaveBeenCalledWith(
|
||||
'/source-events/event%2F1/retry',
|
||||
expect.objectContaining({ commandId: 'cmd-event' }),
|
||||
{ commandId: 'cmd-event', reason: '补偿传播' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -184,7 +184,7 @@ export function getJobApi(jobId: MuseJobId) {
|
||||
export function retryJobApi(jobId: MuseJobId, data?: JobCommandRequest) {
|
||||
return museAdminApi.post<JobCommandResult>(
|
||||
`/jobs/${encodePath(jobId)}/retry`,
|
||||
data,
|
||||
data ? { commandId: data.commandId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,25 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
activateGlobalKBVersionApi,
|
||||
createGlobalKBDocumentVersionApi,
|
||||
deleteGlobalKBDocumentApi,
|
||||
disableGlobalKnowledgeBaseApi,
|
||||
getGlobalKBDocumentApi,
|
||||
getGlobalKnowledgeBaseApi,
|
||||
getGlobalKnowledgeBaseListApi,
|
||||
getGlobalKBVersionsApi,
|
||||
getGlobalKBSourceBindingsApi,
|
||||
previewGlobalKBImpactApi,
|
||||
publishGlobalKBAccessPolicyApi,
|
||||
reindexGlobalKnowledgeBaseApi,
|
||||
retryGlobalKBSourceEventApi,
|
||||
saveGlobalKBAccessPolicyDraftApi,
|
||||
uploadGlobalKBDocumentApi,
|
||||
} from '../index';
|
||||
|
||||
const clientMocks = vi.hoisted(() => ({
|
||||
delete: vi.fn(),
|
||||
deleteBody: vi.fn(),
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
request: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/muse/client', () => ({
|
||||
museAdminApi: {
|
||||
delete: clientMocks.delete,
|
||||
deleteBody: clientMocks.deleteBody,
|
||||
get: clientMocks.get,
|
||||
post: clientMocks.post,
|
||||
request: clientMocks.request,
|
||||
},
|
||||
}));
|
||||
|
||||
@ -84,4 +97,70 @@ describe('muse knowledge admin api', () => {
|
||||
sourceType: 'global_kb',
|
||||
});
|
||||
});
|
||||
|
||||
it('对知识库和资料路径 ID 做编码,避免斜杠破坏 OpenAPI 路径段', async () => {
|
||||
await getGlobalKnowledgeBaseApi('kb/a b');
|
||||
await getGlobalKBDocumentApi('kb/a b', 'doc/1');
|
||||
await getGlobalKBVersionsApi('kb/a b');
|
||||
await activateGlobalKBVersionApi('kb/a b', 3, {
|
||||
commandId: 'cmd-activate',
|
||||
reason: '版本确认',
|
||||
});
|
||||
await publishGlobalKBAccessPolicyApi('kb/a b', 'draft/1', {
|
||||
commandId: 'cmd-policy-publish',
|
||||
reason: '权限策略发布',
|
||||
});
|
||||
|
||||
expect(clientMocks.get).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2Fa%20b',
|
||||
);
|
||||
expect(clientMocks.get).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2Fa%20b/documents/doc%2F1',
|
||||
);
|
||||
expect(clientMocks.get).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2Fa%20b/versions',
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2Fa%20b/versions/3/activate',
|
||||
expect.objectContaining({ commandId: 'cmd-activate' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2Fa%20b/access-policies/drafts/draft%2F1/publish',
|
||||
expect.objectContaining({ commandId: 'cmd-policy-publish' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('按 OpenAPI 使用 DELETE JSON body 停用全局知识库资料', async () => {
|
||||
await deleteGlobalKBDocumentApi('kb/1', 'doc/1', {
|
||||
commandId: 'cmd-delete',
|
||||
reason: '资料违规',
|
||||
});
|
||||
|
||||
expect(clientMocks.deleteBody).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2F1/documents/doc%2F1',
|
||||
{ commandId: 'cmd-delete', reason: '资料违规' },
|
||||
);
|
||||
expect(clientMocks.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('保留上传和资料版本创建的请求体,不把 multipart 命令错误转成查询参数', async () => {
|
||||
const uploadForm = new FormData();
|
||||
uploadForm.set('commandId', 'cmd-upload');
|
||||
uploadForm.set('sourceDescription', '初始化资料');
|
||||
|
||||
const versionForm = new FormData();
|
||||
versionForm.set('commandId', 'cmd-version');
|
||||
|
||||
await uploadGlobalKBDocumentApi('kb/1', uploadForm);
|
||||
await createGlobalKBDocumentVersionApi('kb/1', 'doc/1', versionForm);
|
||||
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2F1/documents',
|
||||
uploadForm,
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/knowledge/global-kbs/kb%2F1/documents/doc%2F1/versions',
|
||||
versionForm,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,24 @@ function toMuseQueryParams(params: object): MuseQueryParams {
|
||||
return params as MuseQueryParams;
|
||||
}
|
||||
|
||||
/** 编码路径参数,避免 kbId/documentId/draftId 中的斜杠破坏路由段。 */
|
||||
function encodePathParam(value: string | number) {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
/** 全局知识库基础路径。 */
|
||||
function globalKnowledgeBasePath(kbId: string | number) {
|
||||
return `/knowledge/global-kbs/${encodePathParam(kbId)}`;
|
||||
}
|
||||
|
||||
/** 全局知识库资料路径。 */
|
||||
function globalKBDocumentPath(
|
||||
kbId: string | number,
|
||||
documentId: string | number,
|
||||
) {
|
||||
return `${globalKnowledgeBasePath(kbId)}/documents/${encodePathParam(documentId)}`;
|
||||
}
|
||||
|
||||
/** 全局知识库启停和处理状态。 */
|
||||
export type GlobalKnowledgeBaseStatus =
|
||||
| 'active'
|
||||
@ -673,7 +691,7 @@ export function createGlobalKnowledgeBaseApi(
|
||||
/** 查询全局知识库详情。 */
|
||||
export function getGlobalKnowledgeBaseApi(kbId: string) {
|
||||
return museAdminApi.get<GlobalKnowledgeBaseDetailVO>(
|
||||
`/knowledge/global-kbs/${kbId}`,
|
||||
globalKnowledgeBasePath(kbId),
|
||||
);
|
||||
}
|
||||
|
||||
@ -683,7 +701,7 @@ export function updateGlobalKnowledgeBaseApi(
|
||||
data: UpdateGlobalKnowledgeBaseDTO,
|
||||
) {
|
||||
return museAdminApi.patch<UpdateGlobalKnowledgeBaseResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}`,
|
||||
globalKnowledgeBasePath(kbId),
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -694,7 +712,7 @@ export function getGlobalKBDocumentsApi(
|
||||
params: GlobalKBDocumentQuery,
|
||||
) {
|
||||
return museAdminApi.get<PageResult<GlobalKBDocumentSummaryVO>>(
|
||||
`/knowledge/global-kbs/${kbId}/documents`,
|
||||
`${globalKnowledgeBasePath(kbId)}/documents`,
|
||||
toMuseQueryParams(params),
|
||||
);
|
||||
}
|
||||
@ -705,7 +723,7 @@ export function uploadGlobalKBDocumentApi(
|
||||
data: FormData | UploadGlobalKBDocumentDTO,
|
||||
) {
|
||||
return museAdminApi.post<UploadGlobalKBDocumentResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/documents`,
|
||||
`${globalKnowledgeBasePath(kbId)}/documents`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -713,7 +731,7 @@ export function uploadGlobalKBDocumentApi(
|
||||
/** 查询全局知识库资料详情。 */
|
||||
export function getGlobalKBDocumentApi(kbId: string, documentId: string) {
|
||||
return museAdminApi.get<GlobalKBDocumentDetailVO>(
|
||||
`/knowledge/global-kbs/${kbId}/documents/${documentId}`,
|
||||
globalKBDocumentPath(kbId, documentId),
|
||||
);
|
||||
}
|
||||
|
||||
@ -723,9 +741,13 @@ export function deleteGlobalKBDocumentApi(
|
||||
documentId: string,
|
||||
data: DeleteGlobalKBDocumentDTO,
|
||||
) {
|
||||
return museAdminApi.delete<DeleteGlobalKBDocumentResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/documents/${documentId}`,
|
||||
toMuseQueryParams(data),
|
||||
/**
|
||||
* OpenAPI 对这个 DELETE 明确要求 JSON requestBody。
|
||||
* 必须继续走 museAdminApi,确保 URL 保持 `/admin-api/muse/**` 边界。
|
||||
*/
|
||||
return museAdminApi.deleteBody<DeleteGlobalKBDocumentResultVO>(
|
||||
globalKBDocumentPath(kbId, documentId),
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
@ -736,7 +758,7 @@ export function createGlobalKBDocumentVersionApi(
|
||||
data: FormData | CreateGlobalKBDocumentVersionDTO,
|
||||
) {
|
||||
return museAdminApi.post<CreateGlobalKBDocumentVersionResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/documents/${documentId}/versions`,
|
||||
`${globalKBDocumentPath(kbId, documentId)}/versions`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -744,7 +766,7 @@ export function createGlobalKBDocumentVersionApi(
|
||||
/** 查询全局知识库版本列表。 */
|
||||
export function getGlobalKBVersionsApi(kbId: string) {
|
||||
return museAdminApi.get<GlobalKBVersionListVO>(
|
||||
`/knowledge/global-kbs/${kbId}/versions`,
|
||||
`${globalKnowledgeBasePath(kbId)}/versions`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -755,7 +777,7 @@ export function activateGlobalKBVersionApi(
|
||||
data: ActivateGlobalKBVersionDTO,
|
||||
) {
|
||||
return museAdminApi.post<ActivateGlobalKBVersionResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/versions/${version}/activate`,
|
||||
`${globalKnowledgeBasePath(kbId)}/versions/${encodePathParam(version)}/activate`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -763,7 +785,7 @@ export function activateGlobalKBVersionApi(
|
||||
/** 查询全局知识库授权策略列表。 */
|
||||
export function getGlobalKBAccessPoliciesApi(kbId: string) {
|
||||
return museAdminApi.get<GlobalKBAccessPolicyListVO>(
|
||||
`/knowledge/global-kbs/${kbId}/access-policies`,
|
||||
`${globalKnowledgeBasePath(kbId)}/access-policies`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -773,7 +795,7 @@ export function saveGlobalKBAccessPolicyDraftApi(
|
||||
data: SaveGlobalKBAccessPolicyDraftDTO,
|
||||
) {
|
||||
return museAdminApi.post<SaveGlobalKBAccessPolicyDraftResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/access-policies/drafts`,
|
||||
`${globalKnowledgeBasePath(kbId)}/access-policies/drafts`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -785,7 +807,7 @@ export function publishGlobalKBAccessPolicyApi(
|
||||
data: PublishGlobalKBAccessPolicyDTO,
|
||||
) {
|
||||
return museAdminApi.post<PublishGlobalKBAccessPolicyResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/access-policies/drafts/${draftId}/publish`,
|
||||
`${globalKnowledgeBasePath(kbId)}/access-policies/drafts/${encodePathParam(draftId)}/publish`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -796,7 +818,7 @@ export function enableGlobalKnowledgeBaseApi(
|
||||
data: GlobalKBEnableDisableDTO,
|
||||
) {
|
||||
return museAdminApi.post<EnableGlobalKnowledgeBaseResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/enable`,
|
||||
`${globalKnowledgeBasePath(kbId)}/enable`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -807,7 +829,7 @@ export function disableGlobalKnowledgeBaseApi(
|
||||
data: GlobalKBEnableDisableDTO,
|
||||
) {
|
||||
return museAdminApi.post<DisableGlobalKnowledgeBaseResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/disable`,
|
||||
`${globalKnowledgeBasePath(kbId)}/disable`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -818,7 +840,7 @@ export function previewGlobalKBImpactApi(
|
||||
data: PreviewGlobalKBImpactDTO,
|
||||
) {
|
||||
return museAdminApi.post<GlobalKBImpactPreviewVO>(
|
||||
`/knowledge/global-kbs/${kbId}/impact-preview`,
|
||||
`${globalKnowledgeBasePath(kbId)}/impact-preview`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -829,7 +851,7 @@ export function reindexGlobalKnowledgeBaseApi(
|
||||
data: ReindexGlobalKnowledgeBaseDTO,
|
||||
) {
|
||||
return museAdminApi.post<GlobalKBJobResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/reindex`,
|
||||
`${globalKnowledgeBasePath(kbId)}/reindex`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -840,7 +862,7 @@ export function getGlobalKBProcessingTasksApi(
|
||||
params: GlobalKBProcessingTaskQuery,
|
||||
) {
|
||||
return museAdminApi.get<PageResult<GlobalKBProcessingTaskSummaryVO>>(
|
||||
`/knowledge/global-kbs/${kbId}/processing-tasks`,
|
||||
`${globalKnowledgeBasePath(kbId)}/processing-tasks`,
|
||||
toMuseQueryParams(params),
|
||||
);
|
||||
}
|
||||
@ -851,7 +873,7 @@ export function retryGlobalKBSourceEventApi(
|
||||
data: TriggerGlobalKBSourceEventDTO,
|
||||
) {
|
||||
return museAdminApi.post<TriggerGlobalKBSourceEventResultVO>(
|
||||
`/knowledge/global-kbs/${kbId}/source-events`,
|
||||
`${globalKnowledgeBasePath(kbId)}/source-events`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
approveMarketPublishRequestApi,
|
||||
@ -27,6 +27,10 @@ vi.mock('#/api/muse/client', () => ({
|
||||
}));
|
||||
|
||||
describe('muse market admin api', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('按 OpenAPI 契约调用市场审核、治理和申诉端点', async () => {
|
||||
await getMarketAssetListApi({ assetType: 'knowledge_base', pageNo: 1 });
|
||||
await getMarketAssetApi('asset-1');
|
||||
@ -35,12 +39,17 @@ describe('muse market admin api', () => {
|
||||
commandId: 'cmd-preview',
|
||||
scope: 'full_recall',
|
||||
});
|
||||
await previewMarketGovernanceImpactApi('asset-1', {
|
||||
actionType: 'review_rejection',
|
||||
commandId: 'cmd-review-rejection-preview',
|
||||
scope: 'stop_new_acquire',
|
||||
});
|
||||
await delistMarketAssetApi('asset-1', {
|
||||
commandId: 'cmd-delist',
|
||||
expectedStatus: 'listed',
|
||||
impactPreviewId: 'preview-1',
|
||||
reason: '权利声明失效',
|
||||
scope: 'full_delist',
|
||||
scope: 'stop_new_acquire',
|
||||
});
|
||||
await recallMarketAssetApi('asset-1', {
|
||||
basis: '合规召回',
|
||||
@ -82,9 +91,13 @@ describe('muse market admin api', () => {
|
||||
'/market/assets/asset-1/governance-impact',
|
||||
expect.objectContaining({ actionType: 'recall' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/assets/asset-1/governance-impact',
|
||||
expect.objectContaining({ actionType: 'review_rejection' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/assets/asset-1/delist',
|
||||
expect.objectContaining({ scope: 'full_delist' }),
|
||||
expect.objectContaining({ scope: 'stop_new_acquire' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/assets/asset-1/recall',
|
||||
@ -110,4 +123,79 @@ describe('muse market admin api', () => {
|
||||
expect.objectContaining({ resolution: 'maintained' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('编码市场 API 路径中的字符串 ID', async () => {
|
||||
await getMarketAssetApi('asset/with/slash');
|
||||
await previewMarketGovernanceImpactApi('asset/with/slash', {
|
||||
actionType: 'recall',
|
||||
commandId: 'cmd-preview-slash',
|
||||
scope: 'full_recall',
|
||||
});
|
||||
await delistMarketAssetApi('asset/with/slash', {
|
||||
commandId: 'cmd-delist-slash',
|
||||
expectedStatus: 'listed',
|
||||
impactPreviewId: 'preview/slash',
|
||||
reason: '路径编码测试',
|
||||
scope: 'stop_new_acquire',
|
||||
});
|
||||
await recallMarketAssetApi('asset/with/slash', {
|
||||
basis: '合规召回',
|
||||
commandId: 'cmd-recall-slash',
|
||||
expectedStatus: 'listed',
|
||||
impactPreviewId: 'preview/slash',
|
||||
reason: '路径编码测试',
|
||||
scope: 'full_recall',
|
||||
});
|
||||
await approveMarketPublishRequestApi('request/with/slash', {
|
||||
commandId: 'cmd-approve-slash',
|
||||
expectedStatus: 'pending',
|
||||
note: '材料完整',
|
||||
validationResultId: 'validation/slash',
|
||||
});
|
||||
await rejectMarketPublishRequestApi('request/with/slash', {
|
||||
commandId: 'cmd-reject-slash',
|
||||
expectedStatus: 'reviewing',
|
||||
reason: '路径编码测试',
|
||||
validationResultId: 'validation/slash',
|
||||
});
|
||||
await getMarketAppealApi('appeal/with/slash');
|
||||
await resolveMarketAppealApi('appeal/with/slash', {
|
||||
commandId: 'cmd-resolve-slash',
|
||||
expectedStatus: 'reviewing',
|
||||
impactPreviewId: 'preview/slash',
|
||||
reason: '路径编码测试',
|
||||
resolution: 'maintained',
|
||||
});
|
||||
|
||||
expect(clientMocks.get).toHaveBeenCalledWith(
|
||||
'/market/assets/asset%2Fwith%2Fslash',
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/assets/asset%2Fwith%2Fslash/governance-impact',
|
||||
expect.objectContaining({ commandId: 'cmd-preview-slash' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/assets/asset%2Fwith%2Fslash/delist',
|
||||
expect.objectContaining({ commandId: 'cmd-delist-slash' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/assets/asset%2Fwith%2Fslash/recall',
|
||||
expect.objectContaining({ commandId: 'cmd-recall-slash' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/publish-requests/request%2Fwith%2Fslash/approve',
|
||||
expect.objectContaining({ commandId: 'cmd-approve-slash' }),
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/publish-requests/request%2Fwith%2Fslash/reject',
|
||||
expect.objectContaining({ commandId: 'cmd-reject-slash' }),
|
||||
);
|
||||
expect(clientMocks.get).toHaveBeenCalledWith(
|
||||
'/market/appeals/appeal%2Fwith%2Fslash',
|
||||
);
|
||||
expect(clientMocks.post).toHaveBeenCalledWith(
|
||||
'/market/appeals/appeal%2Fwith%2Fslash/resolve',
|
||||
expect.objectContaining({ commandId: 'cmd-resolve-slash' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -9,6 +9,11 @@ function toMuseQueryParams(params: object): MuseQueryParams {
|
||||
return params as MuseQueryParams;
|
||||
}
|
||||
|
||||
/** 编码路径参数,避免业务 ID 中的斜杠被当成额外 URL 分段。 */
|
||||
function encodePathParam(value: string) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
/** 市场资产类型。 */
|
||||
export type MarketAssetType = 'agent' | 'knowledge_base' | 'work';
|
||||
|
||||
@ -49,6 +54,7 @@ export type MarketActionPolicyFilter =
|
||||
export type MarketGovernanceActionType =
|
||||
| 'delist'
|
||||
| 'recall'
|
||||
| 'review_rejection'
|
||||
| 'revoke_license'
|
||||
| 'stop_generation';
|
||||
|
||||
@ -453,7 +459,7 @@ export function getMarketAssetListApi(params: MarketAssetQuery) {
|
||||
/** 查询市场资产治理详情。 */
|
||||
export function getMarketAssetApi(assetId: string) {
|
||||
return museAdminApi.get<AdminMarketAssetDetailVO>(
|
||||
`/market/assets/${assetId}`,
|
||||
`/market/assets/${encodePathParam(assetId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -463,7 +469,7 @@ export function previewMarketGovernanceImpactApi(
|
||||
data: PreviewMarketGovernanceImpactDTO,
|
||||
) {
|
||||
return museAdminApi.post<AdminGovernanceImpactPreviewVO>(
|
||||
`/market/assets/${assetId}/governance-impact`,
|
||||
`/market/assets/${encodePathParam(assetId)}/governance-impact`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -473,7 +479,10 @@ export function delistMarketAssetApi(
|
||||
assetId: string,
|
||||
data: DelistMarketAssetDTO,
|
||||
) {
|
||||
return museAdminApi.post<void>(`/market/assets/${assetId}/delist`, data);
|
||||
return museAdminApi.post<void>(
|
||||
`/market/assets/${encodePathParam(assetId)}/delist`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 召回市场资产。 */
|
||||
@ -481,7 +490,10 @@ export function recallMarketAssetApi(
|
||||
assetId: string,
|
||||
data: RecallMarketAssetDTO,
|
||||
) {
|
||||
return museAdminApi.post<void>(`/market/assets/${assetId}/recall`, data);
|
||||
return museAdminApi.post<void>(
|
||||
`/market/assets/${encodePathParam(assetId)}/recall`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询发布申请审核队列。 */
|
||||
@ -500,7 +512,7 @@ export function approveMarketPublishRequestApi(
|
||||
data: ApproveMarketPublishRequestDTO,
|
||||
) {
|
||||
return museAdminApi.post<void>(
|
||||
`/market/publish-requests/${requestId}/approve`,
|
||||
`/market/publish-requests/${encodePathParam(requestId)}/approve`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -511,7 +523,7 @@ export function rejectMarketPublishRequestApi(
|
||||
data: RejectMarketPublishRequestDTO,
|
||||
) {
|
||||
return museAdminApi.post<void>(
|
||||
`/market/publish-requests/${requestId}/reject`,
|
||||
`/market/publish-requests/${encodePathParam(requestId)}/reject`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
@ -526,7 +538,9 @@ export function getMarketAppealListApi(params: MarketAppealQuery) {
|
||||
|
||||
/** 查询市场申诉详情。 */
|
||||
export function getMarketAppealApi(appealId: string) {
|
||||
return museAdminApi.get<AdminAppealDetailVO>(`/market/appeals/${appealId}`);
|
||||
return museAdminApi.get<AdminAppealDetailVO>(
|
||||
`/market/appeals/${encodePathParam(appealId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 处理市场申诉。 */
|
||||
@ -535,7 +549,7 @@ export function resolveMarketAppealApi(
|
||||
data: ResolveMarketAppealDTO,
|
||||
) {
|
||||
return museAdminApi.post<void>(
|
||||
`/market/appeals/${appealId}/resolve`,
|
||||
`/market/appeals/${encodePathParam(appealId)}/resolve`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,25 +1,47 @@
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import routes from '../modules/muse';
|
||||
|
||||
describe('muse admin routes', () => {
|
||||
it('注册所有 P3 管理端功能域,并统一挂载在 /muse 下', () => {
|
||||
const root = routes[0]!;
|
||||
const childPaths = root.children?.map((route) => route.path) ?? [];
|
||||
const createMuseRouter = () =>
|
||||
createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
expect(root.path).toBe('/muse');
|
||||
expect(childPaths).toEqual([
|
||||
'governance/meta-schemas',
|
||||
'governance/meta-schemas/:schemaKey',
|
||||
'governance/function-orchestration',
|
||||
'ai',
|
||||
'knowledge',
|
||||
'market',
|
||||
'account',
|
||||
'new-api',
|
||||
'jobs',
|
||||
'audit',
|
||||
]);
|
||||
expect(root.children?.every((route) => route.component)).toBe(true);
|
||||
describe('muse admin routes', () => {
|
||||
it('通过路由解析暴露所有 P3 管理端功能域', () => {
|
||||
const memoryRouter = createMuseRouter();
|
||||
const routeContracts = [
|
||||
['MuseMetaSchemaList', '/muse/governance/meta-schemas'],
|
||||
['MuseFunctionOrchestration', '/muse/governance/function-orchestration'],
|
||||
['MuseAiConfig', '/muse/ai'],
|
||||
['MuseGlobalKnowledge', '/muse/knowledge'],
|
||||
['MuseMarketGovernance', '/muse/market'],
|
||||
['MuseAccountGovernance', '/muse/account'],
|
||||
['MuseNewApiUsage', '/muse/new-api'],
|
||||
['MuseTaskOps', '/muse/jobs'],
|
||||
['MuseAuditLog', '/muse/audit'],
|
||||
] as const;
|
||||
|
||||
for (const [name, path] of routeContracts) {
|
||||
const resolved = memoryRouter.resolve({ name });
|
||||
expect(resolved.fullPath).toBe(path);
|
||||
expect(resolved.name).toBe(name);
|
||||
expect(resolved.matched.at(0)?.path).toBe('/muse');
|
||||
}
|
||||
});
|
||||
|
||||
it('MetaSchema 详情页使用真实 REST 风格 path 和路由参数', () => {
|
||||
const memoryRouter = createMuseRouter();
|
||||
const resolved = memoryRouter.resolve({
|
||||
name: 'MuseMetaSchemaDetail',
|
||||
params: { schemaKey: 'work/story' },
|
||||
});
|
||||
|
||||
expect(resolved.name).toBe('MuseMetaSchemaDetail');
|
||||
expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory');
|
||||
expect(resolved.meta.activePath).toBe('/muse/governance/meta-schemas');
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
AdminUsageRecord,
|
||||
AdminUserEntitlementDetail,
|
||||
AccountStatus,
|
||||
Entitlement,
|
||||
QuotaAdjustmentLedgerEntry,
|
||||
QuotaStatus,
|
||||
} from '#/api/muse/account';
|
||||
@ -326,6 +327,37 @@ function displayText(value?: number | string) {
|
||||
return value === undefined || value === '' ? '-' : String(value);
|
||||
}
|
||||
|
||||
/** 当前配额弹窗对应用户的权益快照,禁止跨用户复用 selectedEntitlement。 */
|
||||
const quotaUserEntitlements = computed(() => {
|
||||
if (selectedEntitlement.value?.userId !== quotaForm.userId) {
|
||||
return [];
|
||||
}
|
||||
return selectedEntitlement.value.entitlements;
|
||||
});
|
||||
|
||||
/** 用当前用户的权益快照填充调整前审计快照。 */
|
||||
function applyQuotaSnapshot(entitlement?: Entitlement) {
|
||||
quotaForm.beforeLimit = entitlement?.limit;
|
||||
quotaForm.beforeRemaining = entitlement?.remaining;
|
||||
quotaForm.beforeUsed = entitlement?.used;
|
||||
quotaForm.resourceType = entitlement?.resourceType ?? '';
|
||||
}
|
||||
|
||||
/** 切换资源类型时同步切换 beforeSnapshot,避免资源类型和快照错配。 */
|
||||
function handleQuotaResourceTypeChange(resourceType: string) {
|
||||
const entitlement = quotaUserEntitlements.value.find(
|
||||
(item) => item.resourceType === resourceType,
|
||||
);
|
||||
applyQuotaSnapshot(entitlement);
|
||||
}
|
||||
|
||||
/** 提交前再次从当前用户快照取值,防止表单残留被当作可信审计快照。 */
|
||||
function getQuotaSubmissionEntitlement() {
|
||||
return quotaUserEntitlements.value.find(
|
||||
(item) => item.resourceType === quotaForm.resourceType,
|
||||
);
|
||||
}
|
||||
|
||||
/** 刷新用户账户摘要列表。 */
|
||||
async function loadUsers(page = userPager.current, pageSize = userPager.pageSize) {
|
||||
userLoading.value = true;
|
||||
@ -475,16 +507,29 @@ function submitGovernanceCommand() {
|
||||
|
||||
/** 打开配额调整弹窗,并用当前权益快照预填审计字段。 */
|
||||
function openQuotaModal(record: AdminAccountUserSummary) {
|
||||
const entitlement = selectedEntitlement.value?.entitlements?.[0];
|
||||
const isCurrentUserSnapshot =
|
||||
selectedUser.value?.userId === record.userId &&
|
||||
selectedEntitlement.value?.userId === record.userId;
|
||||
if (!isCurrentUserSnapshot) {
|
||||
message.error('请先点击“权益”加载当前用户的权益快照,再调整配额');
|
||||
return;
|
||||
}
|
||||
const entitlementDetail = selectedEntitlement.value;
|
||||
if (!entitlementDetail) {
|
||||
message.error('请先加载当前用户的权益快照');
|
||||
return;
|
||||
}
|
||||
const entitlement = entitlementDetail.entitlements[0];
|
||||
if (!entitlement) {
|
||||
message.error('当前用户没有可用于配额调整的权益快照');
|
||||
return;
|
||||
}
|
||||
quotaForm.approvalRef = '';
|
||||
quotaForm.beforeLimit = entitlement?.limit;
|
||||
quotaForm.beforeRemaining = entitlement?.remaining;
|
||||
quotaForm.beforeUsed = entitlement?.used;
|
||||
quotaForm.commandId = createCommandId('quota');
|
||||
quotaForm.delta = undefined;
|
||||
quotaForm.reason = '';
|
||||
quotaForm.resourceType = entitlement?.resourceType ?? '';
|
||||
quotaForm.userId = record.userId;
|
||||
applyQuotaSnapshot(entitlement);
|
||||
quotaModalOpen.value = true;
|
||||
}
|
||||
|
||||
@ -498,18 +543,23 @@ async function submitQuotaAdjustment() {
|
||||
message.error('请填写非 0 调整量');
|
||||
return;
|
||||
}
|
||||
const entitlement = getQuotaSubmissionEntitlement();
|
||||
if (!entitlement) {
|
||||
message.error('当前资源类型没有匹配的用户权益快照,请重新加载权益后再提交');
|
||||
return;
|
||||
}
|
||||
quotaSubmitting.value = true;
|
||||
try {
|
||||
await createQuotaAdjustmentApi(quotaForm.userId, {
|
||||
adjustments: [
|
||||
{
|
||||
beforeSnapshot: {
|
||||
limit: quotaForm.beforeLimit,
|
||||
remaining: quotaForm.beforeRemaining,
|
||||
used: quotaForm.beforeUsed,
|
||||
limit: entitlement.limit,
|
||||
remaining: entitlement.remaining,
|
||||
used: entitlement.used,
|
||||
},
|
||||
delta: quotaForm.delta,
|
||||
resourceType: quotaForm.resourceType,
|
||||
resourceType: entitlement.resourceType,
|
||||
},
|
||||
],
|
||||
approvalRef: quotaForm.approvalRef || undefined,
|
||||
@ -808,7 +858,19 @@ onMounted(() => {
|
||||
<Input v-model:value="quotaForm.userId" disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="资源类型" required>
|
||||
<Input v-model:value="quotaForm.resourceType" />
|
||||
<Select
|
||||
v-model:value="quotaForm.resourceType"
|
||||
placeholder="选择当前用户权益资源"
|
||||
@change="(value) => handleQuotaResourceTypeChange(String(value))"
|
||||
>
|
||||
<Select.Option
|
||||
v-for="entitlement in quotaUserEntitlements"
|
||||
:key="entitlement.resourceType"
|
||||
:value="entitlement.resourceType"
|
||||
>
|
||||
{{ entitlement.resourceType }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="调整量" required>
|
||||
<InputNumber v-model:value="quotaForm.delta" class="w-full" />
|
||||
|
||||
@ -49,6 +49,17 @@ import {
|
||||
|
||||
defineOptions({ name: 'MuseGovernanceFunctionOrchestration' });
|
||||
|
||||
interface FunctionChainPreviewTicketCompat {
|
||||
/** 后端兼容返回的功能链路校验结果票据。 */
|
||||
validationResultId?: string;
|
||||
/** 后端兼容返回的功能链路校验票据别名。 */
|
||||
validationTicketId?: string;
|
||||
/** 后端兼容返回的功能链路校验结果别名。 */
|
||||
validationId?: string;
|
||||
/** 后端兼容返回的票据通用别名。 */
|
||||
ticketId?: string;
|
||||
}
|
||||
|
||||
/** 功能链路状态展示配置。 */
|
||||
const STATUS_META_MAP: Record<FunctionChainStatus, { color: string; label: string }> = {
|
||||
active: { color: 'green', label: '已启用' },
|
||||
@ -103,8 +114,19 @@ const protectionNodeLoading = ref(false);
|
||||
const previewDrawerOpen = ref(false);
|
||||
const activateDrawerOpen = ref(false);
|
||||
const previewLoading = ref(false);
|
||||
const previewRequestSeq = ref(0);
|
||||
const activating = ref(false);
|
||||
const impactPreview = ref<FunctionChainImpactPreview>();
|
||||
const impactPreviewBinding = ref<{
|
||||
/** 影响预览所属链路。 */
|
||||
chainKey: string;
|
||||
/** 影响预览所属目标版本。 */
|
||||
targetVersion: number;
|
||||
/** 当前可提交的影响预览票据。 */
|
||||
impactPreviewId: string;
|
||||
/** 当前可提交的校验结果票据。 */
|
||||
validationResultId: string;
|
||||
}>();
|
||||
|
||||
/** 影响预览请求表单。 */
|
||||
const previewForm = reactive({
|
||||
@ -121,8 +143,29 @@ const activateForm = reactive({
|
||||
validationResultId: '',
|
||||
});
|
||||
|
||||
/** 当前影响预览是否仍绑定当前链路和目标版本。 */
|
||||
const hasCurrentImpactPreviewBinding = computed(() => {
|
||||
return (
|
||||
!!selectedChain.value &&
|
||||
!!activateForm.targetVersion &&
|
||||
!!impactPreviewBinding.value &&
|
||||
impactPreviewBinding.value.chainKey === selectedChain.value.chainKey &&
|
||||
impactPreviewBinding.value.targetVersion === activateForm.targetVersion &&
|
||||
impactPreviewBinding.value.impactPreviewId === activateForm.impactPreviewId &&
|
||||
!!activateForm.impactPreviewId
|
||||
);
|
||||
});
|
||||
|
||||
/** 当前激活票据是否完整绑定当前链路和目标版本。 */
|
||||
const hasCurrentActivationTicketBinding = computed(() => {
|
||||
return hasCurrentImpactPreviewBinding.value && !!activateForm.validationResultId;
|
||||
});
|
||||
|
||||
/** 是否存在保护节点边界违规,存在时前端也阻断激活入口。 */
|
||||
const hasProtectionBoundaryViolation = computed(() => {
|
||||
if (!hasCurrentImpactPreviewBinding.value) {
|
||||
return false;
|
||||
}
|
||||
return (impactPreview.value?.slotChanges ?? []).some(
|
||||
(item) => item.changeType === 'protection_boundary_violation',
|
||||
);
|
||||
@ -133,6 +176,19 @@ function createCommandId(action: string) {
|
||||
return `muse-admin-function-chain-${action}-${Date.now()}`;
|
||||
}
|
||||
|
||||
/** 兼容后端影响预览返回的校验票据,不改 OpenAPI 合同,只在页面内窄类型读取。 */
|
||||
function readValidationResultId(
|
||||
result: FunctionChainImpactPreview & FunctionChainPreviewTicketCompat,
|
||||
) {
|
||||
return (
|
||||
result.validationResultId ??
|
||||
result.validationTicketId ??
|
||||
result.validationId ??
|
||||
result.ticketId ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** 推导功能链路状态。 */
|
||||
function resolveChainStatus(row: FunctionChainSummary): FunctionChainStatus {
|
||||
return row.status ?? 'active';
|
||||
@ -140,11 +196,28 @@ function resolveChainStatus(row: FunctionChainSummary): FunctionChainStatus {
|
||||
|
||||
/** 选中链路并加载保护节点注册表。 */
|
||||
async function handleSelectChain(row: FunctionChainSummary) {
|
||||
const previousChainKey = selectedChain.value?.chainKey;
|
||||
const currentBinding =
|
||||
impactPreviewBinding.value?.chainKey === row.chainKey
|
||||
? impactPreviewBinding.value
|
||||
: undefined;
|
||||
selectedChain.value = row;
|
||||
previewForm.expectedCurrentVersion = row.activeVersion;
|
||||
previewForm.targetVersion = row.activeVersion;
|
||||
previewForm.targetVersion = currentBinding?.targetVersion ?? row.activeVersion;
|
||||
activateForm.expectedActiveVersion = row.activeVersion;
|
||||
activateForm.targetVersion = row.activeVersion;
|
||||
activateForm.targetVersion = currentBinding?.targetVersion ?? row.activeVersion;
|
||||
if (!currentBinding) {
|
||||
impactPreview.value = undefined;
|
||||
impactPreviewBinding.value = undefined;
|
||||
activateForm.impactPreviewId = '';
|
||||
activateForm.validationResultId = '';
|
||||
} else {
|
||||
activateForm.impactPreviewId = currentBinding.impactPreviewId;
|
||||
activateForm.validationResultId = currentBinding.validationResultId;
|
||||
}
|
||||
if (previousChainKey !== row.chainKey) {
|
||||
activateForm.reason = '';
|
||||
}
|
||||
protectionNodeLoading.value = true;
|
||||
try {
|
||||
const result = await listProtectionNodesApi({
|
||||
@ -167,7 +240,7 @@ function openPreviewDrawer(row: FunctionChainSummary) {
|
||||
/** 打开激活确认抽屉。 */
|
||||
function openActivateDrawer(row: FunctionChainSummary) {
|
||||
handleSelectChain(row);
|
||||
activateForm.impactPreviewId = impactPreview.value?.impactPreviewId ?? '';
|
||||
activateForm.impactPreviewId = impactPreviewBinding.value?.impactPreviewId ?? '';
|
||||
activateDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
@ -177,29 +250,78 @@ async function handlePreviewImpact() {
|
||||
message.warning('请先选择功能链路');
|
||||
return;
|
||||
}
|
||||
const requestedSeq = previewRequestSeq.value + 1;
|
||||
previewRequestSeq.value = requestedSeq;
|
||||
const requestedChainKey = selectedChain.value.chainKey;
|
||||
const requestedTargetVersion = previewForm.targetVersion ?? selectedChain.value.activeVersion;
|
||||
const requestedExpectedCurrentVersion = previewForm.expectedCurrentVersion;
|
||||
if (
|
||||
impactPreviewBinding.value &&
|
||||
(impactPreviewBinding.value.chainKey !== requestedChainKey ||
|
||||
impactPreviewBinding.value.targetVersion !== requestedTargetVersion)
|
||||
) {
|
||||
impactPreview.value = undefined;
|
||||
impactPreviewBinding.value = undefined;
|
||||
activateForm.impactPreviewId = '';
|
||||
}
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
impactPreview.value = await previewFunctionChainImpactApi(
|
||||
selectedChain.value.chainKey,
|
||||
const result = (await previewFunctionChainImpactApi(
|
||||
requestedChainKey,
|
||||
{
|
||||
commandId: createCommandId('impact-preview'),
|
||||
expectedCurrentVersion: previewForm.expectedCurrentVersion,
|
||||
targetVersion: previewForm.targetVersion,
|
||||
expectedCurrentVersion: requestedExpectedCurrentVersion,
|
||||
targetVersion: requestedTargetVersion,
|
||||
},
|
||||
);
|
||||
activateForm.impactPreviewId = impactPreview.value.impactPreviewId ?? '';
|
||||
)) as FunctionChainImpactPreview & FunctionChainPreviewTicketCompat;
|
||||
if (
|
||||
previewRequestSeq.value !== requestedSeq ||
|
||||
selectedChain.value?.chainKey !== requestedChainKey ||
|
||||
(previewForm.targetVersion ?? selectedChain.value?.activeVersion) !==
|
||||
requestedTargetVersion
|
||||
) {
|
||||
return;
|
||||
}
|
||||
impactPreview.value = result;
|
||||
impactPreviewBinding.value = {
|
||||
chainKey: requestedChainKey,
|
||||
impactPreviewId: result.impactPreviewId ?? '',
|
||||
targetVersion: requestedTargetVersion,
|
||||
validationResultId: readValidationResultId(result),
|
||||
};
|
||||
activateForm.targetVersion = requestedTargetVersion;
|
||||
activateForm.impactPreviewId = impactPreviewBinding.value.impactPreviewId;
|
||||
activateForm.validationResultId = impactPreviewBinding.value.validationResultId;
|
||||
message.success('影响预览已生成');
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
if (previewRequestSeq.value === requestedSeq) {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 激活前校验预览票据和当前链路、版本一致。 */
|
||||
function ensureCurrentImpactPreviewBinding() {
|
||||
if (!hasCurrentImpactPreviewBinding.value) {
|
||||
message.warning('请先为当前链路和目标版本重新生成影响预览');
|
||||
return false;
|
||||
}
|
||||
if (!activateForm.validationResultId) {
|
||||
message.warning('请先完成当前链路校验,确保当前预览返回校验结果 ID');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 执行功能链路激活。 */
|
||||
async function handleActivateChain() {
|
||||
if (!selectedChain.value || !activateForm.targetVersion) {
|
||||
message.warning('请先选择要激活的功能链路版本');
|
||||
return;
|
||||
}
|
||||
if (!ensureCurrentImpactPreviewBinding()) {
|
||||
return;
|
||||
}
|
||||
if (hasProtectionBoundaryViolation.value) {
|
||||
message.error('影响预览存在保护节点边界违规,不能激活');
|
||||
return;
|
||||
@ -481,10 +603,17 @@ function handleRefresh() {
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="validationResultId">
|
||||
<a-input v-model:value="activateForm.validationResultId" />
|
||||
<a-input v-model:value="activateForm.validationResultId" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="impactPreviewId">
|
||||
<a-input v-model:value="activateForm.impactPreviewId" />
|
||||
<a-input v-model:value="activateForm.impactPreviewId" readonly />
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{
|
||||
hasCurrentActivationTicketBinding
|
||||
? '当前链路预览和校验票据已绑定'
|
||||
: '请先为当前链路和目标版本生成影响预览并取得校验票据'
|
||||
}}
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="expectedActiveVersion">
|
||||
<a-input-number
|
||||
|
||||
@ -0,0 +1,281 @@
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from 'vue';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const routeState = reactive({
|
||||
params: { schemaKey: 'work.story' },
|
||||
query: {} as Record<string, string>,
|
||||
});
|
||||
|
||||
function makeStub(name: string) {
|
||||
return defineComponent({
|
||||
name,
|
||||
props: [
|
||||
'dataSource',
|
||||
'label',
|
||||
'loading',
|
||||
'message',
|
||||
'open',
|
||||
'placeholder',
|
||||
'status',
|
||||
'subTitle',
|
||||
'title',
|
||||
'value',
|
||||
],
|
||||
setup(props, { slots }) {
|
||||
return () =>
|
||||
h('section', { 'data-stub': name }, [
|
||||
props.title,
|
||||
props.label,
|
||||
props.message,
|
||||
props.placeholder,
|
||||
props.status,
|
||||
props.subTitle,
|
||||
props.value,
|
||||
props.open ? 'open' : '',
|
||||
Array.isArray(props.dataSource)
|
||||
? JSON.stringify(props.dataSource)
|
||||
: '',
|
||||
slots.default?.(),
|
||||
slots.footer?.(),
|
||||
slots.bodyCell?.({
|
||||
column: { dataIndex: 'required' },
|
||||
record: {},
|
||||
}),
|
||||
slots.renderItem?.({
|
||||
item: { code: 'stub', message: 'stub message' },
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => routeState,
|
||||
}));
|
||||
|
||||
vi.mock('@vben/common-ui', () => ({
|
||||
Page: makeStub('Page'),
|
||||
}));
|
||||
|
||||
vi.mock('ant-design-vue', () => ({
|
||||
Alert: makeStub('Alert'),
|
||||
Button: defineComponent({
|
||||
emits: ['click'],
|
||||
props: ['disabled', 'loading'],
|
||||
setup(props, { slots, emit }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit('click'),
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
Card: makeStub('Card'),
|
||||
Col: makeStub('Col'),
|
||||
Descriptions: makeStub('Descriptions'),
|
||||
DescriptionsItem: makeStub('DescriptionsItem'),
|
||||
Drawer: makeStub('Drawer'),
|
||||
Form: makeStub('Form'),
|
||||
FormItem: makeStub('FormItem'),
|
||||
Input: makeStub('Input'),
|
||||
InputNumber: makeStub('InputNumber'),
|
||||
List: makeStub('List'),
|
||||
ListItem: makeStub('ListItem'),
|
||||
RadioButton: makeStub('RadioButton'),
|
||||
RadioGroup: makeStub('RadioGroup'),
|
||||
Result: makeStub('Result'),
|
||||
Row: makeStub('Row'),
|
||||
Select: makeStub('Select'),
|
||||
Space: makeStub('Space'),
|
||||
Table: makeStub('Table'),
|
||||
Tag: makeStub('Tag'),
|
||||
Textarea: makeStub('Textarea'),
|
||||
message: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
Modal: {
|
||||
confirm: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const getMetaSchemaApi = vi.fn();
|
||||
const saveMetaSchemaDraftApi = vi.fn();
|
||||
const validateMetaSchemaDraftApi = vi.fn();
|
||||
const previewMetaSchemaDraftImpactApi = vi.fn();
|
||||
|
||||
vi.mock('#/api/muse/governance/meta-schema', () => ({
|
||||
activateMetaSchemaVersionApi: vi.fn(),
|
||||
getMetaSchemaApi,
|
||||
getMetaSchemaVersionApi: vi.fn(),
|
||||
previewMetaSchemaDraftImpactApi,
|
||||
publishMetaSchemaDraftApi: vi.fn(),
|
||||
saveMetaSchemaDraftApi,
|
||||
setMetaSchemaGrayRulesApi: vi.fn(),
|
||||
validateMetaSchemaDraftApi,
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function makeDetail(schemaKey = 'work.story') {
|
||||
return {
|
||||
activeVersion: 1,
|
||||
description: '当前结构',
|
||||
displayName: '作品结构',
|
||||
fields: [
|
||||
{
|
||||
displayName: '标题',
|
||||
fieldKey: 'title',
|
||||
fieldType: 'string',
|
||||
},
|
||||
],
|
||||
schemaKey,
|
||||
scope: 'work',
|
||||
};
|
||||
}
|
||||
|
||||
describe('muse meta schema detail page', () => {
|
||||
beforeEach(() => {
|
||||
routeState.params.schemaKey = 'work.story';
|
||||
routeState.query = {};
|
||||
getMetaSchemaApi.mockReset();
|
||||
saveMetaSchemaDraftApi.mockReset();
|
||||
validateMetaSchemaDraftApi.mockReset();
|
||||
previewMetaSchemaDraftImpactApi.mockReset();
|
||||
});
|
||||
|
||||
it('丢弃旧保存响应,避免草稿版本回滚', async () => {
|
||||
const firstSaveRequest = deferred<{ draftVersion: number }>();
|
||||
|
||||
getMetaSchemaApi.mockResolvedValue(makeDetail());
|
||||
saveMetaSchemaDraftApi
|
||||
.mockReturnValueOnce(firstSaveRequest.promise)
|
||||
.mockResolvedValueOnce({ draftVersion: 2 });
|
||||
validateMetaSchemaDraftApi.mockResolvedValue({
|
||||
valid: true,
|
||||
validationResultId: 'validation-current',
|
||||
});
|
||||
|
||||
const { root, unmount } = await mountPage();
|
||||
await flushPromises();
|
||||
|
||||
const buttons = () =>
|
||||
[...root.querySelectorAll<HTMLElement>('button,a-button')].map((button) => ({
|
||||
element: button,
|
||||
text: button.textContent ?? '',
|
||||
}));
|
||||
|
||||
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
|
||||
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
firstSaveRequest.resolve({ draftVersion: 1 });
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
buttons().find((button) => button.text.includes('校验草稿'))?.element.click();
|
||||
await flushPromises();
|
||||
|
||||
expect(validateMetaSchemaDraftApi).toHaveBeenCalledWith('work.story', 2);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('丢弃草稿切换前返回的校验和影响预览票据', async () => {
|
||||
const validateRequest = deferred<{
|
||||
valid: boolean;
|
||||
validationResultId: string;
|
||||
}>();
|
||||
const previewRequest = deferred<{
|
||||
draftVersion: number;
|
||||
impactPreviewId: string;
|
||||
schemaKey: string;
|
||||
}>();
|
||||
|
||||
const oldValidationResult = {
|
||||
valid: true,
|
||||
validationResultId: 'validation-old',
|
||||
};
|
||||
const oldImpactPreview = {
|
||||
draftVersion: 1,
|
||||
impactPreviewId: 'impact-old',
|
||||
schemaKey: 'work.story',
|
||||
};
|
||||
|
||||
getMetaSchemaApi.mockResolvedValue(makeDetail());
|
||||
saveMetaSchemaDraftApi
|
||||
.mockResolvedValueOnce({ draftVersion: 1 })
|
||||
.mockResolvedValueOnce({ draftVersion: 2 });
|
||||
validateMetaSchemaDraftApi.mockReturnValue(validateRequest.promise);
|
||||
previewMetaSchemaDraftImpactApi.mockReturnValue(previewRequest.promise);
|
||||
|
||||
const { root, unmount } = await mountPage();
|
||||
await flushPromises();
|
||||
|
||||
const buttons = () =>
|
||||
[...root.querySelectorAll<HTMLElement>('button,a-button')].map((button) => ({
|
||||
element: button,
|
||||
text: button.textContent ?? '',
|
||||
}));
|
||||
|
||||
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
buttons().find((button) => button.text.includes('校验草稿'))?.element.click();
|
||||
buttons().find((button) => button.text.includes('影响预览'))?.element.click();
|
||||
|
||||
buttons().find((button) => button.text.includes('保存草稿'))?.element.click();
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
validateRequest.resolve(oldValidationResult);
|
||||
previewRequest.resolve(oldImpactPreview);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
buttons().find((button) => button.text.includes('发布确认'))?.element.click();
|
||||
await nextTick();
|
||||
buttons().find((button) => button.text.includes('灰度规则 / 激活'))?.element.click();
|
||||
await nextTick();
|
||||
|
||||
expect(root.textContent).not.toContain('validation-old');
|
||||
expect(root.textContent).not.toContain('impact-old');
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
async function mountPage() {
|
||||
const { default: DetailPage } = await import('../detail.vue');
|
||||
const root = document.createElement('div');
|
||||
document.body.append(root);
|
||||
|
||||
const app = createApp(DetailPage);
|
||||
app.mount(root);
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
return {
|
||||
root,
|
||||
unmount: () => {
|
||||
app.unmount();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
* 提供 active 字段展示、草稿 JSON 编辑、校验、影响预览、发布确认和灰度规则配置。
|
||||
*/
|
||||
import type { TableColumnsType } from 'ant-design-vue';
|
||||
import type { LocationQueryValue } from 'vue-router';
|
||||
import type {
|
||||
GrayRuleStrategy,
|
||||
MetaField,
|
||||
@ -17,12 +16,37 @@ import type {
|
||||
ValidationResult,
|
||||
} from '#/api/muse/governance/meta-schema';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Card as ACard,
|
||||
Col as ACol,
|
||||
Descriptions as ADescriptions,
|
||||
DescriptionsItem as ADescriptionsItem,
|
||||
Drawer as ADrawer,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
Input as AInput,
|
||||
InputNumber as AInputNumber,
|
||||
List as AList,
|
||||
ListItem as AListItem,
|
||||
RadioButton as ARadioButton,
|
||||
RadioGroup as ARadioGroup,
|
||||
Result as AResult,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Table as ATable,
|
||||
Tag as ATag,
|
||||
Textarea as ATextarea,
|
||||
message,
|
||||
Modal,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
activateMetaSchemaVersionApi,
|
||||
@ -80,6 +104,30 @@ interface VersionHistoryRow {
|
||||
note: string;
|
||||
}
|
||||
|
||||
interface DraftTicketBinding {
|
||||
/** 当前票据所属 schemaKey,防止详情页切换后跨 schema 复用。 */
|
||||
schemaKey: string;
|
||||
/** 当前票据所属草稿版本。 */
|
||||
draftVersion?: number;
|
||||
/** 当前票据发布出的版本;激活只能引用刚发布的版本。 */
|
||||
publishedVersion?: number;
|
||||
/** 当前草稿校验结果票据。 */
|
||||
validationResultId: string;
|
||||
/** 当前草稿影响预览票据。 */
|
||||
impactPreviewId: string;
|
||||
}
|
||||
|
||||
interface ValidationResultTicketCompat {
|
||||
/** 后端兼容返回的校验结果票据。 */
|
||||
validationResultId?: string;
|
||||
/** 后端兼容返回的校验票据别名。 */
|
||||
validationTicketId?: string;
|
||||
/** 后端兼容返回的校验结果别名。 */
|
||||
validationId?: string;
|
||||
/** 后端兼容返回的票据通用别名。 */
|
||||
ticketId?: string;
|
||||
}
|
||||
|
||||
/** 版本历史表格列。 */
|
||||
const versionColumns: TableColumnsType<VersionHistoryRow> = [
|
||||
{ dataIndex: 'version', minWidth: 100, title: '版本' },
|
||||
@ -105,6 +153,25 @@ const impactPreview = ref<MetaSchemaImpactPreview>();
|
||||
const publishResult = ref<MetaSchemaPublishResponse>();
|
||||
const draftVersion = ref<number>();
|
||||
const draftFieldsJson = ref('[]');
|
||||
const draftTicketBinding = reactive<DraftTicketBinding>({
|
||||
draftVersion: undefined,
|
||||
impactPreviewId: '',
|
||||
publishedVersion: undefined,
|
||||
schemaKey: '',
|
||||
validationResultId: '',
|
||||
});
|
||||
|
||||
/** 详情加载序号,用于避免快速切换路由时旧请求覆盖新页面状态。 */
|
||||
let detailLoadSeq = 0;
|
||||
|
||||
/** 草稿保存请求序号,用于避免旧保存响应回写新草稿版本。 */
|
||||
let saveDraftSeq = 0;
|
||||
|
||||
/** 草稿校验请求序号,用于避免旧请求回写新草稿票据。 */
|
||||
let validateDraftSeq = 0;
|
||||
|
||||
/** 影响预览请求序号,用于避免旧请求回写新草稿票据。 */
|
||||
let previewImpactSeq = 0;
|
||||
|
||||
/** 基础表单状态,schemaKey 在新建草稿时由管理员显式输入。 */
|
||||
const baseForm = reactive({
|
||||
@ -136,14 +203,34 @@ const grayForm = reactive({
|
||||
version: undefined as number | undefined,
|
||||
});
|
||||
|
||||
/** 获取 query 中第一个字符串值。 */
|
||||
function firstQueryValue(value: LocationQueryValue | LocationQueryValue[] | undefined) {
|
||||
/** 当前草稿绑定的票据信息,仅允许由校验和预览回填。 */
|
||||
const currentDraftTicketSnapshot = computed(() => ({
|
||||
impactPreviewId: draftTicketBinding.impactPreviewId,
|
||||
validationResultId: draftTicketBinding.validationResultId,
|
||||
}));
|
||||
|
||||
/** 获取路由参数或 query 中第一个字符串值。 */
|
||||
function firstRouteString(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? '';
|
||||
return typeof value[0] === 'string' ? value[0] : '';
|
||||
}
|
||||
return value ?? '';
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** 当前是否处于创建模式。 */
|
||||
const isCreateMode = computed(() => firstRouteString(route.query.mode) === 'create');
|
||||
|
||||
/**
|
||||
* 当前路由对应的 schemaKey。
|
||||
* 创建模式只允许从 query.schemaKey 预填,避免把路由占位参数误当成真实 schemaKey。
|
||||
*/
|
||||
const routeSchemaKey = computed(() => {
|
||||
if (isCreateMode.value) {
|
||||
return firstRouteString(route.query.schemaKey);
|
||||
}
|
||||
return firstRouteString(route.query.schemaKey) || firstRouteString(route.params.schemaKey);
|
||||
});
|
||||
|
||||
/** 生成命令幂等键。 */
|
||||
function createCommandId(action: string) {
|
||||
return `muse-admin-meta-schema-${action}-${Date.now()}`;
|
||||
@ -171,6 +258,17 @@ function toStringArray(value: unknown) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 兼容后端校验结果返回的票据字段,不改 OpenAPI 合同,只做窄类型读取。 */
|
||||
function readValidationResultId(result: ValidationResult & ValidationResultTicketCompat) {
|
||||
return (
|
||||
result.validationResultId ??
|
||||
result.validationTicketId ??
|
||||
result.validationId ??
|
||||
result.ticketId ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/** 从对象中解析布尔值,非布尔值不提交。 */
|
||||
function toOptionalBoolean(value: unknown) {
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
@ -253,8 +351,32 @@ function splitWhitelist(text: string) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 当前可用 schemaKey。 */
|
||||
const currentSchemaKey = computed(() => baseForm.schemaKey.trim());
|
||||
/** 重置发布表单,避免 route/schema 切换后旧发布理由和票据残留。 */
|
||||
function resetPublishForm() {
|
||||
publishForm.expectedVersion = undefined;
|
||||
publishForm.impactPreviewId = '';
|
||||
publishForm.reason = '';
|
||||
publishForm.validationResultId = '';
|
||||
}
|
||||
|
||||
/** 重置激活与灰度表单,避免旧策略、百分比和白名单跨 schema 泄漏。 */
|
||||
function resetGrayForm() {
|
||||
grayForm.activateMode = 'full';
|
||||
grayForm.commandReason = '';
|
||||
grayForm.expectedActiveVersion = undefined;
|
||||
grayForm.impactPreviewId = '';
|
||||
grayForm.percentage = 0;
|
||||
grayForm.strategy = 'percentage';
|
||||
grayForm.tenantWhitelistText = '';
|
||||
grayForm.userWhitelistText = '';
|
||||
grayForm.validationResultId = '';
|
||||
grayForm.version = undefined;
|
||||
}
|
||||
|
||||
/** 当前可用 schemaKey。创建模式只接受管理员当前输入,不吃路由占位参数。 */
|
||||
const currentSchemaKey = computed(() =>
|
||||
isCreateMode.value ? baseForm.schemaKey.trim() : routeSchemaKey.value || baseForm.schemaKey.trim(),
|
||||
);
|
||||
|
||||
/** active 字段列表。 */
|
||||
const activeFields = computed(() => detail.value?.fields ?? []);
|
||||
@ -284,6 +406,87 @@ const versionRows = computed<VersionHistoryRow[]>(() => {
|
||||
return rows;
|
||||
});
|
||||
|
||||
/** 是否已经拿到当前草稿的发布/激活票据。 */
|
||||
const hasCurrentDraftTicketBinding = computed(() => {
|
||||
return (
|
||||
draftTicketBinding.schemaKey === currentSchemaKey.value &&
|
||||
draftVersion.value !== undefined &&
|
||||
draftTicketBinding.draftVersion === draftVersion.value &&
|
||||
Boolean(draftTicketBinding.validationResultId) &&
|
||||
Boolean(draftTicketBinding.impactPreviewId)
|
||||
);
|
||||
});
|
||||
|
||||
/** 清掉旧草稿相关的票据,防止旧票据误发到新草稿。 */
|
||||
function clearDraftTicketBinding() {
|
||||
draftTicketBinding.schemaKey = '';
|
||||
draftTicketBinding.draftVersion = undefined;
|
||||
draftTicketBinding.publishedVersion = undefined;
|
||||
draftTicketBinding.validationResultId = '';
|
||||
draftTicketBinding.impactPreviewId = '';
|
||||
impactPreview.value = undefined;
|
||||
}
|
||||
|
||||
/** 当前草稿重新保存后,旧票据全部失效,只保留新的草稿版本号。 */
|
||||
function resetCurrentDraftCommandTickets(nextDraftVersion?: number) {
|
||||
draftTicketBinding.schemaKey = currentSchemaKey.value;
|
||||
draftTicketBinding.draftVersion = nextDraftVersion;
|
||||
draftTicketBinding.publishedVersion = undefined;
|
||||
draftTicketBinding.validationResultId = '';
|
||||
draftTicketBinding.impactPreviewId = '';
|
||||
syncCommandTicketForms();
|
||||
impactPreview.value = undefined;
|
||||
}
|
||||
|
||||
/** 将当前草稿票据回填到发布和激活表单。 */
|
||||
function syncCommandTicketForms() {
|
||||
publishForm.validationResultId = draftTicketBinding.validationResultId;
|
||||
publishForm.impactPreviewId = draftTicketBinding.impactPreviewId;
|
||||
grayForm.validationResultId = draftTicketBinding.validationResultId;
|
||||
grayForm.impactPreviewId = draftTicketBinding.impactPreviewId;
|
||||
}
|
||||
|
||||
/** 切换到新路由时,清空上一页的工作区状态。 */
|
||||
function resetWorkspaceState() {
|
||||
saveDraftSeq += 1;
|
||||
validateDraftSeq += 1;
|
||||
previewImpactSeq += 1;
|
||||
loading.value = false;
|
||||
saving.value = false;
|
||||
validating.value = false;
|
||||
previewing.value = false;
|
||||
publishing.value = false;
|
||||
activating.value = false;
|
||||
graySaving.value = false;
|
||||
publishDrawerOpen.value = false;
|
||||
grayDrawerOpen.value = false;
|
||||
detail.value = undefined;
|
||||
selectedVersion.value = undefined;
|
||||
validationResult.value = undefined;
|
||||
impactPreview.value = undefined;
|
||||
publishResult.value = undefined;
|
||||
draftVersion.value = undefined;
|
||||
draftFieldsJson.value = '[]';
|
||||
baseForm.description = '';
|
||||
baseForm.displayName = '';
|
||||
baseForm.expectedVersion = undefined;
|
||||
baseForm.schemaKey = '';
|
||||
resetPublishForm();
|
||||
resetGrayForm();
|
||||
clearDraftTicketBinding();
|
||||
}
|
||||
|
||||
/** 选择当前草稿绑定是否有效,不有效时提示管理员先校验并生成预览。 */
|
||||
function ensureCurrentDraftTicketBinding(actionLabel: string) {
|
||||
if (!hasCurrentDraftTicketBinding.value) {
|
||||
message.warning(`请先完成当前草稿的校验和影响预览,再执行${actionLabel}`);
|
||||
return false;
|
||||
}
|
||||
/** 当前草稿已有票据时,再次执行前把表单回填到同一组票据,避免人工改串。 */
|
||||
syncCommandTicketForms();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 同步详情数据到编辑表单。 */
|
||||
function syncDetailToForm(data: MetaSchemaDetail) {
|
||||
baseForm.schemaKey = data.schemaKey;
|
||||
@ -297,18 +500,31 @@ function syncDetailToForm(data: MetaSchemaDetail) {
|
||||
|
||||
/** 加载 MetaSchema 详情。 */
|
||||
async function loadDetail() {
|
||||
const schemaKey = firstQueryValue(route.query.schemaKey);
|
||||
const requestSeq = ++detailLoadSeq;
|
||||
resetWorkspaceState();
|
||||
if (isCreateMode.value) {
|
||||
const seedSchemaKey = firstRouteString(route.query.schemaKey);
|
||||
if (seedSchemaKey) {
|
||||
baseForm.schemaKey = seedSchemaKey;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const schemaKey = routeSchemaKey.value;
|
||||
if (!schemaKey) {
|
||||
draftFieldsJson.value = '[]';
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getMetaSchemaApi(schemaKey);
|
||||
if (requestSeq !== detailLoadSeq) {
|
||||
return;
|
||||
}
|
||||
detail.value = data;
|
||||
syncDetailToForm(data);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (requestSeq === detailLoadSeq) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -336,6 +552,8 @@ async function handleSaveDraft() {
|
||||
message.warning('请先填写 schemaKey');
|
||||
return;
|
||||
}
|
||||
const requestSeq = ++saveDraftSeq;
|
||||
const requestSchemaKey = currentSchemaKey.value;
|
||||
let fields: MetaField[];
|
||||
try {
|
||||
fields = parseDraftFieldsJson();
|
||||
@ -345,18 +563,33 @@ async function handleSaveDraft() {
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const response = await saveMetaSchemaDraftApi(currentSchemaKey.value, {
|
||||
const response = await saveMetaSchemaDraftApi(requestSchemaKey, {
|
||||
commandId: createCommandId('save-draft'),
|
||||
description: baseForm.description,
|
||||
displayName: baseForm.displayName,
|
||||
expectedVersion: baseForm.expectedVersion,
|
||||
fields,
|
||||
});
|
||||
if (
|
||||
requestSeq !== saveDraftSeq ||
|
||||
requestSchemaKey !== currentSchemaKey.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
draftVersion.value = response.draftVersion;
|
||||
validationResult.value = response.validationSummary;
|
||||
resetCurrentDraftCommandTickets(response.draftVersion);
|
||||
if (response.validationSummary) {
|
||||
draftTicketBinding.validationResultId = readValidationResultId(
|
||||
response.validationSummary as ValidationResult & ValidationResultTicketCompat,
|
||||
);
|
||||
syncCommandTicketForms();
|
||||
}
|
||||
message.success('草稿已保存');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
if (requestSeq === saveDraftSeq) {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -366,15 +599,32 @@ async function handleValidateDraft() {
|
||||
message.warning('请先保存草稿获取 draftVersion');
|
||||
return;
|
||||
}
|
||||
const requestSeq = ++validateDraftSeq;
|
||||
const requestSchemaKey = currentSchemaKey.value;
|
||||
const requestDraftVersion = draftVersion.value;
|
||||
validating.value = true;
|
||||
try {
|
||||
validationResult.value = await validateMetaSchemaDraftApi(
|
||||
currentSchemaKey.value,
|
||||
draftVersion.value,
|
||||
);
|
||||
const result = (await validateMetaSchemaDraftApi(
|
||||
requestSchemaKey,
|
||||
requestDraftVersion,
|
||||
)) as ValidationResult & ValidationResultTicketCompat;
|
||||
if (
|
||||
requestSeq !== validateDraftSeq ||
|
||||
requestSchemaKey !== currentSchemaKey.value ||
|
||||
requestDraftVersion !== draftVersion.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
validationResult.value = result;
|
||||
draftTicketBinding.schemaKey = requestSchemaKey;
|
||||
draftTicketBinding.draftVersion = requestDraftVersion;
|
||||
draftTicketBinding.validationResultId = readValidationResultId(result);
|
||||
syncCommandTicketForms();
|
||||
message.success(validationResult.value.valid ? '校验通过' : '校验完成,请查看问题');
|
||||
} finally {
|
||||
validating.value = false;
|
||||
if (requestSeq === validateDraftSeq) {
|
||||
validating.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -384,24 +634,41 @@ async function handlePreviewImpact() {
|
||||
message.warning('请先保存草稿获取 draftVersion');
|
||||
return;
|
||||
}
|
||||
const requestSeq = ++previewImpactSeq;
|
||||
const requestSchemaKey = currentSchemaKey.value;
|
||||
const requestDraftVersion = draftVersion.value;
|
||||
previewing.value = true;
|
||||
try {
|
||||
impactPreview.value = await previewMetaSchemaDraftImpactApi(
|
||||
currentSchemaKey.value,
|
||||
draftVersion.value,
|
||||
const result = await previewMetaSchemaDraftImpactApi(
|
||||
requestSchemaKey,
|
||||
requestDraftVersion,
|
||||
);
|
||||
publishForm.impactPreviewId = impactPreview.value.impactPreviewId ?? '';
|
||||
grayForm.impactPreviewId = impactPreview.value.impactPreviewId ?? '';
|
||||
if (
|
||||
requestSeq !== previewImpactSeq ||
|
||||
requestSchemaKey !== currentSchemaKey.value ||
|
||||
requestDraftVersion !== draftVersion.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
impactPreview.value = result;
|
||||
draftTicketBinding.schemaKey = requestSchemaKey;
|
||||
draftTicketBinding.draftVersion = requestDraftVersion;
|
||||
draftTicketBinding.impactPreviewId = result.impactPreviewId ?? '';
|
||||
syncCommandTicketForms();
|
||||
message.success('影响预览已生成');
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
if (requestSeq === previewImpactSeq) {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开发版确认抽屉。 */
|
||||
function openPublishDrawer() {
|
||||
if (!ensureCurrentDraftTicketBinding('发布')) {
|
||||
return;
|
||||
}
|
||||
publishForm.expectedVersion = baseForm.expectedVersion;
|
||||
publishForm.impactPreviewId = impactPreview.value?.impactPreviewId ?? '';
|
||||
publishDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
@ -411,6 +678,9 @@ async function handlePublishDraft() {
|
||||
message.warning('请先保存草稿获取 draftVersion');
|
||||
return;
|
||||
}
|
||||
if (!ensureCurrentDraftTicketBinding('发布')) {
|
||||
return;
|
||||
}
|
||||
if (!publishForm.reason || !publishForm.validationResultId || !publishForm.impactPreviewId) {
|
||||
message.warning('请填写发布理由、校验结果 ID 和影响预览 ID');
|
||||
return;
|
||||
@ -429,6 +699,7 @@ async function handlePublishDraft() {
|
||||
validationResultId: publishForm.validationResultId,
|
||||
},
|
||||
);
|
||||
draftTicketBinding.publishedVersion = publishResult.value.publishedVersion;
|
||||
publishDrawerOpen.value = false;
|
||||
message.success('元结构版本已发布,仍需单独激活或灰度');
|
||||
} finally {
|
||||
@ -438,10 +709,15 @@ async function handlePublishDraft() {
|
||||
|
||||
/** 打开激活和灰度规则抽屉。 */
|
||||
function openGrayDrawer() {
|
||||
grayForm.version =
|
||||
publishResult.value?.publishedVersion ?? detail.value?.grayVersion ?? detail.value?.activeVersion;
|
||||
if (!ensureCurrentDraftTicketBinding('激活')) {
|
||||
return;
|
||||
}
|
||||
if (publishResult.value?.publishedVersion === undefined) {
|
||||
message.warning('请先发布当前草稿,再激活刚发布的版本');
|
||||
return;
|
||||
}
|
||||
grayForm.version = publishResult.value.publishedVersion;
|
||||
grayForm.expectedActiveVersion = detail.value?.activeVersion ?? baseForm.expectedVersion;
|
||||
grayForm.impactPreviewId = impactPreview.value?.impactPreviewId ?? '';
|
||||
grayDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
@ -467,6 +743,17 @@ async function handleActivateVersion() {
|
||||
message.warning('请先选择要激活的版本');
|
||||
return;
|
||||
}
|
||||
if (!ensureCurrentDraftTicketBinding('激活')) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
publishResult.value?.publishedVersion === undefined ||
|
||||
grayForm.version !== publishResult.value.publishedVersion ||
|
||||
draftTicketBinding.publishedVersion !== publishResult.value.publishedVersion
|
||||
) {
|
||||
message.warning('请先发布当前草稿,并只激活该草稿刚发布出的版本');
|
||||
return;
|
||||
}
|
||||
if (!grayForm.commandReason || !grayForm.validationResultId || !grayForm.impactPreviewId) {
|
||||
message.warning('请填写激活理由、校验结果 ID 和影响预览 ID');
|
||||
return;
|
||||
@ -524,7 +811,13 @@ function confirmPublishDraft() {
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(loadDetail);
|
||||
watch(
|
||||
() => [route.query.mode, route.query.schemaKey, route.params.schemaKey],
|
||||
async () => {
|
||||
await loadDetail();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -729,10 +1022,14 @@ onMounted(loadDetail);
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="validationResultId">
|
||||
<a-input v-model:value="publishForm.validationResultId" />
|
||||
<a-input v-model:value="publishForm.validationResultId" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="impactPreviewId">
|
||||
<a-input v-model:value="publishForm.impactPreviewId" />
|
||||
<a-input v-model:value="publishForm.impactPreviewId" readonly />
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
当前绑定:{{ currentDraftTicketSnapshot.validationResultId || '-' }} /
|
||||
{{ currentDraftTicketSnapshot.impactPreviewId || '-' }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="expectedVersion">
|
||||
<a-input-number
|
||||
@ -793,10 +1090,14 @@ onMounted(loadDetail);
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="validationResultId">
|
||||
<a-input v-model:value="grayForm.validationResultId" />
|
||||
<a-input v-model:value="grayForm.validationResultId" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="impactPreviewId">
|
||||
<a-input v-model:value="grayForm.impactPreviewId" />
|
||||
<a-input v-model:value="grayForm.impactPreviewId" readonly />
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
当前绑定:{{ currentDraftTicketSnapshot.validationResultId || '-' }} /
|
||||
{{ currentDraftTicketSnapshot.impactPreviewId || '-' }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="expectedActiveVersion">
|
||||
<a-input-number
|
||||
|
||||
@ -126,15 +126,14 @@ function formatImpactSummary(row: MetaSchemaSummary) {
|
||||
/** 进入详情页。 */
|
||||
function handleViewDetail(row: MetaSchemaSummary) {
|
||||
router.push({
|
||||
path: '/muse/governance/meta-schema/detail',
|
||||
query: { schemaKey: row.schemaKey },
|
||||
path: `/muse/governance/meta-schemas/${encodeURIComponent(row.schemaKey)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 新建草稿入口,详情页负责填写 schemaKey 和字段 JSON。 */
|
||||
function handleCreateDraft() {
|
||||
router.push({
|
||||
path: '/muse/governance/meta-schema/detail',
|
||||
path: '/muse/governance/meta-schemas/__create__',
|
||||
query: { mode: 'create' },
|
||||
});
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import type {
|
||||
JobDetail,
|
||||
JobStatus,
|
||||
JobSummary,
|
||||
SourceActionPolicy,
|
||||
SourceEventDetail,
|
||||
SourceEventSummary,
|
||||
SourceStatus,
|
||||
@ -116,6 +117,13 @@ const sourceStatusColor: Record<SourceStatus, string> = {
|
||||
unauthorized: 'red',
|
||||
};
|
||||
|
||||
const ACTION_POLICY_TEXT: Record<SourceActionPolicy, string> = {
|
||||
allowed: '允许',
|
||||
blocked: '阻断',
|
||||
needs_recheck: '需重验',
|
||||
read_only: '只读',
|
||||
};
|
||||
|
||||
/** 将 Ant Table 通用记录转回任务摘要。 */
|
||||
function asJobSummary(record: TableRecord) {
|
||||
return record as unknown as JobSummary;
|
||||
@ -141,9 +149,42 @@ function getSourceStatusColor(status: unknown) {
|
||||
return sourceStatusColor[status as SourceStatus] ?? 'default';
|
||||
}
|
||||
|
||||
/** 判断任务是否已完成,用于禁用会污染事实的操作。 */
|
||||
function isCompletedStatus(status: unknown) {
|
||||
return status === 'completed';
|
||||
/** 列表合同只有 status;是否 retryable 必须点击时拉详情确认。 */
|
||||
function canOpenRetryJob(record: Pick<JobSummary, 'status'>) {
|
||||
return record.status === 'failed';
|
||||
}
|
||||
|
||||
/** 取消只适用于还未进入终态的排队或运行任务。 */
|
||||
function canCancelJob(record: Pick<JobSummary, 'status'>) {
|
||||
return record.status === 'queued' || record.status === 'running';
|
||||
}
|
||||
|
||||
/** 独立重验当前没有后端写接口,只在非终态任务上开放 UI 留痕。 */
|
||||
function canRecheckJob(record: Pick<JobSummary, 'status'>) {
|
||||
return record.status === 'failed' || record.status === 'running';
|
||||
}
|
||||
|
||||
/** 来源事件传播重试必须由 actionPolicy 明确要求重验。 */
|
||||
function canRetrySourceEvent(record: Pick<SourceEventSummary, 'actionPolicy'>) {
|
||||
return record.actionPolicy === 'needs_recheck';
|
||||
}
|
||||
|
||||
function findCurrentJob(jobId: number | string) {
|
||||
return jobs.value.find((item) => String(item.jobId) === String(jobId));
|
||||
}
|
||||
|
||||
function getActionPolicyText(actionPolicy?: SourceActionPolicy) {
|
||||
return actionPolicy ? ACTION_POLICY_TEXT[actionPolicy] : '-';
|
||||
}
|
||||
|
||||
/** 重试前拉取详情合同中的 retryable,列表摘要不承担重试授权判断。 */
|
||||
async function ensureRetryableJob(jobId: number | string) {
|
||||
const detail = await getJobApi(jobId);
|
||||
if (detail.status !== 'failed' || detail.retryable !== true) {
|
||||
message.error('当前任务详情不允许重试');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const jobColumns = [
|
||||
@ -303,7 +344,22 @@ function handleSourceTableChange(pager: TablePager) {
|
||||
}
|
||||
|
||||
/** 打开任务操作弹窗。 */
|
||||
function openJobAction(record: JobSummary, action: JobAction) {
|
||||
async function openJobAction(record: JobSummary, action: JobAction) {
|
||||
if (action === 'retry' && !canOpenRetryJob(record)) {
|
||||
message.error('当前任务状态不允许重试');
|
||||
return;
|
||||
}
|
||||
if (action === 'cancel' && !canCancelJob(record)) {
|
||||
message.error('当前任务状态不允许取消');
|
||||
return;
|
||||
}
|
||||
if (action === 'recheck' && !canRecheckJob(record)) {
|
||||
message.error('当前任务状态不需要重验');
|
||||
return;
|
||||
}
|
||||
if (action === 'retry' && !(await ensureRetryableJob(record.jobId))) {
|
||||
return;
|
||||
}
|
||||
jobActionForm.action = action;
|
||||
jobActionForm.authorizationChecked = false;
|
||||
jobActionForm.commandId = createCommandId(`job-${action}`);
|
||||
@ -322,7 +378,7 @@ function handleOpenJobDetail(record: TableRecord) {
|
||||
|
||||
/** 表格事件包装:打开任务操作弹窗。 */
|
||||
function handleOpenJobAction(record: TableRecord, action: JobAction) {
|
||||
openJobAction(asJobSummary(record), action);
|
||||
void openJobAction(asJobSummary(record), action);
|
||||
}
|
||||
|
||||
/** 表格事件包装:打开来源事件详情。 */
|
||||
@ -337,6 +393,21 @@ function handleOpenSourceRetry(record: TableRecord) {
|
||||
|
||||
/** 提交任务重验、重试或取消。 */
|
||||
async function submitJobAction() {
|
||||
const currentJob = findCurrentJob(jobActionForm.jobId);
|
||||
if (
|
||||
jobActionForm.action === 'retry' &&
|
||||
(!currentJob || !canOpenRetryJob(currentJob))
|
||||
) {
|
||||
message.error('当前任务状态不允许重试');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
jobActionForm.action === 'cancel' &&
|
||||
(!currentJob || !canCancelJob(currentJob))
|
||||
) {
|
||||
message.error('当前任务状态不允许取消');
|
||||
return;
|
||||
}
|
||||
if (!jobActionForm.reason.trim()) {
|
||||
message.error('请填写操作原因');
|
||||
return;
|
||||
@ -361,9 +432,12 @@ async function submitJobAction() {
|
||||
commandSubmitting.value = true;
|
||||
try {
|
||||
if (jobActionForm.action === 'retry') {
|
||||
if (!(await ensureRetryableJob(jobActionForm.jobId))) {
|
||||
return;
|
||||
}
|
||||
// retry OpenAPI 当前只接收 commandId;reason 仅作为 UI 人工复核留痕,不外发。
|
||||
await retryJobApi(jobActionForm.jobId, {
|
||||
commandId: jobActionForm.commandId,
|
||||
reason: jobActionForm.reason,
|
||||
});
|
||||
message.success('任务重试命令已提交');
|
||||
} else {
|
||||
@ -382,6 +456,10 @@ async function submitJobAction() {
|
||||
|
||||
/** 打开来源传播重试弹窗。 */
|
||||
function openSourceRetry(record: SourceEventSummary) {
|
||||
if (!canRetrySourceEvent(record)) {
|
||||
message.error('当前来源事件动作策略不允许重试传播');
|
||||
return;
|
||||
}
|
||||
sourceRetryForm.commandId = createCommandId('source-retry');
|
||||
sourceRetryForm.eventId = record.eventId;
|
||||
sourceRetryForm.reason = '';
|
||||
@ -471,13 +549,14 @@ onMounted(() => {
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canRecheckJob(asJobSummary(record))"
|
||||
type="link"
|
||||
@click="handleOpenJobAction(record, 'recheck')"
|
||||
>
|
||||
重验
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="isCompletedStatus(record.status)"
|
||||
:disabled="!canOpenRetryJob(asJobSummary(record))"
|
||||
type="link"
|
||||
@click="handleOpenJobAction(record, 'retry')"
|
||||
>
|
||||
@ -485,7 +564,7 @@ onMounted(() => {
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
:disabled="isCompletedStatus(record.status)"
|
||||
:disabled="!canCancelJob(asJobSummary(record))"
|
||||
type="link"
|
||||
@click="handleOpenJobAction(record, 'cancel')"
|
||||
>
|
||||
@ -550,7 +629,13 @@ onMounted(() => {
|
||||
<Button type="link" @click="handleOpenSourceDetail(record)">
|
||||
详情
|
||||
</Button>
|
||||
<Button type="link" @click="handleOpenSourceRetry(record)">
|
||||
<Button
|
||||
:disabled="
|
||||
!canRetrySourceEvent(asSourceEventSummary(record))
|
||||
"
|
||||
type="link"
|
||||
@click="handleOpenSourceRetry(record)"
|
||||
>
|
||||
重试传播
|
||||
</Button>
|
||||
</Space>
|
||||
@ -622,7 +707,7 @@ onMounted(() => {
|
||||
{{ selectedSourceEvent.sourceId }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="动作策略">
|
||||
{{ selectedSourceEvent.actionPolicy }}
|
||||
{{ getActionPolicyText(selectedSourceEvent.actionPolicy) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="传播状态">
|
||||
{{ displayText(selectedSourceEvent.propagationStatus) }}
|
||||
|
||||
@ -126,6 +126,7 @@ describe('muse market admin view', () => {
|
||||
expect(root.textContent).toContain('审核队列');
|
||||
expect(root.textContent).toContain('世界观知识库');
|
||||
expect(root.textContent).toContain('不修改用户私有副本');
|
||||
expect(root.textContent).not.toContain('完全下架');
|
||||
|
||||
app.unmount();
|
||||
root.remove();
|
||||
|
||||
@ -111,29 +111,75 @@ const appealColumns = [
|
||||
];
|
||||
|
||||
/** 当前治理动作目标,避免把状态字段散落在多个 ref 中。 */
|
||||
type ExpectedAppealStatus = Extract<
|
||||
MarketAppealStatus,
|
||||
'pending' | 'reviewing' | 'supplementing'
|
||||
>;
|
||||
type ExpectedListingStatus = Extract<MarketListingStatus, 'delisted' | 'listed'>;
|
||||
type ExpectedReviewStatus = Extract<
|
||||
MarketReviewStatus,
|
||||
'needs_supplement' | 'pending' | 'reviewing'
|
||||
>;
|
||||
type DelistSubmissionScope = Exclude<
|
||||
MarketGovernanceScope,
|
||||
'full_delist' | 'full_recall'
|
||||
>;
|
||||
type PreviewSubmissionScope = Exclude<
|
||||
MarketGovernanceScope,
|
||||
'full_delist'
|
||||
>;
|
||||
type ScopeOption = {
|
||||
/** 范围展示名称。 */
|
||||
label: string;
|
||||
/** 影响预览 OpenAPI 当前允许的治理范围。 */
|
||||
value: PreviewSubmissionScope;
|
||||
};
|
||||
|
||||
const DELIST_SCOPE_OPTIONS: ScopeOption[] = [
|
||||
{ label: '停止新获取', value: 'stop_new_acquire' },
|
||||
{ label: '停止新安装', value: 'stop_new_install' },
|
||||
{ label: '停止新绑定', value: 'stop_new_bind' },
|
||||
{ label: '停止生成使用', value: 'stop_generation' },
|
||||
];
|
||||
|
||||
const RECALL_SCOPE_OPTIONS: ScopeOption[] = [
|
||||
{ label: '停止生成使用', value: 'stop_generation' },
|
||||
{ label: '完全召回', value: 'full_recall' },
|
||||
];
|
||||
|
||||
interface MarketActionTarget {
|
||||
/** 申诉 ID。 */
|
||||
appealId?: string;
|
||||
/** 资产 ID。 */
|
||||
assetId?: string;
|
||||
/** 期望申诉状态。 */
|
||||
expectedAppealStatus?: Extract<
|
||||
MarketAppealStatus,
|
||||
'pending' | 'reviewing' | 'supplementing'
|
||||
>;
|
||||
expectedAppealStatus?: ExpectedAppealStatus;
|
||||
/** 期望上架状态。 */
|
||||
expectedListingStatus?: Extract<MarketListingStatus, 'delisted' | 'listed'>;
|
||||
expectedListingStatus?: ExpectedListingStatus;
|
||||
/** 期望审核状态。 */
|
||||
expectedReviewStatus?: Extract<
|
||||
MarketReviewStatus,
|
||||
'needs_supplement' | 'pending' | 'reviewing'
|
||||
>;
|
||||
expectedReviewStatus?: ExpectedReviewStatus;
|
||||
/** 发布申请 ID。 */
|
||||
requestId?: string;
|
||||
/** 展示名称。 */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/** 影响预览票据绑定信息,本地校验可避免跨资产或跨申诉串用票据。 */
|
||||
interface ImpactPreviewBinding {
|
||||
/** 申诉 ID,只有申诉处理场景需要绑定。 */
|
||||
appealId?: string;
|
||||
/** 资产 ID。 */
|
||||
assetId: string;
|
||||
/** 生成该票据时的动作弹窗上下文代次。 */
|
||||
generation: number;
|
||||
/** 预览动作。 */
|
||||
actionType: MarketGovernanceActionType;
|
||||
/** 预览 ID。 */
|
||||
previewId: string;
|
||||
/** 预览范围。 */
|
||||
scope: PreviewSubmissionScope;
|
||||
}
|
||||
|
||||
/** 市场页面加载态。 */
|
||||
const loading = ref(false);
|
||||
/** 发布审核队列。 */
|
||||
@ -144,8 +190,16 @@ const marketAssets = ref<AdminMarketAssetSummaryVO[]>([]);
|
||||
const appeals = ref<AdminAppealSummaryVO[]>([]);
|
||||
/** 最近一次治理影响预览。 */
|
||||
const impactPreview = ref<AdminGovernanceImpactPreviewVO>();
|
||||
/** 最近一次治理影响预览的绑定元数据。 */
|
||||
const impactPreviewBinding = ref<ImpactPreviewBinding>();
|
||||
/** 动作弹窗是否打开。 */
|
||||
const actionModalOpen = ref(false);
|
||||
/** 当前影响预览请求加载态。 */
|
||||
const previewing = ref(false);
|
||||
/** 影响预览请求序号,只允许最新请求回写 loading 状态。 */
|
||||
const previewRequestSeq = ref(0);
|
||||
/** 动作弹窗上下文代次,打开、切换动作或切换范围都会让旧预览响应失效。 */
|
||||
const actionGeneration = ref(0);
|
||||
/** 当前动作。 */
|
||||
const currentAction = ref<
|
||||
'approve' | 'delist' | 'preview' | 'recall' | 'reject' | 'resolve'
|
||||
@ -184,11 +238,23 @@ const summary = computed(() => {
|
||||
return { blockedAssets, listedAssets, openAppeals, pendingReview };
|
||||
});
|
||||
|
||||
/** 当前动作可选范围按最终命令合同收口,避免预览范围和提交范围漂移。 */
|
||||
const scopeOptions = computed(() => {
|
||||
return currentAction.value === 'recall' || actionForm.actionType === 'recall'
|
||||
? RECALL_SCOPE_OPTIONS
|
||||
: DELIST_SCOPE_OPTIONS;
|
||||
});
|
||||
|
||||
/** 生成幂等命令 ID。 */
|
||||
function createCommandId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`;
|
||||
}
|
||||
|
||||
/** 推进动作上下文代次,用于丢弃旧弹窗或旧动作的异步预览响应。 */
|
||||
function advanceActionGeneration() {
|
||||
actionGeneration.value += 1;
|
||||
}
|
||||
|
||||
/** 把逗号分隔的证据输入转成后端需要的标签数组。 */
|
||||
function splitEvidenceTags(value: string) {
|
||||
return value
|
||||
@ -197,6 +263,166 @@ function splitEvidenceTags(value: string) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 清空当前动作的预览票据绑定,防止改动作或改范围后继续复用旧票据。 */
|
||||
function clearActionPreviewBinding() {
|
||||
actionForm.impactPreviewId = '';
|
||||
impactPreviewBinding.value = undefined;
|
||||
}
|
||||
|
||||
/** 按当前动作类型归一化范围选项,确保最终提交枚举和预览枚举一致。 */
|
||||
function normalizeActionScope() {
|
||||
if (!scopeOptions.value.some((option) => option.value === actionForm.scope)) {
|
||||
actionForm.scope = scopeOptions.value[0]?.value ?? 'stop_new_acquire';
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换治理动作后,旧预览票据立即失效。 */
|
||||
function handleActionTypeChange(value: MarketGovernanceActionType) {
|
||||
advanceActionGeneration();
|
||||
actionForm.actionType = value;
|
||||
normalizeActionScope();
|
||||
clearActionPreviewBinding();
|
||||
}
|
||||
|
||||
/** 切换治理范围后,旧预览票据立即失效。 */
|
||||
function handleScopeChange(value: MarketGovernanceScope) {
|
||||
advanceActionGeneration();
|
||||
actionForm.scope = value;
|
||||
clearActionPreviewBinding();
|
||||
}
|
||||
|
||||
/** 根据申诉类型推导预览动作,申诉处理也必须生成与申诉绑定的影响预览。 */
|
||||
function resolveAppealPreviewAction(row: AdminAppealSummaryVO) {
|
||||
if (row.appealType === 'recall') {
|
||||
return 'recall';
|
||||
}
|
||||
if (row.appealType === 'license_revocation') {
|
||||
return 'revoke_license';
|
||||
}
|
||||
if (row.appealType === 'usage_impact') {
|
||||
return 'stop_generation';
|
||||
}
|
||||
if (row.appealType === 'review_rejection') {
|
||||
return 'review_rejection';
|
||||
}
|
||||
return 'delist';
|
||||
}
|
||||
|
||||
/** 审核动作只允许提交非终态发布申请,避免 expectedStatus 合同错配。 */
|
||||
function getExpectedReviewStatus(status: MarketReviewStatus) {
|
||||
if (
|
||||
status === 'needs_supplement' ||
|
||||
status === 'pending' ||
|
||||
status === 'reviewing'
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 下架只能针对已上架资产;其他终态或非上架态不构造非法 expectedStatus。 */
|
||||
function getExpectedDelistStatus(status: MarketListingStatus) {
|
||||
return status === 'listed' ? status : undefined;
|
||||
}
|
||||
|
||||
/** 召回只允许从已上架或已下架状态进入,其他状态交给服务端前先在前端拦截。 */
|
||||
function getExpectedRecallStatus(status: MarketListingStatus) {
|
||||
if (status === 'delisted' || status === 'listed') {
|
||||
return status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 申诉处理只允许开放状态,终态申诉不能提交旧 expectedStatus。 */
|
||||
function getExpectedAppealStatus(status: MarketAppealStatus) {
|
||||
if (
|
||||
status === 'pending' ||
|
||||
status === 'reviewing' ||
|
||||
status === 'supplementing'
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function canReviewRequest(row: AdminPublishRequestSummaryVO) {
|
||||
return Boolean(getExpectedReviewStatus(row.status));
|
||||
}
|
||||
|
||||
function canDelistAsset(row: AdminMarketAssetSummaryVO) {
|
||||
return Boolean(getExpectedDelistStatus(row.listingStatus));
|
||||
}
|
||||
|
||||
function canRecallAsset(row: AdminMarketAssetSummaryVO) {
|
||||
return Boolean(getExpectedRecallStatus(row.listingStatus));
|
||||
}
|
||||
|
||||
function canResolveAppeal(row: AdminAppealSummaryVO) {
|
||||
return Boolean(getExpectedAppealStatus(row.status));
|
||||
}
|
||||
|
||||
/** 预览接口不支持 full_delist,不能用其他范围票据替代完全下架票据。 */
|
||||
function toPreviewScope(scope: MarketGovernanceScope): PreviewSubmissionScope | undefined {
|
||||
return scope === 'full_delist' ? undefined : scope;
|
||||
}
|
||||
|
||||
/** 下架提交只允许使用已经按相同范围完成影响预览的安全下架范围。 */
|
||||
function toDelistScope(scope: MarketGovernanceScope): DelistSubmissionScope | undefined {
|
||||
if (scope === 'full_delist' || scope === 'full_recall') {
|
||||
return undefined;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** 资产直达治理动作不能借用其他影响动作,申诉处理仍按申诉类型单独映射。 */
|
||||
function getLockedAssetActionType() {
|
||||
if (currentAction.value === 'delist') {
|
||||
return 'delist';
|
||||
}
|
||||
if (currentAction.value === 'recall') {
|
||||
return 'recall';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 影响预览必须和当前资产、动作、范围以及申诉上下文一致。 */
|
||||
function validatePreviewBinding() {
|
||||
const binding = impactPreviewBinding.value;
|
||||
if (!binding || binding.previewId !== actionForm.impactPreviewId) {
|
||||
return false;
|
||||
}
|
||||
if (binding.generation !== actionGeneration.value || !actionModalOpen.value) {
|
||||
return false;
|
||||
}
|
||||
if (binding.assetId !== actionTarget.value.assetId) {
|
||||
return false;
|
||||
}
|
||||
if (currentAction.value === 'resolve') {
|
||||
const previewScope = toPreviewScope(actionForm.scope);
|
||||
return (
|
||||
!!previewScope &&
|
||||
binding.appealId === actionTarget.value.appealId &&
|
||||
binding.actionType === actionForm.actionType &&
|
||||
binding.scope === previewScope
|
||||
);
|
||||
}
|
||||
if (
|
||||
currentAction.value === 'delist' ||
|
||||
currentAction.value === 'recall'
|
||||
) {
|
||||
const lockedActionType = getLockedAssetActionType();
|
||||
const previewScope = toPreviewScope(actionForm.scope);
|
||||
return (
|
||||
!!previewScope &&
|
||||
!!lockedActionType &&
|
||||
actionForm.actionType === lockedActionType &&
|
||||
binding.actionType === lockedActionType &&
|
||||
binding.scope === previewScope
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 加载审核队列。 */
|
||||
async function loadReviewQueue() {
|
||||
const result = await getMarketPublishRequestListApi({
|
||||
@ -242,13 +468,20 @@ function openReviewAction(
|
||||
action: 'approve' | 'reject',
|
||||
row: AdminPublishRequestSummaryVO,
|
||||
) {
|
||||
const expectedReviewStatus = getExpectedReviewStatus(row.status);
|
||||
if (!expectedReviewStatus) {
|
||||
message.error('当前发布申请状态不能执行审核动作');
|
||||
return;
|
||||
}
|
||||
advanceActionGeneration();
|
||||
currentAction.value = action;
|
||||
actionTarget.value = {
|
||||
assetId: row.assetId,
|
||||
expectedReviewStatus: row.status as MarketActionTarget['expectedReviewStatus'],
|
||||
expectedReviewStatus,
|
||||
requestId: row.requestId,
|
||||
title: row.assetName,
|
||||
};
|
||||
clearActionPreviewBinding();
|
||||
actionForm.evidenceTags = '';
|
||||
actionForm.note = '';
|
||||
actionForm.reason = '';
|
||||
@ -261,15 +494,24 @@ function openAssetAction(
|
||||
action: 'delist' | 'preview' | 'recall',
|
||||
row: AdminMarketAssetSummaryVO,
|
||||
) {
|
||||
const expectedListingStatus =
|
||||
action === 'delist'
|
||||
? getExpectedDelistStatus(row.listingStatus)
|
||||
: getExpectedRecallStatus(row.listingStatus);
|
||||
if (action !== 'preview' && !expectedListingStatus) {
|
||||
message.error('当前资产状态不能执行该市场治理动作');
|
||||
return;
|
||||
}
|
||||
advanceActionGeneration();
|
||||
currentAction.value = action;
|
||||
actionTarget.value = {
|
||||
assetId: row.assetId,
|
||||
expectedListingStatus: row.listingStatus as MarketActionTarget['expectedListingStatus'],
|
||||
expectedListingStatus,
|
||||
title: row.name,
|
||||
};
|
||||
clearActionPreviewBinding();
|
||||
actionForm.actionType = action === 'recall' ? 'recall' : 'delist';
|
||||
actionForm.basis = '';
|
||||
actionForm.impactPreviewId = impactPreview.value?.previewId ?? '';
|
||||
actionForm.reason = '';
|
||||
actionForm.scope = action === 'recall' ? 'full_recall' : 'stop_new_acquire';
|
||||
actionModalOpen.value = true;
|
||||
@ -277,17 +519,26 @@ function openAssetAction(
|
||||
|
||||
/** 打开申诉处理动作。 */
|
||||
function openAppealAction(row: AdminAppealSummaryVO) {
|
||||
const expectedAppealStatus = getExpectedAppealStatus(row.status);
|
||||
if (!expectedAppealStatus) {
|
||||
message.error('当前申诉状态不能继续处理');
|
||||
return;
|
||||
}
|
||||
advanceActionGeneration();
|
||||
currentAction.value = 'resolve';
|
||||
actionTarget.value = {
|
||||
appealId: row.appealId,
|
||||
assetId: row.assetId,
|
||||
expectedAppealStatus: row.status as MarketActionTarget['expectedAppealStatus'],
|
||||
expectedAppealStatus,
|
||||
title: row.assetName,
|
||||
};
|
||||
clearActionPreviewBinding();
|
||||
actionForm.actionType = resolveAppealPreviewAction(row);
|
||||
actionForm.basis = '';
|
||||
actionForm.evidenceTags = '';
|
||||
actionForm.impactPreviewId = impactPreview.value?.previewId ?? '';
|
||||
actionForm.reason = '';
|
||||
actionForm.resolution = 'maintained';
|
||||
actionForm.scope = actionForm.actionType === 'recall' ? 'full_recall' : 'stop_new_acquire';
|
||||
actionModalOpen.value = true;
|
||||
}
|
||||
|
||||
@ -296,18 +547,58 @@ async function handlePreviewImpact() {
|
||||
if (!actionTarget.value.assetId) {
|
||||
return;
|
||||
}
|
||||
const previewScope =
|
||||
actionForm.scope === 'full_delist' ? 'stop_new_acquire' : actionForm.scope;
|
||||
impactPreview.value = await previewMarketGovernanceImpactApi(
|
||||
actionTarget.value.assetId,
|
||||
{
|
||||
actionType: actionForm.actionType,
|
||||
commandId: createCommandId('market-impact-preview'),
|
||||
scope: previewScope as Exclude<MarketGovernanceScope, 'full_delist'>,
|
||||
},
|
||||
);
|
||||
actionForm.impactPreviewId = impactPreview.value.previewId;
|
||||
message.success('市场治理影响预览已生成');
|
||||
normalizeActionScope();
|
||||
const requestedSeq = previewRequestSeq.value + 1;
|
||||
previewRequestSeq.value = requestedSeq;
|
||||
const requestedGeneration = actionGeneration.value;
|
||||
const requestedCurrentAction = currentAction.value;
|
||||
const requestedAssetId = actionTarget.value.assetId;
|
||||
const requestedAppealId = actionTarget.value.appealId;
|
||||
const requestedActionType = actionForm.actionType;
|
||||
const previewScope = toPreviewScope(actionForm.scope);
|
||||
if (!previewScope) {
|
||||
clearActionPreviewBinding();
|
||||
message.error('当前治理范围暂不支持影响预览,请重新选择范围');
|
||||
return;
|
||||
}
|
||||
previewing.value = true;
|
||||
try {
|
||||
const result = await previewMarketGovernanceImpactApi(
|
||||
requestedAssetId,
|
||||
{
|
||||
actionType: requestedActionType,
|
||||
commandId: createCommandId('market-impact-preview'),
|
||||
scope: previewScope,
|
||||
},
|
||||
);
|
||||
if (
|
||||
previewRequestSeq.value !== requestedSeq ||
|
||||
actionGeneration.value !== requestedGeneration ||
|
||||
currentAction.value !== requestedCurrentAction ||
|
||||
!actionModalOpen.value ||
|
||||
actionTarget.value.assetId !== requestedAssetId ||
|
||||
actionTarget.value.appealId !== requestedAppealId ||
|
||||
actionForm.actionType !== requestedActionType ||
|
||||
toPreviewScope(actionForm.scope) !== previewScope
|
||||
) {
|
||||
return;
|
||||
}
|
||||
impactPreview.value = result;
|
||||
impactPreviewBinding.value = {
|
||||
appealId: requestedAppealId,
|
||||
assetId: requestedAssetId,
|
||||
generation: requestedGeneration,
|
||||
actionType: requestedActionType,
|
||||
previewId: result.previewId,
|
||||
scope: previewScope,
|
||||
};
|
||||
actionForm.impactPreviewId = result.previewId;
|
||||
message.success('市场治理影响预览已生成');
|
||||
} finally {
|
||||
if (previewRequestSeq.value === requestedSeq) {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交审核、治理或申诉动作。 */
|
||||
@ -326,11 +617,20 @@ async function handleSubmitAction() {
|
||||
message.error('审核通过需要校验结果引用');
|
||||
return;
|
||||
}
|
||||
if (!actionTarget.value.expectedReviewStatus) {
|
||||
message.error('当前发布申请状态不能审核通过');
|
||||
return;
|
||||
}
|
||||
if (!actionForm.note.trim()) {
|
||||
message.error('审核通过必须填写可审计原因');
|
||||
return;
|
||||
}
|
||||
await approveMarketPublishRequestApi(actionTarget.value.requestId, {
|
||||
commandId: createCommandId('market-review-approve'),
|
||||
evidenceTags: splitEvidenceTags(actionForm.evidenceTags),
|
||||
expectedStatus: actionTarget.value.expectedReviewStatus ?? 'pending',
|
||||
note: actionForm.note || '审核材料完整',
|
||||
expectedStatus: actionTarget.value.expectedReviewStatus,
|
||||
// OpenAPI 当前只支持 note 字段,前端用 note 承载审核通过原因。
|
||||
note: actionForm.note,
|
||||
validationResultId: actionForm.validationResultId,
|
||||
});
|
||||
message.success('发布申请已审核通过');
|
||||
@ -341,10 +641,14 @@ async function handleSubmitAction() {
|
||||
message.error('审核驳回需要校验结果引用');
|
||||
return;
|
||||
}
|
||||
if (!actionTarget.value.expectedReviewStatus) {
|
||||
message.error('当前发布申请状态不能审核驳回');
|
||||
return;
|
||||
}
|
||||
await rejectMarketPublishRequestApi(actionTarget.value.requestId, {
|
||||
commandId: createCommandId('market-review-reject'),
|
||||
evidenceTags: splitEvidenceTags(actionForm.evidenceTags),
|
||||
expectedStatus: actionTarget.value.expectedReviewStatus ?? 'pending',
|
||||
expectedStatus: actionTarget.value.expectedReviewStatus,
|
||||
reason: actionForm.reason,
|
||||
validationResultId: actionForm.validationResultId,
|
||||
});
|
||||
@ -356,13 +660,33 @@ async function handleSubmitAction() {
|
||||
message.error('下架前必须先生成影响预览');
|
||||
return;
|
||||
}
|
||||
if (!actionTarget.value.expectedListingStatus) {
|
||||
message.error('当前资产状态不能下架');
|
||||
return;
|
||||
}
|
||||
if (actionTarget.value.expectedListingStatus !== 'listed') {
|
||||
message.error('下架只能针对已上架资产');
|
||||
return;
|
||||
}
|
||||
if (actionForm.actionType !== 'delist') {
|
||||
message.error('下架提交只能使用下架影响预览,请重新生成');
|
||||
return;
|
||||
}
|
||||
if (!validatePreviewBinding()) {
|
||||
message.error('影响预览与当前资产、动作或范围不匹配,请重新生成');
|
||||
return;
|
||||
}
|
||||
const delistScope = toDelistScope(actionForm.scope);
|
||||
if (!delistScope) {
|
||||
message.error('当前下架范围暂不支持提交,请重新选择范围并生成影响预览');
|
||||
return;
|
||||
}
|
||||
await delistMarketAssetApi(actionTarget.value.assetId, {
|
||||
commandId: createCommandId('market-asset-delist'),
|
||||
expectedStatus: 'listed',
|
||||
expectedStatus: actionTarget.value.expectedListingStatus,
|
||||
impactPreviewId: actionForm.impactPreviewId,
|
||||
reason: actionForm.reason,
|
||||
scope:
|
||||
actionForm.scope === 'full_recall' ? 'full_delist' : actionForm.scope,
|
||||
scope: delistScope,
|
||||
});
|
||||
message.success('市场资产下架命令已提交');
|
||||
}
|
||||
@ -376,10 +700,22 @@ async function handleSubmitAction() {
|
||||
message.error('召回需要影响预览和合规依据');
|
||||
return;
|
||||
}
|
||||
if (!actionTarget.value.expectedListingStatus) {
|
||||
message.error('当前资产状态不能召回');
|
||||
return;
|
||||
}
|
||||
if (actionForm.actionType !== 'recall') {
|
||||
message.error('召回提交只能使用召回影响预览,请重新生成');
|
||||
return;
|
||||
}
|
||||
if (!validatePreviewBinding()) {
|
||||
message.error('影响预览与当前资产、动作或范围不匹配,请重新生成');
|
||||
return;
|
||||
}
|
||||
await recallMarketAssetApi(actionTarget.value.assetId, {
|
||||
basis: actionForm.basis,
|
||||
commandId: createCommandId('market-asset-recall'),
|
||||
expectedStatus: actionTarget.value.expectedListingStatus ?? 'listed',
|
||||
expectedStatus: actionTarget.value.expectedListingStatus,
|
||||
impactPreviewId: actionForm.impactPreviewId,
|
||||
reason: actionForm.reason,
|
||||
scope:
|
||||
@ -395,10 +731,18 @@ async function handleSubmitAction() {
|
||||
message.error('申诉处理需要影响预览引用');
|
||||
return;
|
||||
}
|
||||
if (!actionTarget.value.expectedAppealStatus) {
|
||||
message.error('当前申诉状态不能处理');
|
||||
return;
|
||||
}
|
||||
if (!validatePreviewBinding()) {
|
||||
message.error('影响预览与当前申诉、资产、动作或范围不匹配,请重新生成');
|
||||
return;
|
||||
}
|
||||
await resolveMarketAppealApi(actionTarget.value.appealId, {
|
||||
commandId: createCommandId('market-appeal-resolve'),
|
||||
evidenceTags: splitEvidenceTags(actionForm.evidenceTags),
|
||||
expectedStatus: actionTarget.value.expectedAppealStatus ?? 'reviewing',
|
||||
expectedStatus: actionTarget.value.expectedAppealStatus,
|
||||
impactPreviewId: actionForm.impactPreviewId,
|
||||
reason: actionForm.reason,
|
||||
resolution: actionForm.resolution,
|
||||
@ -494,6 +838,9 @@ onMounted(loadMarketDashboard);
|
||||
<template v-else-if="column.dataIndex === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
:disabled="
|
||||
!canReviewRequest(record as AdminPublishRequestSummaryVO)
|
||||
"
|
||||
size="small"
|
||||
@click="
|
||||
openReviewAction(
|
||||
@ -506,6 +853,9 @@ onMounted(loadMarketDashboard);
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="
|
||||
!canReviewRequest(record as AdminPublishRequestSummaryVO)
|
||||
"
|
||||
size="small"
|
||||
@click="
|
||||
openReviewAction(
|
||||
@ -555,6 +905,9 @@ onMounted(loadMarketDashboard);
|
||||
<template v-else-if="column.dataIndex === 'actions'">
|
||||
<a-space>
|
||||
<a-button
|
||||
:disabled="
|
||||
!canDelistAsset(record as AdminMarketAssetSummaryVO)
|
||||
"
|
||||
size="small"
|
||||
@click="
|
||||
openAssetAction(
|
||||
@ -578,6 +931,9 @@ onMounted(loadMarketDashboard);
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="
|
||||
!canRecallAsset(record as AdminMarketAssetSummaryVO)
|
||||
"
|
||||
size="small"
|
||||
@click="
|
||||
openAssetAction(
|
||||
@ -616,6 +972,7 @@ onMounted(loadMarketDashboard);
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'actions'">
|
||||
<a-button
|
||||
:disabled="!canResolveAppeal(record as AdminAppealSummaryVO)"
|
||||
size="small"
|
||||
@click="openAppealAction(record as AdminAppealSummaryVO)"
|
||||
>
|
||||
@ -667,9 +1024,15 @@ onMounted(loadMarketDashboard);
|
||||
v-if="currentAction === 'preview' || currentAction === 'recall'"
|
||||
label="影响动作"
|
||||
>
|
||||
<a-select v-model:value="actionForm.actionType">
|
||||
<a-select
|
||||
v-model:value="actionForm.actionType"
|
||||
@change="(value) => handleActionTypeChange(String(value) as MarketGovernanceActionType)"
|
||||
>
|
||||
<a-select-option value="delist">下架</a-select-option>
|
||||
<a-select-option value="recall">召回</a-select-option>
|
||||
<a-select-option value="review_rejection">
|
||||
审核申诉恢复
|
||||
</a-select-option>
|
||||
<a-select-option value="stop_generation">
|
||||
停止生成
|
||||
</a-select-option>
|
||||
@ -684,19 +1047,17 @@ onMounted(loadMarketDashboard);
|
||||
"
|
||||
label="治理范围"
|
||||
>
|
||||
<a-select v-model:value="actionForm.scope">
|
||||
<a-select-option value="stop_new_acquire">
|
||||
停止新获取
|
||||
<a-select
|
||||
v-model:value="actionForm.scope"
|
||||
@change="(value) => handleScopeChange(String(value) as MarketGovernanceScope)"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="option in scopeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
<a-select-option value="stop_new_install">
|
||||
停止新安装
|
||||
</a-select-option>
|
||||
<a-select-option value="stop_new_bind">停止新绑定</a-select-option>
|
||||
<a-select-option value="stop_generation">
|
||||
停止生成使用
|
||||
</a-select-option>
|
||||
<a-select-option value="full_delist">完全下架</a-select-option>
|
||||
<a-select-option value="full_recall">完全召回</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
@ -712,7 +1073,7 @@ onMounted(loadMarketDashboard);
|
||||
<a-textarea
|
||||
v-model:value="actionForm.note"
|
||||
:rows="3"
|
||||
placeholder="说明审核材料完整性和通过理由"
|
||||
placeholder="OpenAPI 当前用 note 承载审核通过原因"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
@ -723,10 +1084,16 @@ onMounted(loadMarketDashboard);
|
||||
"
|
||||
label="影响预览 ID"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="actionForm.impactPreviewId"
|
||||
placeholder="先执行影响预览后自动带入"
|
||||
/>
|
||||
<a-space direction="vertical" class="w-full">
|
||||
<a-input
|
||||
v-model:value="actionForm.impactPreviewId"
|
||||
placeholder="先执行影响预览后自动带入"
|
||||
readonly
|
||||
/>
|
||||
<a-button :loading="previewing" @click="handlePreviewImpact">
|
||||
生成当前对象影响预览
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="currentAction === 'recall'" label="召回依据">
|
||||
<a-input
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user