From 7a5298c3fc3001fca8b0da62dde7401df0d66ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Wed, 19 Aug 2026 22:40:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20Skill=20=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E6=93=8D=E4=BD=9C=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在列表提供发布、重新发布和下架入口 - 发布前执行校验并按需展开校验问题 --- .../app/src/views/ai/skill/SkillList.test.ts | 20 +- .../app/src/views/ai/skill/SkillList.vue | 179 +++++++++++++++--- .../ai/skill/SkillResourceWorkbench.test.ts | 52 +++++ .../views/ai/skill/SkillResourceWorkbench.vue | 11 +- 4 files changed, 229 insertions(+), 33 deletions(-) diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts index c27ca1b1..baf15ad1 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts @@ -15,12 +15,28 @@ describe('skill list contract', () => { expect(listSource).toContain("text: '新建技能'"); }); - it('shows only the agreed business columns and omits technical source data', () => { - for (const label of ['技能', '用途', '创建人', '范围', '状态', '操作']) { + it('shows only the agreed business columns and omits status and technical source data', () => { + for (const label of ['技能', '用途', '创建人', '范围', '操作']) { expect(listSource).toContain(`label="${label}"`); } + expect(listSource).not.toContain('label="状态"'); expect(listSource).not.toContain('label="来源"'); expect(listSource).not.toContain('label="能力"'); expect(listSource).not.toContain('

技能库

'); }); + + it('supports publishing, republishing, and taking published skills off shelf from the list', () => { + expect(listSource).toContain('canPublishRow(row)'); + expect(listSource).toContain("? '重新发布'"); + expect(listSource).toContain(':type="rowPublishButtonType(row)"'); + expect(listSource).toContain("? 'warning'"); + expect(listSource).toContain(": 'success'"); + expect(listSource).toContain('command="offline"'); + expect(listSource).toContain('skill-list-page__danger-action'); + expect(listSource).toContain('color: hsl(var(--destructive))'); + expect(listSource).toContain('validateSkillForPublish(row.id)'); + expect(listSource).toContain( + 'submitSkillPublishApproval(target.id, reason)', + ); + }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue index c2c37623..ece40e3a 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillList.vue @@ -49,7 +49,8 @@ import { withListReturnTo } from '#/router/list-return-context'; import { canAiResourceDelete, canAiResourceOffline, - resolveAiResourceDisplayStatus, + canAiResourcePublish, + canAiResourceRepublish, } from '#/views/ai/shared/publish-status'; import { @@ -59,6 +60,8 @@ import { getSkillCategories, submitSkillDeleteApproval, submitSkillOfflineApproval, + submitSkillPublishApproval, + validateSkillForPublish, } from './api'; import { isSkillAccessDeniedError, @@ -91,6 +94,9 @@ const editingCategory = ref(); const categoryAction = ref(''); const copyDialogOpen = ref(false); const copyLoading = ref(false); +const publishDialogOpen = ref(false); +const publishReason = ref(''); +const publishTarget = ref(); const rowAction = ref(''); const copyForm = reactive({ categoryId: '' as number | string, @@ -105,6 +111,9 @@ const canView = computed(() => hasPermission(['/api/v1/skill/getDetail'])); const canUpdate = computed(() => hasPermission(['/api/v1/skill/update'])); const canExport = computed(() => hasPermission(['/api/v1/skill/export'])); const canCopy = computed(() => canCreate.value); +const canPublish = computed(() => + hasPermission(['/api/v1/skill/submitPublishApproval']), +); const canOffline = computed(() => hasPermission(['/api/v1/skill/submitOfflineApproval']), ); @@ -291,21 +300,6 @@ function handlePageStateChange(state: { if (target.fullPath !== route.fullPath) void router.replace(target); } -function statusMeta(row: SkillInfo) { - const status = resolveAiResourceDisplayStatus( - row.displayPublishStatus, - row.publishStatus, - ); - return { - DELETE_PENDING: { label: '删除中', type: 'danger' as const }, - DRAFT: { label: '草稿', type: 'info' as const }, - OFFLINE: { label: '已下线', type: 'info' as const }, - OFFLINE_PENDING: { label: '下线中', type: 'warning' as const }, - PUBLISHED: { label: '已发布', type: 'success' as const }, - PUBLISH_PENDING: { label: '待审核', type: 'warning' as const }, - }[status]; -} - function scopeMeta(scope?: string) { return { DEPT: { label: '部门', type: 'primary' as const }, @@ -318,6 +312,27 @@ function rowPrimaryLabel(row: SkillInfo) { return row.manageable !== false && canUpdate.value ? '编辑' : '查看'; } +function canPublishRow(row: SkillInfo) { + return ( + row.manageable !== false && + canPublish.value && + (canAiResourcePublish(row.displayPublishStatus, row.publishStatus) || + canAiResourceRepublish(row.displayPublishStatus, row.publishStatus)) + ); +} + +function rowPublishLabel(row: SkillInfo) { + return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus) + ? '重新发布' + : '发布'; +} + +function rowPublishButtonType(row: SkillInfo): 'success' | 'warning' { + return canAiResourceRepublish(row.displayPublishStatus, row.publishStatus) + ? 'warning' + : 'success'; +} + function canOfflineRow(row: SkillInfo) { return ( row.manageable !== false && @@ -334,8 +349,62 @@ function canDeleteRow(row: SkillInfo) { ); } -function isRowBusy(row: SkillInfo) { - return rowAction.value.endsWith(`:${row.id}`); +function isPublishRowBusy(row: SkillInfo) { + return ( + rowAction.value === `publish:${row.id}` || + rowAction.value === `validate:${row.id}` + ); +} + +function isMoreActionBusy(row: SkillInfo) { + return !isPublishRowBusy(row) && rowAction.value.endsWith(`:${row.id}`); +} + +async function openPublishDialog(row: SkillInfo) { + if (!row.id || !canPublishRow(row) || rowAction.value) return; + rowAction.value = `validate:${row.id}`; + try { + const response = await validateSkillForPublish(row.id); + if (response.errorCode !== 0) return; + if (!response.data.valid) { + const issue = response.data.issues?.find( + (item) => item.severity === 'ERROR', + ); + ElMessage.warning( + issue?.message + ? `发布校验未通过:${issue.message}` + : '发布校验未通过,请编辑技能后重试', + ); + return; + } + publishTarget.value = row; + publishReason.value = ''; + publishDialogOpen.value = true; + } finally { + rowAction.value = ''; + } +} + +async function confirmPublish() { + const target = publishTarget.value; + const reason = publishReason.value.trim(); + if (!target?.id || rowAction.value) return; + if (!reason) { + ElMessage.warning('请输入发布说明'); + return; + } + if (reason.length > 500) return; + rowAction.value = `publish:${target.id}`; + try { + const response = await submitSkillPublishApproval(target.id, reason); + if (response.errorCode !== 0) return; + publishDialogOpen.value = false; + publishTarget.value = undefined; + pageDataRef.value?.reload?.(); + ElMessage.success(response.message || '已提交发布'); + } finally { + rowAction.value = ''; + } } async function handleRowCommand(command: string, row: SkillInfo) { @@ -396,9 +465,9 @@ async function exportOne(row: SkillInfo) { async function offline(row: SkillInfo) { if (!row.id || rowAction.value) return; try { - await ElMessageBox.confirm('下线后将不可继续使用,确认下线?', '下线技能', { + await ElMessageBox.confirm('下架后将不可继续使用,确认下架?', '下架技能', { cancelButtonText: '取消', - confirmButtonText: '下线', + confirmButtonText: '下架', type: 'warning', }); } catch { @@ -597,19 +666,22 @@ async function handleCategorySaved() { - - - - + @@ -848,6 +963,10 @@ async function handleCategorySaved() { align-items: center; } +.skill-list-page__danger-action { + color: hsl(var(--destructive)); +} + @media (max-width: 900px) { .skill-list-page { padding: var(--space-4); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts index 6c241bd0..ea7790e6 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.test.ts @@ -326,6 +326,58 @@ describe('skill resource workbench validation', () => { expect(valid).toBe(true); expect(apiMocks.validateSkillForPublish).toHaveBeenCalledWith(101); + expect(wrapper.find('.document-editor-workbench__inspector').exists()).toBe( + false, + ); + }); + + it('keeps validation issues collapsed until the user opens them', async () => { + apiMocks.validateSkillForPublish.mockResolvedValue({ + data: { + issues: [ + { + line: 3, + message: '缺少必填字段', + path: 'SKILL.md', + severity: 'ERROR', + }, + ], + valid: false, + }, + errorCode: 0, + }); + wrapper = mount(SkillResourceWorkbench, { + global: { + directives: { loading: {} }, + stubs: { + MarkdownLiveEditor: { + template: '
', + }, + }, + }, + props: { skillId: 101 }, + }); + await flushPromises(); + + const valid = await ( + wrapper.vm as unknown as { + validateAll: () => Promise; + } + ).validateAll(); + await flushPromises(); + + expect(valid).toBe(false); + expect(wrapper.find('.document-editor-workbench__inspector').exists()).toBe( + false, + ); + const issueButton = wrapper.get('button[aria-label="查看校验问题"]'); + expect(issueButton.text()).toContain('校验问题 1'); + + await issueButton.trigger('click'); + + expect(wrapper.find('.document-editor-workbench__inspector').exists()).toBe( + true, + ); }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue index 5ecdbcbc..976be6c5 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue @@ -540,7 +540,6 @@ async function validateAll() { if (res.errorCode !== 0) return false; issues.value = res.data.issues || []; emit('issues', issues.value); - inspector.value = 'validation'; if (res.data.valid) ElMessage.success('发布级校验通过'); else ElMessage.warning('发现需要处理的校验问题'); return res.data.valid; @@ -1246,6 +1245,16 @@ defineExpose({ size="small" />
+ + 校验问题 {{ issues.length }} +