From 5de3b209c1f9e19c873a14ed62807ac51ebe682f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 14 Aug 2026 18:58:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=A1=91=E6=8A=80=E8=83=BD?= =?UTF-8?q?=E5=BA=93=E4=B8=8E=E7=BB=9F=E4=B8=80=E6=96=87=E4=BB=B6=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一分类检索、新建导入和发布交互 - 使用单一文件工作台编辑全部 Skill 资源 - 下沉可复用 Markdown 与代码编辑能力 --- .../ai/skill/SkillCapabilityPanel.test.ts | 457 ---- .../views/ai/skill/SkillCapabilityPanel.vue | 1359 ----------- .../views/ai/skill/SkillCreateDialog.test.ts | 115 +- .../src/views/ai/skill/SkillCreateDialog.vue | 617 +++-- .../ai/skill/SkillDetail.behavior.test.ts | 192 ++ .../src/views/ai/skill/SkillDetail.test.ts | 469 +--- .../app/src/views/ai/skill/SkillDetail.vue | 782 +++---- .../app/src/views/ai/skill/SkillList.test.ts | 230 +- .../app/src/views/ai/skill/SkillList.vue | 2002 +++-------------- .../ai/skill/SkillResourceWorkbench.test.ts | 88 +- .../views/ai/skill/SkillResourceWorkbench.vue | 289 +-- .../ai/skill/SkillSettingsDialog.test.ts | 2 - .../views/ai/skill/SkillSettingsDialog.vue | 16 +- .../app/src/views/ai/skill/api.ts | 103 +- .../views/ai/skill/skill-api-error.test.ts | 12 - .../app/src/views/ai/skill/skill-api-error.ts | 10 - .../views/ai/skill/skill-capability.test.ts | 113 - .../src/views/ai/skill/skill-capability.ts | 113 - .../src/views/ai/skill/skill-create.test.ts | 1 - .../app/src/views/ai/skill/skill-create.ts | 7 +- .../src/views/ai/skill/skill-draft.test.ts | 4 - .../app/src/views/ai/skill/skill-draft.ts | 2 - .../src/views/ai/skill/skill-import.test.ts | 116 - .../app/src/views/ai/skill/skill-import.ts | 59 - .../views/ai/skill/skill-list-route-state.ts | 8 +- .../app/src/views/ai/skill/types.ts | 118 +- .../packages/@core/ui-kit/editor-ui/README.md | 45 + .../@core/ui-kit/editor-ui/src/CodeEditor.vue | 41 +- .../src/MarkdownLiveEditor.security.test.ts | 2 +- .../editor-ui/src/MarkdownLiveEditor.test.ts | 192 +- .../editor-ui/src/MarkdownLiveEditor.vue | 497 +++- .../editor-ui/src/markdown-fidelity.test.ts | 34 + .../ui-kit/editor-ui/src/markdown-fidelity.ts | 40 +- .../editor-ui/src/milkdown-frontmatter.ts | 6 +- .../@core/ui-kit/editor-ui/src/types.ts | 9 +- .../DocumentEditorWorkbench.vue | 4 +- .../document-editor/FileTreeNode.vue | 3 +- .../__tests__/FileTreePanel.test.ts | 7 +- 38 files changed, 2354 insertions(+), 5810 deletions(-) delete mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.test.ts delete mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue create mode 100644 easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.behavior.test.ts delete mode 100644 easyflow-ui-admin/app/src/views/ai/skill/skill-capability.test.ts delete mode 100644 easyflow-ui-admin/app/src/views/ai/skill/skill-capability.ts delete mode 100644 easyflow-ui-admin/app/src/views/ai/skill/skill-import.test.ts delete mode 100644 easyflow-ui-admin/app/src/views/ai/skill/skill-import.ts create mode 100644 easyflow-ui-admin/packages/@core/ui-kit/editor-ui/README.md diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.test.ts deleted file mode 100644 index 366e9402..00000000 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.test.ts +++ /dev/null @@ -1,457 +0,0 @@ -import { flushPromises, mount } from '@vue/test-utils'; - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import SkillCapabilityPanel from './SkillCapabilityPanel.vue'; - -const apiMocks = vi.hoisted(() => ({ - getSkillCapabilityBindings: vi.fn(), - getSkillCapabilityCandidates: vi.fn(), - getSkillCapabilityTools: vi.fn(), - getSkillDetail: vi.fn(), - replaceSkillCapabilityBindings: vi.fn(), -})); - -vi.mock('./api', () => apiMocks); - -let wrapper: ReturnType | undefined; -const modalStub = { - name: 'EasyFlowFormModal', - props: ['open'], - template: - '
', -}; - -function mountPanel() { - wrapper = mount(SkillCapabilityPanel, { - attachTo: document.body, - global: { - directives: { access: {}, loading: {} }, - stubs: { EasyFlowFormModal: modalStub }, - }, - props: { - capabilityHash: 'a'.repeat(64), - skillId: 101, - }, - }); - return wrapper; -} - -function mountEmbeddedPanel() { - wrapper = mount(SkillCapabilityPanel, { - attachTo: document.body, - global: { - directives: { access: {}, loading: {} }, - stubs: { EasyFlowFormModal: modalStub }, - }, - props: { - capabilityHash: 'a'.repeat(64), - embedded: true, - skillId: 101, - }, - }); - return wrapper; -} - -describe('skill capability panel load isolation', () => { - beforeEach(() => { - vi.clearAllMocks(); - apiMocks.getSkillCapabilityBindings.mockImplementation( - (skillId: number | string) => - Promise.resolve( - String(skillId) === '101' - ? { - data: [ - { - capabilityType: 'WORKFLOW', - enabled: true, - runtimeName: 'old_workflow', - targetId: 11, - targetName: '旧 Skill 工作流', - targetStatus: 'AVAILABLE', - }, - ], - errorCode: 0, - } - : { - data: undefined, - errorCode: 1, - message: 'load failed', - }, - ), - ); - apiMocks.getSkillCapabilityCandidates.mockResolvedValue({ - data: [], - errorCode: 0, - }); - apiMocks.getSkillCapabilityTools.mockResolvedValue({ - data: { status: 'AVAILABLE', targetId: 11, toolNames: [] }, - errorCode: 0, - }); - apiMocks.getSkillDetail.mockResolvedValue({ - data: { capabilityHash: 'b'.repeat(64) }, - errorCode: 0, - }); - apiMocks.replaceSkillCapabilityBindings.mockResolvedValue({ - data: { bindings: [], capabilityHash: 'c'.repeat(64) }, - errorCode: 0, - }); - }); - - afterEach(() => { - wrapper?.unmount(); - wrapper = undefined; - document.body.innerHTML = ''; - }); - - it('clears the previous Skill bindings and disables mutations when the next load fails', async () => { - wrapper = mount(SkillCapabilityPanel, { - attachTo: document.body, - global: { - directives: { access: {}, loading: {} }, - stubs: { - EasyFlowFormModal: modalStub, - }, - }, - props: { - capabilityHash: 'a'.repeat(64), - skillId: 101, - }, - }); - await flushPromises(); - - expect(wrapper.get('.skill-capability-panel__title').text()).toContain('1'); - expect(wrapper.find('.skill-capability-panel__list').exists()).toBe(true); - - const addButton = wrapper - .findAll('button') - .find((button) => button.text().includes('添加能力')); - expect(addButton).toBeDefined(); - await addButton?.trigger('click'); - await flushPromises(); - expect( - wrapper.findComponent({ name: 'EasyFlowFormModal' }).props('open'), - ).toBe(true); - - await wrapper.setProps({ - capabilityHash: 'b'.repeat(64), - skillId: 202, - }); - await flushPromises(); - - expect(apiMocks.getSkillCapabilityBindings).toHaveBeenNthCalledWith(1, 101); - expect(apiMocks.getSkillCapabilityBindings).toHaveBeenNthCalledWith(2, 202); - expect(wrapper.get('.skill-capability-panel__title').text()).toContain('0'); - expect(wrapper.find('.skill-capability-panel__list').exists()).toBe(false); - expect(wrapper.text()).toContain('load failed'); - expect( - wrapper.findComponent({ name: 'EasyFlowFormModal' }).props('open'), - ).toBe(false); - - expect(wrapper.text()).not.toContain('保存绑定'); - expect(wrapper.text()).not.toContain('校验'); - const addButtons = wrapper - .findAll('button') - .filter((button) => button.text().includes('添加')); - - expect(addButtons.length).toBeGreaterThan(0); - expect( - addButtons.every((button) => button.attributes('disabled') !== undefined), - ).toBe(true); - }); - - it('removes duplicated embedded labels and keeps only actionable save states', async () => { - mountEmbeddedPanel(); - await flushPromises(); - - expect(wrapper?.find('.skill-capability-panel__title').exists()).toBe( - false, - ); - expect(wrapper?.text()).not.toContain('已保存'); - - apiMocks.replaceSkillCapabilityBindings.mockImplementation( - () => new Promise(() => undefined), - ); - wrapper?.findComponent({ name: 'ElSwitch' }).vm.$emit('change', false); - await Promise.resolve(); - - expect(wrapper?.text()).toContain('保存中'); - }); - - it('loads MCP tools only for a selected scope and keeps failures retryable', async () => { - apiMocks.getSkillCapabilityCandidates.mockResolvedValue({ - data: [ - { - capabilityType: 'MCP', - name: '知识库 MCP', - status: 'AVAILABLE', - targetId: 88, - }, - ], - errorCode: 0, - }); - apiMocks.getSkillCapabilityTools - .mockResolvedValueOnce({ - data: undefined, - errorCode: 1, - message: 'load failed', - }) - .mockResolvedValueOnce({ - data: { - status: 'AVAILABLE', - targetId: 88, - toolNames: ['search'], - }, - errorCode: 0, - }); - wrapper = mount(SkillCapabilityPanel, { - attachTo: document.body, - global: { - directives: { access: {}, loading: {} }, - stubs: { - EasyFlowFormModal: modalStub, - }, - }, - props: { - capabilityHash: 'a'.repeat(64), - skillId: 101, - }, - }); - await flushPromises(); - - const addButton = wrapper - .findAll('button') - .find((button) => button.text().includes('添加能力')); - expect(addButton).toBeDefined(); - await addButton?.trigger('click'); - await flushPromises(); - - const typeSegmented = wrapper.findComponent({ name: 'ElSegmented' }); - expect(typeSegmented.exists()).toBe(true); - typeSegmented.vm.$emit('update:modelValue', 'MCP'); - await flushPromises(); - - let selects = wrapper.findAllComponents({ name: 'ElSelect' }); - const candidateSelect = selects.find((select) => select.props('remote')); - expect(candidateSelect).toBeDefined(); - candidateSelect?.vm.$emit('update:modelValue', 88); - await flushPromises(); - - expect(apiMocks.getSkillCapabilityTools).not.toHaveBeenCalled(); - - selects = wrapper.findAllComponents({ name: 'ElSelect' }); - const scopeSelect = selects.find( - (select) => select.props('modelValue') === 'ALL', - ); - expect(scopeSelect).toBeDefined(); - scopeSelect?.vm.$emit('update:modelValue', 'SELECTED'); - scopeSelect?.vm.$emit('change', 'SELECTED'); - await flushPromises(); - - expect(apiMocks.getSkillCapabilityTools).toHaveBeenCalledTimes(1); - expect(apiMocks.getSkillCapabilityTools).toHaveBeenLastCalledWith(88); - expect( - wrapper - .findAllComponents({ name: 'ElFormItem' }) - .some((item) => item.props('error') === 'load failed'), - ).toBe(true); - - const retryButton = wrapper - .findAll('button') - .find((button) => button.text().trim() === '重新加载'); - await retryButton?.trigger('click'); - await flushPromises(); - - expect(apiMocks.getSkillCapabilityTools).toHaveBeenCalledTimes(2); - expect( - wrapper - .findAllComponents({ name: 'ElFormItem' }) - .some((item) => item.props('error') === 'load failed'), - ).toBe(false); - expect( - wrapper - .findAllComponents({ name: 'ElOption' }) - .some((option) => option.props('value') === 'search'), - ).toBe(true); - }); - - it('generates a stable runtime name for resources with a Chinese name', async () => { - apiMocks.getSkillCapabilityCandidates.mockResolvedValue({ - data: [ - { - capabilityType: 'WORKFLOW', - name: '测试工作流', - status: 'AVAILABLE', - targetId: 88, - }, - ], - errorCode: 0, - }); - mountPanel(); - await flushPromises(); - - const addButton = wrapper - ?.findAll('button') - .find((button) => button.text().includes('添加能力')); - await addButton?.trigger('click'); - await flushPromises(); - - const candidateSelect = wrapper - ?.findAllComponents({ name: 'ElSelect' }) - .find((select) => select.props('remote')); - candidateSelect?.vm.$emit('update:modelValue', 88); - await flushPromises(); - - expect( - wrapper?.findComponent({ name: 'ElInput' }).props('modelValue'), - ).toBe('workflow_88'); - }); - - it('automatically saves toggles with the expected hash and updates the quiet state', async () => { - apiMocks.replaceSkillCapabilityBindings.mockImplementation( - (_skillId: number | string, nextBindings: unknown[]) => - Promise.resolve({ - data: { - bindings: structuredClone(nextBindings), - capabilityHash: 'c'.repeat(64), - }, - errorCode: 0, - }), - ); - mountPanel(); - await flushPromises(); - - wrapper?.findComponent({ name: 'ElSwitch' }).vm.$emit('change', false); - await flushPromises(); - - expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenCalledWith( - 101, - [expect.objectContaining({ enabled: false })], - 'a'.repeat(64), - ); - expect(wrapper?.text()).toContain('已保存'); - expect( - (wrapper?.vm as unknown as { hasDirty: () => boolean }).hasDirty(), - ).toBe(false); - }); - - it('serializes rapid changes and persists the latest desired state', async () => { - let resolveFirst: - | ((value: { - data: { bindings: unknown[]; capabilityHash: string }; - errorCode: number; - }) => void) - | undefined; - apiMocks.replaceSkillCapabilityBindings - .mockImplementationOnce( - (_skillId: number | string, _nextBindings: unknown[]) => - new Promise((resolve) => { - resolveFirst = resolve; - }), - ) - .mockImplementationOnce( - (_skillId: number | string, nextBindings: unknown[]) => - Promise.resolve({ - data: { - bindings: structuredClone(nextBindings), - capabilityHash: 'd'.repeat(64), - }, - errorCode: 0, - }), - ); - mountPanel(); - await flushPromises(); - - const toggle = wrapper?.findComponent({ name: 'ElSwitch' }); - toggle?.vm.$emit('change', false); - await Promise.resolve(); - toggle?.vm.$emit('change', true); - resolveFirst?.({ - data: { - bindings: [ - { - capabilityType: 'WORKFLOW', - enabled: false, - runtimeName: 'old_workflow', - targetId: 11, - targetName: '旧 Skill 工作流', - targetStatus: 'AVAILABLE', - }, - ], - capabilityHash: 'c'.repeat(64), - }, - errorCode: 0, - }); - await flushPromises(); - - expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenCalledTimes(2); - expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenNthCalledWith( - 2, - 101, - [expect.objectContaining({ enabled: true })], - 'c'.repeat(64), - ); - expect(wrapper?.text()).toContain('已保存'); - }); - - it('rolls back an optimistic toggle when automatic save fails', async () => { - apiMocks.replaceSkillCapabilityBindings.mockRejectedValue( - new Error('服务暂时不可用'), - ); - mountPanel(); - await flushPromises(); - - const toggle = wrapper?.findComponent({ name: 'ElSwitch' }); - toggle?.vm.$emit('change', false); - await flushPromises(); - - expect( - wrapper?.findComponent({ name: 'ElSwitch' }).props('modelValue'), - ).toBe(true); - expect(wrapper?.text()).toContain('服务暂时不可用'); - expect(wrapper?.text()).toContain('保存失败'); - }); - - it('keeps local changes on conflict and can reapply them with a refreshed hash', async () => { - apiMocks.replaceSkillCapabilityBindings - .mockRejectedValueOnce({ - response: { - data: { message: '能力配置已被其他操作更新' }, - status: 409, - }, - }) - .mockImplementationOnce( - (_skillId: number | string, nextBindings: unknown[]) => - Promise.resolve({ - data: { - bindings: structuredClone(nextBindings), - capabilityHash: 'd'.repeat(64), - }, - errorCode: 0, - }), - ); - mountPanel(); - await flushPromises(); - - const toggle = wrapper?.findComponent({ name: 'ElSwitch' }); - toggle?.vm.$emit('change', false); - await flushPromises(); - - expect(toggle?.props('modelValue')).toBe(false); - expect(wrapper?.text()).toContain('本地修改已保留'); - - const reapply = wrapper - ?.findAll('button') - .find((button) => button.text().trim() === '重新应用'); - await reapply?.trigger('click'); - await flushPromises(); - - expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenNthCalledWith( - 2, - 101, - [expect.objectContaining({ enabled: false })], - 'b'.repeat(64), - ); - expect(wrapper?.text()).toContain('已保存'); - }); -}); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue deleted file mode 100644 index f35954ce..00000000 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue +++ /dev/null @@ -1,1359 +0,0 @@ - - - - - diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.test.ts index 2b416b25..76d882ec 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.test.ts @@ -1,106 +1,21 @@ -import { flushPromises, mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import dialogSource from './SkillCreateDialog.vue?raw'; -import SkillCreateDialog from './SkillCreateDialog.vue'; - -const apiMocks = vi.hoisted(() => ({ - saveSkill: vi.fn(), -})); - -vi.mock('./api', () => apiMocks); -vi.mock('@easyflow/common-ui', async () => { - const { defineComponent, h } = await import('vue'); - return { - EasyFlowFormModal: defineComponent({ - props: { - open: Boolean, - submitting: Boolean, - title: { default: '', type: String }, - }, - emits: ['confirm', 'update:open'], - setup(props, { emit, slots }) { - return () => - props.open - ? h('section', { class: 'form-modal-stub' }, [ - h('h2', props.title), - slots.default?.(), - h( - 'button', - { - class: 'form-modal-confirm', - disabled: props.submitting, - onClick: () => emit('confirm'), - type: 'button', - }, - '创建并进入详情', - ), - ]) - : null; - }, - }), - }; -}); - -describe('skill create dialog', () => { - beforeEach(() => { - vi.clearAllMocks(); - apiMocks.saveSkill.mockResolvedValue({ - data: { - displayName: '研究助手', - id: 101, - publishStatus: 'DRAFT', - }, - errorCode: 0, - }); +describe('skill create and import contract', () => { + it('offers manual authoring and independent standard ZIP batch import', () => { + expect(dialogSource).toContain('value="manual">自己编写'); + expect(dialogSource).toContain('value="import">批量导入'); + expect(dialogSource).toContain('accept=".zip,application/zip"'); + expect(dialogSource).toContain('multiple'); + expect(dialogSource).toContain('单次最多导入 20 个技能'); + expect(dialogSource).toContain('importSkillConfirmBatch'); + expect(dialogSource).not.toContain('.efskill'); }); - it('creates a valid draft from the compact business form', async () => { - const wrapper = mount(SkillCreateDialog, { - props: { - canPublish: true, - categories: [{ categoryName: '研发', id: 8, status: 1 }], - defaultCategoryId: 8, - modelValue: true, - }, - }); - await flushPromises(); - - expect(wrapper.get('h2').text()).toBe('新建 Skill'); - await wrapper.get('input').setValue('研究助手'); - const radioGroup = wrapper.getComponent({ name: 'ElRadioGroup' }); - radioGroup.vm.$emit('update:modelValue', 'PUBLISH'); - await wrapper.get('.form-modal-confirm').trigger('click'); - await flushPromises(); - - expect(apiMocks.saveSkill).toHaveBeenCalledWith( - expect.objectContaining({ - categoryId: 8, - displayName: '研究助手', - enabled: true, - publishStatus: 'DRAFT', - visibilityScope: 'PRIVATE', - }), - ); - expect(apiMocks.saveSkill.mock.calls[0]?.[0].skillContent).toContain( - '# Instructions', - ); - expect(wrapper.emitted('created')?.[0]?.[0]).toMatchObject({ - intent: 'PUBLISH', - skill: { id: 101 }, - }); - expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]); - }); - - it('keeps publish intent unavailable without publish permission', async () => { - const wrapper = mount(SkillCreateDialog, { - props: { categories: [], modelValue: true }, - }); - await flushPromises(); - - const publishOption = wrapper - .findAllComponents({ name: 'ElRadioButton' }) - .find((option) => option.text().includes('创建后发布')); - expect(publishOption?.props('disabled')).toBe(true); + it('requires one shared category and visibility scope for the operation', () => { + expect(dialogSource).toContain('v-model="form.categoryId"'); + expect(dialogSource).toContain('v-model="form.visibilityScope"'); + expect(dialogSource).toContain('visibilityScope: form.visibilityScope'); }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue index 6c6f9610..3d29046d 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue @@ -1,65 +1,92 @@ diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.behavior.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.behavior.test.ts new file mode 100644 index 00000000..28b2d092 --- /dev/null +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.behavior.test.ts @@ -0,0 +1,192 @@ +import type { Router } from 'vue-router'; + +/* eslint-disable vue/one-component-per-file -- Inline stubs isolate the detail workflow. */ +import { flushPromises, mount } from '@vue/test-utils'; +import { createMemoryHistory, createRouter } from 'vue-router'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SkillDetail from './SkillDetail.vue'; + +const apiMocks = vi.hoisted(() => ({ + getSkillCategories: vi.fn(), + getSkillDetail: vi.fn(), + submitSkillDeleteApproval: vi.fn(), + submitSkillOfflineApproval: vi.fn(), + submitSkillPublishApproval: vi.fn(), +})); +const childMocks = vi.hoisted(() => ({ + getSkillContent: vi.fn<() => string | undefined>(() => undefined), + save: vi.fn<() => Promise>(async () => true), + validate: vi.fn<() => Promise>(async () => true), +})); + +vi.mock('vue-router', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + onBeforeRouteLeave: vi.fn(), + onBeforeRouteUpdate: vi.fn(), + }; +}); +vi.mock('./api', () => apiMocks); +vi.mock('#/api/common/hasPermission', () => ({ hasPermission: () => true })); +vi.mock('./SkillResourceWorkbench.vue', async () => { + const { defineComponent, h } = await import('vue'); + return { + default: defineComponent({ + name: 'SkillResourceWorkbench', + props: { readonly: Boolean }, + emits: ['dirty', 'requestSave'], + setup(props, { expose }) { + expose({ + getSkillContent: childMocks.getSkillContent, + saveAll: childMocks.save, + validateAll: childMocks.validate, + }); + return () => + h('div', { + 'data-readonly': String(props.readonly), + 'data-testid': 'resource-workbench', + }); + }, + }), + }; +}); +vi.mock('./SkillSettingsDialog.vue', async () => { + const { defineComponent } = await import('vue'); + return { + default: defineComponent({ + name: 'SkillSettingsDialog', + setup: () => () => null, + }), + }; +}); + +let router: Router; + +async function mountDetail(id = '101') { + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { component: { template: '
' }, path: '/ai/skill' }, + { component: { template: '
' }, path: '/ai/skill/detail/:id' }, + ], + }); + await router.push(`/ai/skill/detail/${id}`); + await router.isReady(); + const wrapper = mount(SkillDetail, { + attachTo: document.body, + global: { + directives: { loading: {} }, + plugins: [router], + }, + }); + await flushPromises(); + return wrapper; +} + +function workbench(wrapper: Awaited>) { + return wrapper.getComponent({ name: 'SkillResourceWorkbench' }); +} + +function publishButton(wrapper: Awaited>) { + const button = wrapper + .findAll('button') + .find((candidate) => candidate.text().trim() === '发布'); + if (!button) throw new Error('未找到发布按钮'); + return button; +} + +describe('skill detail runtime behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + childMocks.getSkillContent.mockReturnValue(undefined); + childMocks.save.mockResolvedValue(true); + childMocks.validate.mockResolvedValue(true); + apiMocks.getSkillCategories.mockResolvedValue({ data: [], errorCode: 0 }); + apiMocks.getSkillDetail.mockResolvedValue({ + data: { + description: '用于验收统一工作台', + displayName: '验收技能', + displayPublishStatus: 'DRAFT', + id: 101, + manageable: true, + name: 'acceptance-skill', + publishStatus: 'DRAFT', + skillContent: + '---\nname: acceptance-skill\ndescription: 用于验收统一工作台\n---\n', + visibilityScope: 'PRIVATE', + }, + errorCode: 0, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('returns the retired direct-new route to the Skill list', async () => { + const wrapper = await mountDetail('new'); + + expect(router.currentRoute.value.path).toBe('/ai/skill'); + expect(apiMocks.getSkillDetail).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('stops publishing when saving dirty files fails', async () => { + childMocks.save.mockResolvedValue(false); + const wrapper = await mountDetail(); + workbench(wrapper).vm.$emit('dirty', true); + await flushPromises(); + + await publishButton(wrapper).trigger('click'); + await flushPromises(); + + expect(childMocks.save).toHaveBeenCalledOnce(); + expect(childMocks.validate).not.toHaveBeenCalled(); + expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('locks the editor while validating and stops on validation failure', async () => { + let resolveValidation: ((result: boolean) => void) | undefined; + childMocks.validate.mockImplementation( + () => + new Promise((resolve) => { + resolveValidation = resolve; + }), + ); + const wrapper = await mountDetail(); + + await publishButton(wrapper).trigger('click'); + await flushPromises(); + expect(workbench(wrapper).props('readonly')).toBe(true); + + resolveValidation?.(false); + await flushPromises(); + expect(workbench(wrapper).props('readonly')).toBe(false); + expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled(); + wrapper.unmount(); + }); + + it('handles the save shortcut through the single workbench', async () => { + const wrapper = await mountDetail(); + workbench(wrapper).vm.$emit('dirty', true); + await flushPromises(); + const shortcut = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 's', + metaKey: true, + }); + + window.dispatchEvent(shortcut); + await flushPromises(); + + expect(shortcut.defaultPrevented).toBe(true); + expect(childMocks.save).toHaveBeenCalledOnce(); + wrapper.unmount(); + }); +}); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts index 2a425af9..ba3b90df 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts @@ -1,449 +1,40 @@ -import type { Router } from 'vue-router'; +import { describe, expect, it } from 'vitest'; -/* eslint-disable vue/one-component-per-file -- Inline stubs keep this publish-flow test isolated. */ -import { flushPromises, mount } from '@vue/test-utils'; -import { createMemoryHistory, createRouter } from 'vue-router'; +import detailSource from './SkillDetail.vue?raw'; -import { ElMessage, ElMessageBox } from 'element-plus'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import SkillDetail from './SkillDetail.vue'; - -const apiMocks = vi.hoisted(() => ({ - getSkillCategories: vi.fn(), - getSkillDetail: vi.fn(), - saveSkill: vi.fn(), - submitSkillDeleteApproval: vi.fn(), - submitSkillOfflineApproval: vi.fn(), - submitSkillPublishApproval: vi.fn(), - updateSkill: vi.fn(), -})); - -const childMocks = vi.hoisted(() => ({ - capabilityHasDirty: vi.fn(() => false), - capabilitySave: vi.fn(), - resourceSave: vi.fn(async () => true), - resourceValidate: vi.fn(), -})); -const permissionMocks = vi.hoisted(() => ({ - hasPermission: vi.fn((_permissions: string[]) => true), -})); - -vi.mock('vue-router', async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - onBeforeRouteLeave: vi.fn(), - onBeforeRouteUpdate: vi.fn(), - }; -}); -vi.mock('./api', () => apiMocks); -vi.mock('#/api/common/hasPermission', () => ({ - hasPermission: permissionMocks.hasPermission, -})); -vi.mock('./SkillResourceWorkbench.vue', async () => { - const { defineComponent, h } = await import('vue'); - return { - default: defineComponent({ - name: 'SkillResourceWorkbench', - props: { - activePanel: { default: 'resources', type: String }, - capabilityAvailable: Boolean, - readonly: Boolean, - }, - emits: [ - 'dirty', - 'issues', - 'locateCapability', - 'newContent', - 'requestSave', - 'update:activePanel', - ], - setup(props, { expose, slots }) { - expose({ - getSkillContent: () => undefined, - saveAll: childMocks.resourceSave, - validateAll: childMocks.resourceValidate, - }); - return () => - h( - 'div', - { - 'data-readonly': String(props.readonly), - 'data-testid': 'resource-workbench', - }, - slots.capability?.(), - ); - }, - }), - }; -}); -vi.mock('./SkillSettingsDialog.vue', async () => { - const { defineComponent, h } = await import('vue'); - return { - default: defineComponent({ - name: 'SkillSettingsDialog', - props: { - categoryLoadError: { default: '', type: String }, - modelValue: Boolean, - }, - emits: ['retryCategories', 'saved', 'update:modelValue'], - setup(props, { emit }) { - return () => - props.modelValue - ? h('div', { 'data-testid': 'settings-dialog' }, [ - props.categoryLoadError, - h('button', { onClick: () => emit('retryCategories') }, '重试'), - ]) - : null; - }, - }), - }; -}); -vi.mock('./SkillCapabilityPanel.vue', async () => { - const { defineComponent, h } = await import('vue'); - return { - default: defineComponent({ - name: 'SkillCapabilityPanel', - props: { locked: Boolean, readonly: Boolean }, - setup(props, { expose }) { - expose({ - flushPending: childMocks.capabilitySave, - hasDirty: childMocks.capabilityHasDirty, - }); - return () => - h('div', { - 'data-locked': String(props.locked), - 'data-testid': 'capability-panel', - }); - }, - }), - }; -}); - -let router: Router; - -async function mountDetail(id = '101', navTitle?: string) { - router = createRouter({ - history: createMemoryHistory(), - routes: [ - { component: { template: '
' }, path: '/ai/skill' }, - { - component: { template: '
' }, - path: '/ai/skill/detail/:id', - }, - ], - }); - await router.push({ - path: `/ai/skill/detail/${id}`, - query: navTitle ? { navTitle } : undefined, - }); - await router.isReady(); - const wrapper = mount(SkillDetail, { - attachTo: document.body, - global: { - directives: { access: {}, loading: {} }, - plugins: [router], - }, - }); - await flushPromises(); - return wrapper; -} - -function publishButton(wrapper: ReturnType) { - const button = wrapper - .findAll('button') - .find((candidate) => candidate.text().trim() === '发布'); - if (!button) throw new Error('未找到发布按钮'); - return button; -} - -function markResourceDirty(wrapper: ReturnType) { - wrapper - .getComponent({ name: 'SkillResourceWorkbench' }) - .vm.$emit('dirty', true); -} - -describe('skill detail publish transaction', () => { - beforeEach(() => { - vi.clearAllMocks(); - childMocks.capabilityHasDirty.mockReset().mockReturnValue(false); - childMocks.capabilitySave.mockReset().mockResolvedValue(true); - childMocks.resourceSave.mockReset().mockResolvedValue(true); - childMocks.resourceValidate.mockReset().mockResolvedValue(true); - permissionMocks.hasPermission.mockReset().mockReturnValue(true); - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - callback(0); - return 1; - }); - apiMocks.getSkillCategories.mockResolvedValue({ data: [], errorCode: 0 }); - apiMocks.getSkillDetail.mockResolvedValue({ - data: { - capabilityHash: 'a'.repeat(64), - description: 'Demonstration skill', - displayName: '演示 Skill', - displayPublishStatus: 'DRAFT', - enabled: true, - id: 101, - manageable: true, - name: 'demo-skill', - publishStatus: 'DRAFT', - skillContent: - '---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n', - visibilityScope: 'PRIVATE', - }, - errorCode: 0, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it('locks editors and follows validate, confirm, submit order', async () => { - const calls: string[] = []; - childMocks.resourceSave.mockImplementation(async () => { - calls.push('save'); - return true; - }); - let resolveValidation: ((value: boolean) => void) | undefined; - let resolveSubmit: - | ((value: { data: number; errorCode: number; message: string }) => void) - | undefined; - childMocks.resourceValidate.mockImplementation( - () => - new Promise((resolve) => { - calls.push('validate'); - resolveValidation = resolve; - }), +describe('skill studio contract', () => { + it('uses one unified file workbench and one publish modal', () => { + expect(detailSource).not.toContain('activeTab'); + expect(detailSource).not.toContain('instructionsOnly'); + expect(detailSource).not.toContain('skill-detail-page__tabs'); + expect(detailSource.match(/ { - calls.push('confirm'); - return { action: 'confirm' } as Awaited< - ReturnType - >; - }); - apiMocks.submitSkillPublishApproval.mockImplementation( - () => - new Promise((resolve) => { - calls.push('submit'); - resolveSubmit = resolve; - }), + expect(detailSource).not.toContain('能力绑定'); + expect(detailSource).not.toContain('SkillCapabilityPanel'); + }); + + it('keeps the detail shell compact and uses the neutral content surface', () => { + expect(detailSource).toContain('min-height: 48px'); + expect(detailSource).toContain('background: hsl(var(--background))'); + expect(detailSource).not.toContain( + 'background: hsl(var(--surface-canvas))', ); - const wrapper = await mountDetail(); - markResourceDirty(wrapper); - await flushPromises(); - - await publishButton(wrapper).trigger('click'); - await flushPromises(); - - expect(calls).toEqual(['save', 'validate']); - expect( - wrapper - .get('[data-testid="resource-workbench"]') - .attributes('data-readonly'), - ).toBe('true'); - expect( - wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'), - ).toBe('true'); - - resolveValidation?.(true); - await flushPromises(); - - expect(calls).toEqual(['save', 'validate', 'confirm', 'submit']); - expect(childMocks.resourceSave).toHaveBeenCalledOnce(); - expect(childMocks.resourceValidate).toHaveBeenCalledWith(); - - resolveSubmit?.({ data: 9001, errorCode: 0, message: '已提交' }); - await flushPromises(); - - expect( - wrapper - .get('[data-testid="resource-workbench"]') - .attributes('data-readonly'), - ).toBe('false'); - expect( - wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'), - ).toBe('false'); - wrapper.unmount(); }); - it('does not confirm or submit when publish validation fails', async () => { - childMocks.resourceValidate.mockResolvedValue(false); - const confirm = vi.spyOn(ElMessageBox, 'confirm'); - const wrapper = await mountDetail(); - - await publishButton(wrapper).trigger('click'); - await flushPromises(); - - expect(childMocks.resourceValidate).toHaveBeenCalledWith(); - expect(confirm).not.toHaveBeenCalled(); - expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled(); - wrapper.unmount(); - }); - - it('stops before validation when saving dirty resources fails', async () => { - childMocks.resourceSave.mockResolvedValue(false); - const confirm = vi.spyOn(ElMessageBox, 'confirm'); - const wrapper = await mountDetail(); - markResourceDirty(wrapper); - await flushPromises(); - - await publishButton(wrapper).trigger('click'); - await flushPromises(); - - expect(childMocks.resourceSave).toHaveBeenCalledOnce(); - expect(childMocks.resourceValidate).not.toHaveBeenCalled(); - expect(confirm).not.toHaveBeenCalled(); - expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled(); - wrapper.unmount(); - }); - - it('uses one resource and capability workspace and opens settings on demand', async () => { - const wrapper = await mountDetail(); - const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' }); - - expect(wrapper.findAll('[role="tab"]')).toHaveLength(0); - expect(workbench.props('activePanel')).toBe('resources'); - expect(workbench.props('capabilityAvailable')).toBe(true); - expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe( - true, + it('saves dirty files and validates the package before opening publish', () => { + const saveAt = detailSource.indexOf('await saveFiles(false)'); + const validateAt = detailSource.indexOf( + 'resourceWorkbenchRef.value?.validateAll()', ); + const dialogAt = detailSource.indexOf('publishDialogOpen.value = true'); - expect(wrapper.text()).not.toContain('设置'); - expect(wrapper.text()).not.toContain('校验'); - const lifecycleDropdown = wrapper.findComponent({ name: 'ElDropdown' }); - expect(lifecycleDropdown.props('trigger')).toBe('click'); - lifecycleDropdown.vm.$emit('command', 'settings'); - await flushPromises(); - expect(wrapper.find('[data-testid="settings-dialog"]').exists()).toBe(true); - wrapper.unmount(); - }); - - it('keeps the routed display title when an older detail response omits it', async () => { - apiMocks.getSkillDetail.mockResolvedValueOnce({ - data: { - capabilityHash: 'a'.repeat(64), - displayName: '', - displayPublishStatus: 'DRAFT', - manageable: true, - name: '', - publishStatus: 'DRAFT', - skillContent: - '---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n', - }, - errorCode: 0, - }); - const wrapper = await mountDetail('101', '验收 Skill'); - - expect(wrapper.get('h1').text()).toBe('验收 Skill'); - wrapper.unmount(); - }); - - it('keeps one file save action and handles workbench and keyboard save requests', async () => { - const wrapper = await mountDetail(); - const saveButtons = wrapper - .findAll('button') - .filter((button) => button.text().trim() === '保存'); - expect(saveButtons).toHaveLength(1); - expect(saveButtons[0]?.attributes('disabled')).toBeDefined(); - - markResourceDirty(wrapper); - await flushPromises(); - expect(saveButtons[0]?.attributes('disabled')).toBeUndefined(); - - wrapper - .getComponent({ name: 'SkillResourceWorkbench' }) - .vm.$emit('requestSave'); - await flushPromises(); - expect(childMocks.resourceSave).toHaveBeenCalledOnce(); - - markResourceDirty(wrapper); - await flushPromises(); - const shortcut = new KeyboardEvent('keydown', { - bubbles: true, - cancelable: true, - key: 's', - metaKey: true, - }); - window.dispatchEvent(shortcut); - await flushPromises(); - - expect(shortcut.defaultPrevented).toBe(true); - expect(childMocks.resourceSave).toHaveBeenCalledTimes(2); - wrapper.unmount(); - }); - - it('redirects the retired direct-new route back to the Skill list', async () => { - const wrapper = await mountDetail('new'); - - expect(router.currentRoute.value.path).toBe('/ai/skill'); - wrapper.unmount(); - }); - - it('keeps validation navigation on resources without capability permission', async () => { - permissionMocks.hasPermission.mockImplementation( - (permissions: string[]) => - !permissions.includes('/api/v1/skill/capability'), - ); - const warning = vi.spyOn(ElMessage, 'warning'); - const wrapper = await mountDetail(); - - const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' }); - workbench.vm.$emit('locateCapability'); - await flushPromises(); - - expect(workbench.props('activePanel')).toBe('resources'); - expect(workbench.props('capabilityAvailable')).toBe(false); - expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe( - false, - ); - expect(warning).toHaveBeenCalledWith('当前没有查看能力绑定的权限'); - wrapper.unmount(); - }); - - it('distinguishes a category request failure from an unavailable category and supports retry', async () => { - apiMocks.getSkillCategories.mockRejectedValueOnce(new Error('network')); - apiMocks.getSkillDetail.mockResolvedValueOnce({ - data: { - categoryId: 77, - categoryName: '研发工具', - description: 'Demonstration skill', - displayName: '演示 Skill', - displayPublishStatus: 'DRAFT', - enabled: true, - id: 101, - manageable: true, - name: 'demo-skill', - publishStatus: 'DRAFT', - skillContent: - '---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n', - visibilityScope: 'PRIVATE', - }, - errorCode: 0, - }); - const wrapper = await mountDetail(); - - wrapper - .findComponent({ name: 'ElDropdown' }) - .vm.$emit('command', 'settings'); - await flushPromises(); - expect(wrapper.get('[data-testid="settings-dialog"]').text()).toContain( - '分类加载失败,请重试', - ); - - apiMocks.getSkillCategories.mockResolvedValueOnce({ - data: [{ categoryName: '研发工具', id: 77, status: 1 }], - errorCode: 0, - }); - await wrapper - .get('[data-testid="settings-dialog"] button') - .trigger('click'); - await flushPromises(); - - expect(apiMocks.getSkillCategories).toHaveBeenCalledTimes(2); - expect(wrapper.text()).not.toContain('分类加载失败,请重试'); - wrapper.unmount(); + expect(saveAt).toBeGreaterThan(0); + expect(validateAt).toBeGreaterThan(saveAt); + expect(dialogAt).toBeGreaterThan(validateAt); }); }); diff --git a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue index 7d94fc9f..3d8d027a 100644 --- a/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue +++ b/easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.vue @@ -3,7 +3,6 @@ import type { SkillCategory, SkillInfo } from './types'; import { computed, - nextTick, onBeforeUnmount, onMounted, reactive, @@ -27,9 +26,11 @@ import { } from '@element-plus/icons-vue'; import { ElButton, + ElDialog, ElDropdown, ElDropdownItem, ElDropdownMenu, + ElInput, ElMessage, ElMessageBox, ElResult, @@ -58,62 +59,54 @@ import { } from './api'; import { isSkillAccessDeniedError } from './skill-api-error'; import { readFrontmatterScalar, splitSkillMarkdown } from './skill-markdown'; -import SkillCapabilityPanel from './SkillCapabilityPanel.vue'; import SkillResourceWorkbench from './SkillResourceWorkbench.vue'; import SkillSettingsDialog from './SkillSettingsDialog.vue'; const route = useRoute(); const router = useRouter(); -const skillId = computed(() => String(route.params.id || 'new')); -const isNew = computed(() => skillId.value === 'new'); +const skillId = computed(() => String(route.params.id || '')); const loading = ref(false); const saving = ref(false); -const publishing = ref(false); const validating = ref(false); -const lifecycleAction = ref<'delete' | 'offline' | undefined>(); +const publishing = ref(false); +const lifecycleAction = ref<'delete' | 'offline'>(); const loadError = ref(''); const loadAccessDenied = ref(false); -const activeSection = ref<'capability' | 'resources'>('resources'); +const resourceDirty = ref(false); const settingsDialogOpen = ref(false); +const publishDialogOpen = ref(false); +const publishReason = ref(''); const categories = ref([]); const categoryLoading = ref(false); const categoryLoadError = ref(''); const resourceWorkbenchRef = ref>(); -const capabilityPanelRef = ref>(); -const resourceDirty = ref(false); -const capabilityDirty = ref(false); let allowOperationNavigation = false; let initRequest = 0; - +let categoryRequest = 0; const skill = reactive({ description: '', displayName: '', - enabled: true, name: '', - skillContent: '---\nname: ""\ndescription: ""\n---\n\n# Instructions\n', + skillContent: '', visibilityScope: 'PRIVATE', }); -const operationLocked = computed( - () => - saving.value || - validating.value || - publishing.value || - Boolean(lifecycleAction.value), -); -const canViewCapabilities = computed(() => - hasPermission(['/api/v1/skill/capability']), +const operationLocked = computed(() => + Boolean( + saving.value || + validating.value || + publishing.value || + lifecycleAction.value, + ), ); -const hasUnsavedChanges = computed( - () => resourceDirty.value || capabilityDirty.value, +const detailTitle = computed( + () => + skill.displayName || + String(route.query.navTitle || '') || + skill.name || + '技能', ); -const detailTitle = computed(() => { - if (loadError.value) - return loadAccessDenied.value ? '无权查看 Skill' : '无法加载 Skill'; - const routeTitle = String(route.query.navTitle || '').trim(); - return skill.displayName || routeTitle || skill.name || 'Skill'; -}); -const status = computed(() => +const displayStatus = computed(() => resolveAiResourceDisplayStatus( skill.displayPublishStatus, skill.publishStatus, @@ -122,62 +115,39 @@ const status = computed(() => const canManage = computed( () => hasPermission(['/api/v1/skill/update']) && skill.manageable !== false, ); -const canManageResource = computed( - () => !isNew.value && skill.manageable !== false, +const canEditFiles = computed( + () => canManage.value && hasPermission(['/api/v1/skill/file']), ); const canSubmitPublish = computed( () => - canManageResource.value && + canManage.value && hasPermission(['/api/v1/skill/submitPublishApproval']) && (canAiResourcePublish(skill.displayPublishStatus, skill.publishStatus) || canAiResourceRepublish(skill.displayPublishStatus, skill.publishStatus)), ); const canSubmitOffline = computed( () => - canManageResource.value && + canManage.value && hasPermission(['/api/v1/skill/submitOfflineApproval']) && canAiResourceOffline(skill.displayPublishStatus, skill.publishStatus), ); const canSubmitDelete = computed( () => - canManageResource.value && + canManage.value && hasPermission(['/api/v1/skill/submitDeleteApproval']) && canAiResourceDelete(skill.displayPublishStatus, skill.publishStatus), ); -const canEditFiles = computed( - () => skill.manageable !== false && hasPermission(['/api/v1/skill/file']), +const statusMeta = computed( + () => + ({ + 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 }, + })[displayStatus.value], ); -const canBindCapabilities = computed( - () => skill.manageable !== false && canViewCapabilities.value, -); -const headerSaveState = computed(() => { - if (saving.value) return '保存中'; - if (resourceDirty.value) return '有未保存修改'; - if (capabilityDirty.value) return '能力保存中'; - return '已保存'; -}); -const statusMeta = computed(() => { - switch (status.value) { - case 'DELETE_PENDING': { - return { label: '删除审批中', type: 'danger' as const }; - } - case 'OFFLINE': { - return { label: '已下线', type: 'info' as const }; - } - case 'OFFLINE_PENDING': { - return { label: '下线审批中', type: 'warning' as const }; - } - case 'PUBLISH_PENDING': { - return { label: '发布审批中', type: 'warning' as const }; - } - case 'PUBLISHED': { - return { label: '已发布', type: 'success' as const }; - } - default: { - return { label: '草稿', type: 'info' as const }; - } - } -}); onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload); @@ -189,33 +159,73 @@ onBeforeUnmount(() => { window.removeEventListener('beforeunload', handleBeforeUnload); window.removeEventListener('keydown', handleSaveShortcut); }); +onBeforeRouteLeave(confirmNavigation); +onBeforeRouteUpdate(confirmNavigation); -async function confirmUnsavedNavigation() { - if (operationLocked.value && !allowOperationNavigation) { - ElMessage.info('操作处理中,请稍候'); - return false; +async function init() { + if (!skillId.value || skillId.value === 'new') { + await returnToListDuringOperation(); + return; } - if (!hasUnsavedChanges.value) return true; + const request = ++initRequest; + loading.value = true; + loadError.value = ''; try { - await ElMessageBox.confirm( - '当前仍有未保存内容,离开后修改会丢失。', - '离开 Skill', - { - cancelButtonText: '继续编辑', - confirmButtonText: '放弃修改', - type: 'warning', - }, - ); - return true; - } catch { - return false; + const [detailResponse] = await Promise.all([ + getSkillDetail(skillId.value), + loadCategories(), + ]); + if (request !== initRequest) return; + if (detailResponse.errorCode !== 0) throw detailResponse; + resetSkill(detailResponse.data); + resourceDirty.value = false; + loadAccessDenied.value = false; + } catch (error) { + if (request !== initRequest) return; + loadAccessDenied.value = isSkillAccessDeniedError(error); + loadError.value = loadAccessDenied.value + ? '当前账号没有查看权限' + : '加载失败,请重试'; + } finally { + if (request === initRequest) loading.value = false; } } -onBeforeRouteLeave(confirmUnsavedNavigation); -onBeforeRouteUpdate(confirmUnsavedNavigation); +async function loadCategories() { + const request = ++categoryRequest; + categoryLoading.value = true; + categoryLoadError.value = ''; + try { + const response = await getSkillCategories(); + if (request !== categoryRequest) return; + if (response.errorCode !== 0) throw response; + categories.value = response.data || []; + } catch { + if (request !== categoryRequest) return; + categories.value = []; + categoryLoadError.value = '分类加载失败,请重试'; + } finally { + if (request === categoryRequest) categoryLoading.value = false; + } +} -async function returnToSkillListDuringOperation() { +function resetSkill(next: SkillInfo) { + for (const key of Object.keys(skill)) Reflect.deleteProperty(skill, key); + Object.assign(skill, { + description: '', + displayName: '', + name: '', + skillContent: '', + visibilityScope: 'PRIVATE', + ...next, + }); +} + +async function backToList() { + await navigateBackToList(router, route.query, ['/ai/skill'], '/ai/skill'); +} + +async function returnToListDuringOperation() { allowOperationNavigation = true; try { await router.replace( @@ -226,405 +236,201 @@ async function returnToSkillListDuringOperation() { } } -async function backToSkillList() { - await navigateBackToList(router, route.query, ['/ai/skill'], '/ai/skill'); -} - -async function init() { - const request = ++initRequest; - loading.value = true; - loadError.value = ''; - loadAccessDenied.value = false; - try { - if (isNew.value) { - await returnToSkillListDuringOperation(); - return; - } - await loadCategories(request); - if (request !== initRequest) return; - const res = await getSkillDetail(skillId.value); - if (request !== initRequest) return; - if (res.errorCode === 0) { - resetSkill(res.data); - } else { - if (isSkillAccessDeniedError(res)) throw res; - throw new Error(res.message || 'Skill 加载失败'); - } - resourceDirty.value = false; - capabilityDirty.value = false; - } catch (error) { - if (request === initRequest) { - loadAccessDenied.value = isSkillAccessDeniedError(error); - loadError.value = loadAccessDenied.value - ? '当前账号没有查看此 Skill 的权限' - : 'Skill 加载失败,请重试'; - } - } finally { - if (request === initRequest) loading.value = false; - } -} - -async function loadCategories(request = initRequest) { - categoryLoading.value = true; - try { - const response = await getSkillCategories(); - if (request !== initRequest) return false; - if (response.errorCode !== 0) { - throw new Error(response.message || '分类加载失败'); - } - categories.value = response.data || []; - categoryLoadError.value = ''; - return true; - } catch { - if (request === initRequest) { - categoryLoadError.value = '分类加载失败,请重试'; - } - return false; - } finally { - if (request === initRequest) categoryLoading.value = false; - } -} - -function resetSkill(next: SkillInfo) { - for (const key of Object.keys(skill)) delete skill[key]; - Object.assign(skill, next); -} - -function syncSkillMetadataFromContent(content: string) { - const parts = splitSkillMarkdown(content); - const name = readFrontmatterScalar(parts.frontmatter, 'name'); - const description = readFrontmatterScalar(parts.frontmatter, 'description'); - skill.name = name; - skill.description = description; -} - -async function refreshSavedDetail() { - try { - const res = await getSkillDetail(skillId.value); - if (res.errorCode !== 0) return false; - resetSkill(res.data); - return true; - } catch { - return false; - } -} - -function locateCapabilitySection() { - if (!canViewCapabilities.value) { - activeSection.value = 'resources'; - ElMessage.warning('当前没有查看能力绑定的权限'); - return; - } - activeSection.value = 'capability'; -} - -function handleSettingsSaved(next: SkillInfo) { - Object.assign(skill, next); -} - -function handleCapabilitySaved(bindings: unknown[]) { - skill.capabilityCount = bindings.length; -} - -function handleLifecycleCommand(command: 'delete' | 'offline' | 'settings') { - if (command === 'settings') { - settingsDialogOpen.value = true; - return; - } - if (command === 'offline') void offline(); - else void remove(); -} - -async function saveFiles(allowLifecycleAction = false, showFeedback = true) { - if (saving.value || (lifecycleAction.value && !allowLifecycleAction)) - return false; +async function confirmNavigation() { + if (operationLocked.value && !allowOperationNavigation) return false; if (!resourceDirty.value) return true; + try { + await ElMessageBox.confirm('当前有未保存修改,确认离开?', '离开编辑', { + cancelButtonText: '继续编辑', + confirmButtonText: '放弃修改', + type: 'warning', + }); + return true; + } catch { + return false; + } +} + +async function saveFiles(showFeedback = true) { + if (saving.value || !resourceDirty.value) return !resourceDirty.value; saving.value = true; try { - await nextTick(); - if (!canEditFiles.value) { - ElMessage.warning('文件管理权限已变化,请刷新后重试'); - return false; - } if (!(await resourceWorkbenchRef.value?.saveAll())) return false; - const latestSkillContent = resourceWorkbenchRef.value?.getSkillContent?.(); - if (typeof latestSkillContent === 'string') { - skill.skillContent = latestSkillContent; - syncSkillMetadataFromContent(latestSkillContent); - } - const refreshed = await refreshSavedDetail(); + const content = resourceWorkbenchRef.value?.getSkillContent?.(); + if (typeof content === 'string') syncSkillMetadata(content); resourceDirty.value = false; - if (showFeedback && refreshed) ElMessage.success('修改已保存'); - else if (!refreshed) { - ElMessage.warning('修改已保存,最新状态加载失败,请刷新页面'); - } + if (showFeedback) ElMessage.success('已保存'); return true; } finally { saving.value = false; } } -async function flushPendingChanges(allowLifecycleAction = false) { - if (resourceDirty.value && !(await saveFiles(allowLifecycleAction, false))) - return false; - const panel = capabilityPanelRef.value; - if (capabilityDirty.value || panel?.hasDirty()) { - if (!canBindCapabilities.value || !panel) { - ElMessage.warning('能力绑定权限或页面状态已变化,请刷新后重试'); - return false; - } - if (!(await panel.flushPending())) { - activeSection.value = 'capability'; - ElMessage.warning('能力绑定尚未保存,请处理后重试'); - return false; - } - } - capabilityDirty.value = false; - return true; +function syncSkillMetadata(content: string) { + const parts = splitSkillMarkdown(content); + skill.skillContent = content; + skill.name = readFrontmatterScalar(parts.frontmatter, 'name'); + skill.description = readFrontmatterScalar(parts.frontmatter, 'description'); } -async function validateSkillContentInternal(savePendingChanges: boolean) { - if ( - validating.value || - lifecycleAction.value || - (publishing.value && savePendingChanges) - ) - return false; - if (isNew.value) { - ElMessage.info('请先保存 Skill,再执行完整校验'); - return false; - } +async function openPublishDialog() { + if (!canSubmitPublish.value || operationLocked.value) return; + if (resourceDirty.value && !(await saveFiles(false))) return; validating.value = true; try { - await nextTick(); - if ( - savePendingChanges && - hasUnsavedChanges.value && - !(await flushPendingChanges()) - ) { - return false; - } - activeSection.value = 'resources'; - await new Promise((resolve) => requestAnimationFrame(resolve)); - return (await resourceWorkbenchRef.value?.validateAll()) ?? false; + if (!(await resourceWorkbenchRef.value?.validateAll())) return; + publishReason.value = ''; + publishDialogOpen.value = true; } finally { validating.value = false; } } -async function publish() { - if ( - publishing.value || - saving.value || - validating.value || - lifecycleAction.value - ) - return; - if (isNew.value) { - ElMessage.warning('请先保存 Skill'); +async function confirmPublish() { + const reason = publishReason.value.trim(); + if (!reason) { + ElMessage.warning('请输入发布说明'); return; } - if (!canSubmitPublish.value) return; + if (reason.length > 500) return; publishing.value = true; try { - await nextTick(); - if (hasUnsavedChanges.value && !(await flushPendingChanges())) return; - if (!(await validateSkillContentInternal(false))) return; - if (!canSubmitPublish.value) { - ElMessage.warning('发布权限或状态已变化,请刷新后重试'); - return; - } - const republish = canAiResourceRepublish( - skill.displayPublishStatus, - skill.publishStatus, - ); - try { - await ElMessageBox.confirm( - republish - ? '发布级校验已通过,确认将当前内容作为新版本提交审批?' - : '发布级校验已通过,确认提交发布审批?', - republish ? '重新发布' : '发布 Skill', - { - cancelButtonText: '取消', - confirmButtonText: '提交审批', - type: 'info', - }, - ); - } catch { - return; - } - const res = await submitSkillPublishApproval(skillId.value); - if (res.errorCode === 0) { - ElMessage.success(res.message || '已提交发布审批'); - await init(); - } + const response = await submitSkillPublishApproval(skillId.value, reason); + if (response.errorCode !== 0) return; + publishDialogOpen.value = false; + ElMessage.success(response.message || '已提交发布'); + await init(); } finally { publishing.value = false; } } +function handleMoreCommand(command: 'delete' | 'offline' | 'settings') { + if (command === 'settings') settingsDialogOpen.value = true; + if (command === 'offline') void offline(); + if (command === 'delete') void remove(); +} + async function offline() { - if ( - lifecycleAction.value || - publishing.value || - saving.value || - validating.value - ) + if (!canSubmitOffline.value || operationLocked.value) return; + try { + await ElMessageBox.confirm('下线后将不可继续使用,确认下线?', '下线技能', { + cancelButtonText: '取消', + confirmButtonText: '下线', + type: 'warning', + }); + } catch { return; - if (!canSubmitOffline.value) return; + } lifecycleAction.value = 'offline'; try { - await nextTick(); - try { - await ElMessageBox.confirm( - hasUnsavedChanges.value - ? '将先保存当前修改,再提交下线审批。下线后,当前发布版本将停止使用。' - : '下线后,当前发布版本将停止使用。', - '下线 Skill', - { - cancelButtonText: '取消', - confirmButtonText: '提交审批', - type: 'warning', - }, - ); - } catch { - return; - } - if (hasUnsavedChanges.value && !(await flushPendingChanges(true))) return; - const res = await submitSkillOfflineApproval(skillId.value); - if (res.errorCode === 0) { - ElMessage.success(res.message || '已提交下线审批'); - await init(); - } + if (resourceDirty.value && !(await saveFiles(false))) return; + const response = await submitSkillOfflineApproval(skillId.value); + if (response.errorCode === 0) await init(); } finally { lifecycleAction.value = undefined; } } async function remove() { - if ( - lifecycleAction.value || - publishing.value || - saving.value || - validating.value - ) + if (!canSubmitDelete.value || operationLocked.value) return; + try { + await ElMessageBox.confirm('删除后无法恢复,确认删除?', '删除技能', { + cancelButtonText: '取消', + confirmButtonText: '删除', + type: 'warning', + }); + } catch { return; - if (!canSubmitDelete.value) return; + } lifecycleAction.value = 'delete'; try { - await nextTick(); - try { - await ElMessageBox.confirm( - hasUnsavedChanges.value - ? '将先保存当前修改,再删除此 Skill。删除后将无法恢复。' - : '删除后将无法继续编辑或使用此 Skill。', - '删除 Skill', - { - cancelButtonText: '取消', - confirmButtonText: '确认删除', - type: 'warning', - }, - ); - } catch { - return; - } - if (hasUnsavedChanges.value && !(await flushPendingChanges(true))) return; - const res = await submitSkillDeleteApproval(skillId.value); - if (res.errorCode === 0) { - if (res.data === null || res.data === undefined) { - ElMessage.success(res.message || 'Skill 已删除'); - await returnToSkillListDuringOperation(); - } else { - ElMessage.success(res.message || '已提交删除审批'); - await init(); - } - } + if (resourceDirty.value && !(await saveFiles(false))) return; + const response = await submitSkillDeleteApproval(skillId.value); + if (response.errorCode !== 0) return; + await (response.data === null || response.data === undefined + ? returnToListDuringOperation() + : init()); } finally { lifecycleAction.value = undefined; } } +function handleSettingsSaved(next: SkillInfo) { + Object.assign(skill, next); +} + function handleBeforeUnload(event: BeforeUnloadEvent) { - if (!operationLocked.value && !hasUnsavedChanges.value) return; + if (!operationLocked.value && !resourceDirty.value) return; event.preventDefault(); event.returnValue = ''; } function handleSaveShortcut(event: KeyboardEvent) { - if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 's') return; - } event.preventDefault(); - if (canEditFiles.value && resourceDirty.value && !operationLocked.value) { - void saveFiles(); - } + if (resourceDirty.value && !operationLocked.value) void saveFiles(); }