feat: 重塑技能库与统一文件工作台
- 统一分类检索、新建导入和发布交互 - 使用单一文件工作台编辑全部 Skill 资源 - 下沉可复用 Markdown 与代码编辑能力
This commit is contained in:
@@ -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<typeof mount> | undefined;
|
||||
const modalStub = {
|
||||
name: 'EasyFlowFormModal',
|
||||
props: ['open'],
|
||||
template:
|
||||
'<div v-if="open" data-testid="capability-dialog"><slot></slot><button data-testid="modal-confirm" @click="$emit(\'confirm\')">保存</button></div>',
|
||||
};
|
||||
|
||||
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('已保存');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,65 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCreateIntent } from './skill-create';
|
||||
import type { SkillCategory, SkillInfo } from './types';
|
||||
import type {
|
||||
SkillCategory,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillVisibilityScope,
|
||||
} from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import { Document, UploadFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
ElTag,
|
||||
} from 'element-plus';
|
||||
|
||||
import { saveSkill } from './api';
|
||||
import {
|
||||
cancelSkillImport,
|
||||
importSkillConfirmBatch,
|
||||
importSkillPreviews,
|
||||
saveSkill,
|
||||
} from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
import { buildInitialSkillDraft } from './skill-create';
|
||||
|
||||
type CreateMode = 'import' | 'manual';
|
||||
type ConflictStrategy = 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
|
||||
interface ImportRow {
|
||||
file: File;
|
||||
message?: string;
|
||||
preview?: SkillImportPreview;
|
||||
rename: string;
|
||||
result?: 'failed' | 'success';
|
||||
strategy: ConflictStrategy;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canPublish?: boolean;
|
||||
categories: SkillCategory[];
|
||||
categoriesLoading?: boolean;
|
||||
defaultCategoryId?: number | string;
|
||||
defaultMode?: CreateMode;
|
||||
modelValue: boolean;
|
||||
}>(),
|
||||
{
|
||||
canPublish: false,
|
||||
categoriesLoading: false,
|
||||
defaultCategoryId: '',
|
||||
defaultMode: 'manual',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [payload: { intent: SkillCreateIntent; skill: SkillInfo }];
|
||||
created: [skill: SkillInfo];
|
||||
imported: [count: number];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const saving = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
const mode = ref<CreateMode>('manual');
|
||||
const busy = ref(false);
|
||||
const actionError = ref('');
|
||||
const importRows = ref<ImportRow[]>([]);
|
||||
const importFinished = ref(false);
|
||||
const form = reactive({
|
||||
categoryId: '' as number | string,
|
||||
displayName: '',
|
||||
intent: 'DRAFT' as SkillCreateIntent,
|
||||
visibilityScope: 'PRIVATE',
|
||||
visibilityScope: 'PRIVATE' as SkillVisibilityScope,
|
||||
});
|
||||
|
||||
const dialogVisible = computed({
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saving.value) {
|
||||
if (!value && busy.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
@@ -71,165 +98,481 @@ const categoryOptions = computed(() =>
|
||||
);
|
||||
const rules: FormRules = {
|
||||
displayName: [
|
||||
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: 'Skill 名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
{ message: '请输入技能名称', required: true, trigger: 'blur' },
|
||||
{ max: 128, message: '技能名称不能超过 128 个字符', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
categoryId: props.defaultCategoryId || '',
|
||||
displayName: '',
|
||||
intent: 'DRAFT',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || props.categoriesLoading) return;
|
||||
form.displayName = form.displayName.trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saving.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await saveSkill(
|
||||
buildInitialSkillDraft({
|
||||
categoryId: form.categoryId || undefined,
|
||||
displayName: form.displayName,
|
||||
visibilityScope: form.visibilityScope,
|
||||
}),
|
||||
);
|
||||
if (response.errorCode !== 0 || !response.data?.id) {
|
||||
actionError.value = response.message || 'Skill 创建失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success('Skill 已创建');
|
||||
emit('update:modelValue', false);
|
||||
emit('created', { intent: form.intent, skill: response.data });
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'Skill 创建失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
const confirmText = computed(() =>
|
||||
mode.value === 'manual' ? '创建并进入编辑' : '导入',
|
||||
);
|
||||
const importReady = computed(
|
||||
() =>
|
||||
importRows.value.length > 0 &&
|
||||
importRows.value.every((row) => {
|
||||
const item = row.preview?.skills[0];
|
||||
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
|
||||
return false;
|
||||
if (!item.conflict) return true;
|
||||
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
|
||||
if (row.strategy === 'RENAME') return canonicalName(row.rename);
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) resetForm();
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
mode.value = props.defaultMode;
|
||||
Object.assign(form, {
|
||||
categoryId: props.defaultCategoryId || '',
|
||||
displayName: '',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
importRows.value = [];
|
||||
importFinished.value = false;
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
if (mode.value === 'manual')
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return;
|
||||
await (mode.value === 'manual' ? createManualSkill() : confirmImports());
|
||||
}
|
||||
|
||||
async function createManualSkill() {
|
||||
form.displayName = form.displayName.trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await saveSkill(buildInitialSkillDraft(form));
|
||||
if (response.errorCode !== 0 || !response.data?.id) {
|
||||
actionError.value = response.message || '技能创建失败,请重试';
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', false);
|
||||
emit('created', response.data);
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'技能创建失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFiles() {
|
||||
if (!busy.value) fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleFiles(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = [...(input.files || [])];
|
||||
input.value = '';
|
||||
await previewFiles(files);
|
||||
}
|
||||
|
||||
async function handleDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
await previewFiles([...(event.dataTransfer?.files || [])]);
|
||||
}
|
||||
|
||||
async function previewFiles(files: File[]) {
|
||||
if (busy.value || files.length === 0) return;
|
||||
if (files.length > 20) {
|
||||
ElMessage.warning('单次最多导入 20 个技能');
|
||||
return;
|
||||
}
|
||||
if (files.some((file) => !file.name.toLowerCase().endsWith('.zip'))) {
|
||||
ElMessage.warning('仅支持标准 Skill ZIP');
|
||||
return;
|
||||
}
|
||||
await cancelPendingImports();
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await importSkillPreviews(files);
|
||||
if (response.errorCode !== 0) {
|
||||
actionError.value = response.message || '导入预检失败';
|
||||
return;
|
||||
}
|
||||
importRows.value = files.map((file, index) => {
|
||||
const preview = response.data[index];
|
||||
const item = preview?.skills[0];
|
||||
return {
|
||||
file,
|
||||
preview,
|
||||
rename: item ? `${item.name}-copy` : '',
|
||||
strategy: item?.conflict ? 'RENAME' : 'REJECT',
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'导入预检失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmImports() {
|
||||
if (!importReady.value) {
|
||||
ElMessage.warning('请先处理导入错误和名称冲突');
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await importSkillConfirmBatch(
|
||||
importRows.value.map((row) => {
|
||||
const item = row.preview!.skills[0]!;
|
||||
return {
|
||||
categoryId: form.categoryId || undefined,
|
||||
conflictStrategy: row.strategy,
|
||||
importToken: row.preview!.importToken!,
|
||||
renames:
|
||||
row.strategy === 'RENAME'
|
||||
? { [item.packageRoot || item.packageId]: row.rename.trim() }
|
||||
: undefined,
|
||||
visibilityScope: form.visibilityScope,
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (response.errorCode !== 0) {
|
||||
actionError.value = response.message || '导入失败';
|
||||
return;
|
||||
}
|
||||
let succeeded = 0;
|
||||
response.data.forEach((result, index) => {
|
||||
const row = importRows.value[index];
|
||||
if (!row) return;
|
||||
row.result = result.success ? 'success' : 'failed';
|
||||
row.message = result.message;
|
||||
if (result.success) succeeded += result.skills.length;
|
||||
});
|
||||
importFinished.value = true;
|
||||
emit('imported', succeeded);
|
||||
if (succeeded === importRows.value.length) {
|
||||
ElMessage.success(`已导入 ${succeeded} 个技能`);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(error, '导入失败,请重试');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestClose() {
|
||||
if (busy.value) return;
|
||||
await cancelPendingImports();
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
|
||||
async function cancelPendingImports() {
|
||||
if (importFinished.value) return;
|
||||
const tokens = importRows.value
|
||||
.map((row) => row.preview?.importToken)
|
||||
.filter((token): token is string => typeof token === 'string');
|
||||
await Promise.all(
|
||||
tokens.map((token) => cancelSkillImport(token).catch(() => undefined)),
|
||||
);
|
||||
}
|
||||
|
||||
function importErrorCount(preview?: SkillImportPreview) {
|
||||
return (preview?.issues || []).filter((issue) => issue.severity === 'ERROR')
|
||||
.length;
|
||||
}
|
||||
|
||||
function canonicalName(value: string) {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim());
|
||||
}
|
||||
|
||||
function scopeLabel(scope: SkillVisibilityScope) {
|
||||
return { DEPT: '部门', PRIVATE: '个人', PUBLIC: '全部' }[scope];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saving"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="创建并进入详情"
|
||||
:submitting="saving || categoriesLoading"
|
||||
title="新建 Skill"
|
||||
@confirm="submit"
|
||||
<ElDialog
|
||||
v-model="visible"
|
||||
class="skill-create-dialog"
|
||||
width="min(720px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!busy"
|
||||
:show-close="!busy"
|
||||
title="新建技能"
|
||||
@closed="cancelPendingImports"
|
||||
>
|
||||
<ElRadioGroup v-model="mode" class="skill-create-dialog__mode">
|
||||
<ElRadioButton value="manual">自己编写</ElRadioButton>
|
||||
<ElRadioButton value="import">批量导入</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
|
||||
<p v-if="actionError" class="skill-create-dialog__error" role="alert">
|
||||
{{ actionError }}
|
||||
</p>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-create-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
class="skill-create-dialog__form"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
class="skill-create-form__alert"
|
||||
:closable="false"
|
||||
:title="actionError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<ElFormItem label="Skill 名称" prop="displayName">
|
||||
<ElFormItem v-if="mode === 'manual'" label="技能名称" prop="displayName">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.displayName"
|
||||
maxlength="128"
|
||||
placeholder="请输入 Skill 名称"
|
||||
show-word-limit
|
||||
placeholder="请输入技能名称"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="分类">
|
||||
<ElSelect
|
||||
v-model="form.categoryId"
|
||||
clearable
|
||||
:loading="categoriesLoading"
|
||||
placeholder="未分类"
|
||||
>
|
||||
<ElOption
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 1"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="可见范围">
|
||||
<ElSelect v-model="form.visibilityScope">
|
||||
<ElOption label="仅自己" value="PRIVATE" />
|
||||
<ElOption label="本部门" value="DEPT" />
|
||||
<ElOption label="全员可见" value="PUBLIC" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="发布状态">
|
||||
<div class="skill-create-form__publish-field">
|
||||
<ElRadioGroup v-model="form.intent">
|
||||
<ElRadioButton value="DRAFT">草稿</ElRadioButton>
|
||||
<ElRadioButton value="PUBLISH" :disabled="!canPublish">
|
||||
创建后发布
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
<span v-if="form.intent === 'PUBLISH'">
|
||||
进入详情完善内容,通过校验后提交发布。
|
||||
</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<div class="skill-create-dialog__fields">
|
||||
<ElFormItem label="分类">
|
||||
<ElSelect
|
||||
v-model="form.categoryId"
|
||||
clearable
|
||||
:loading="categoriesLoading"
|
||||
placeholder="未分类"
|
||||
>
|
||||
<ElOption
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
:label="`${'\u3000'.repeat(category.depth)}${category.categoryName}`"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 1"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="范围">
|
||||
<ElSelect v-model="form.visibilityScope">
|
||||
<ElOption
|
||||
v-for="scope in [
|
||||
'PRIVATE',
|
||||
'DEPT',
|
||||
'PUBLIC',
|
||||
] as SkillVisibilityScope[]"
|
||||
:key="scope"
|
||||
:label="scopeLabel(scope)"
|
||||
:value="scope"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</div>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
|
||||
<template v-if="mode === 'import'">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="skill-create-dialog__file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
multiple
|
||||
@change="handleFiles"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="skill-create-dialog__dropzone"
|
||||
:disabled="busy"
|
||||
@click="chooseFiles"
|
||||
@dragover.prevent
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<ElIcon><UploadFilled /></ElIcon>
|
||||
<span>选择或拖入标准 Skill ZIP</span>
|
||||
<small>最多 20 个,每个 ZIP 包含一个技能</small>
|
||||
</button>
|
||||
<div v-if="importRows.length > 0" class="skill-create-dialog__imports">
|
||||
<article v-for="row in importRows" :key="row.file.name">
|
||||
<ElIcon><Document /></ElIcon>
|
||||
<div>
|
||||
<strong>{{ row.preview?.skills[0]?.name || row.file.name }}</strong>
|
||||
<small v-if="importErrorCount(row.preview)">
|
||||
{{ row.preview?.issues?.[0]?.message || '包内容不符合规范' }}
|
||||
</small>
|
||||
<small v-else-if="row.result === 'failed'">{{ row.message }}</small>
|
||||
<small v-else>{{ row.file.name }}</small>
|
||||
</div>
|
||||
<ElTag v-if="row.result === 'success'" type="success" effect="plain">
|
||||
成功
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-else-if="row.result === 'failed'"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>
|
||||
失败
|
||||
</ElTag>
|
||||
<ElTag
|
||||
v-else-if="importErrorCount(row.preview)"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
>
|
||||
错误
|
||||
</ElTag>
|
||||
<template v-else-if="row.preview?.skills[0]?.conflict">
|
||||
<ElSelect
|
||||
v-model="row.strategy"
|
||||
class="skill-create-dialog__strategy"
|
||||
>
|
||||
<ElOption label="重命名" value="RENAME" />
|
||||
<ElOption
|
||||
label="覆盖草稿"
|
||||
value="OVERWRITE"
|
||||
:disabled="!row.preview.skills[0].overwriteAllowed"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElInput
|
||||
v-if="row.strategy === 'RENAME'"
|
||||
v-model="row.rename"
|
||||
class="skill-create-dialog__rename"
|
||||
placeholder="新标准名称"
|
||||
/>
|
||||
</template>
|
||||
<ElTag v-else type="success" effect="plain">可导入</ElTag>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<ElButton :disabled="busy" @click="requestClose">取消</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="busy"
|
||||
:disabled="mode === 'import' && !importReady"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-create-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
.skill-create-dialog__mode {
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.skill-create-form :deep(.el-select) {
|
||||
.skill-create-dialog__mode :deep(.el-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skill-create-dialog__mode :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-create-form__publish-field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
.skill-create-dialog__error {
|
||||
padding: var(--space-3);
|
||||
margin: 0 0 var(--space-4);
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 8%);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.skill-create-form__publish-field > span {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
.skill-create-dialog__fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-dialog__form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-create-dialog__file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.skill-create-dialog__dropzone {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
min-height: 136px;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 1px dashed hsl(var(--line-strong));
|
||||
border-radius: var(--radius-panel);
|
||||
transition: border-color var(--motion-duration-fast)
|
||||
var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.skill-create-dialog__dropzone:hover,
|
||||
.skill-create-dialog__dropzone:focus-visible {
|
||||
border-color: hsl(var(--primary));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.skill-create-dialog__dropzone .el-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.skill-create-dialog__dropzone span {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
max-height: 264px;
|
||||
margin-top: var(--space-4);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports article {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
min-height: 56px;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports article > div {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports strong,
|
||||
.skill-create-dialog__imports small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports small {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.skill-create-dialog__strategy {
|
||||
width: 112px;
|
||||
}
|
||||
|
||||
.skill-create-dialog__rename {
|
||||
width: 176px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.skill-create-dialog__fields {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.skill-create-dialog__imports article {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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<boolean>>(async () => true),
|
||||
validate: vi.fn<() => Promise<boolean>>(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('vue-router')>();
|
||||
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: '<div />' }, path: '/ai/skill' },
|
||||
{ component: { template: '<div />' }, 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<ReturnType<typeof mountDetail>>) {
|
||||
return wrapper.getComponent({ name: 'SkillResourceWorkbench' });
|
||||
}
|
||||
|
||||
function publishButton(wrapper: Awaited<ReturnType<typeof mountDetail>>) {
|
||||
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<boolean>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('vue-router')>();
|
||||
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: '<div />' }, path: '/ai/skill' },
|
||||
{
|
||||
component: { template: '<div />' },
|
||||
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<typeof mount>) {
|
||||
const button = wrapper
|
||||
.findAll('button')
|
||||
.find((candidate) => candidate.text().trim() === '发布');
|
||||
if (!button) throw new Error('未找到发布按钮');
|
||||
return button;
|
||||
}
|
||||
|
||||
function markResourceDirty(wrapper: ReturnType<typeof mount>) {
|
||||
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<boolean>((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(/<SkillResourceWorkbench/g)).toHaveLength(1);
|
||||
expect(detailSource).toContain('title="发布技能"');
|
||||
expect(detailSource).toContain('发布说明');
|
||||
expect(detailSource).toContain('maxlength="500"');
|
||||
expect(detailSource).toContain(
|
||||
'submitSkillPublishApproval(skillId.value, reason)',
|
||||
);
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockImplementation(async () => {
|
||||
calls.push('confirm');
|
||||
return { action: 'confirm' } as Awaited<
|
||||
ReturnType<typeof ElMessageBox.confirm>
|
||||
>;
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,216 +1,26 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import listSource from './SkillList.vue?raw';
|
||||
|
||||
import SkillList from './SkillList.vue';
|
||||
import skillListSource from './SkillList.vue?raw';
|
||||
|
||||
/* eslint-disable vue/one-component-per-file -- Inline stubs keep this layout test isolated. */
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
deleteSkillCategory: vi.fn(),
|
||||
getSkillCategories: vi.fn(),
|
||||
}));
|
||||
const permissionMocks = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn((_permissions: string[]) => true),
|
||||
}));
|
||||
const pageDataMocks = vi.hoisted(() => ({
|
||||
reload: vi.fn(),
|
||||
setQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/common/hasPermission', () => permissionMocks);
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => (key === 'common.allCategories' ? '全部' : key),
|
||||
}));
|
||||
vi.mock('./api', () => ({
|
||||
cancelSkillImport: vi.fn(),
|
||||
copySkill: vi.fn(),
|
||||
deleteSkillCategory: apiMocks.deleteSkillCategory,
|
||||
exportSkills: vi.fn(),
|
||||
getSkillCapabilityCandidates: vi.fn(),
|
||||
getSkillCategories: apiMocks.getSkillCategories,
|
||||
importSkillConfirm: vi.fn(),
|
||||
importSkillPreview: vi.fn(),
|
||||
submitSkillDeleteApproval: vi.fn(),
|
||||
submitSkillOfflineApproval: vi.fn(),
|
||||
submitSkillPublishApproval: vi.fn(),
|
||||
}));
|
||||
vi.mock('#/components/page/PageData.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'PageData',
|
||||
setup(_, { expose }) {
|
||||
expose(pageDataMocks);
|
||||
return () => h('div', { class: 'page-data-container' });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillCategoryFormDialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillCategoryFormDialog',
|
||||
setup: () => () => h('div'),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillCreateDialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillCreateDialog',
|
||||
props: { modelValue: Boolean },
|
||||
emits: ['created', 'update:modelValue'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-open': String(props.modelValue),
|
||||
'data-testid': 'skill-create-dialog',
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
async function mountSkillList() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ component: { template: '<div />' }, path: '/ai/skill' },
|
||||
{
|
||||
component: { template: '<div />' },
|
||||
path: '/ai/skill/detail/:id?',
|
||||
},
|
||||
],
|
||||
});
|
||||
await router.push('/ai/skill');
|
||||
await router.isReady();
|
||||
|
||||
const wrapper = mount(SkillList, {
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
plugins: [router],
|
||||
stubs: { ElDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('skill list layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
permissionMocks.hasPermission.mockReturnValue(true);
|
||||
apiMocks.getSkillCategories.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
categoryName: '研发',
|
||||
id: 1,
|
||||
status: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
describe('skill list contract', () => {
|
||||
it('uses the shared list layout with classification, fuzzy search, and compact filters', () => {
|
||||
expect(listSource).toContain('<PageSide');
|
||||
expect(listSource).toContain('<HeaderSearch');
|
||||
expect(listSource).toContain('搜索技能名称、用途或创建人');
|
||||
expect(listSource).toContain('placeholder="范围:全部"');
|
||||
expect(listSource).toContain('placeholder="状态:全部"');
|
||||
expect(listSource).toContain('placeholder="分类:全部"');
|
||||
expect(listSource).toContain('skill-list-page__mobile-category');
|
||||
expect(listSource).toContain("text: '批量导入'");
|
||||
expect(listSource).toContain("text: '新建技能'");
|
||||
});
|
||||
|
||||
it('places the toolbar above a full-height category and list workspace', async () => {
|
||||
const wrapper = await mountSkillList();
|
||||
|
||||
const header = wrapper.get('.skill-list-page__header');
|
||||
const toolbar = header.get('.custom-header');
|
||||
expect(toolbar.text()).toContain('导入');
|
||||
expect(toolbar.text()).toContain('导出');
|
||||
expect(toolbar.text()).toContain('新建 Skill');
|
||||
expect(toolbar.get('.search-group').findAll('button')).toHaveLength(2);
|
||||
expect(toolbar.find('input').attributes()).toMatchObject({
|
||||
placeholder: '请输入 Skill 名称或描述',
|
||||
});
|
||||
|
||||
const workspace = wrapper.get('.skill-list-page__workspace');
|
||||
expect(workspace.find('.skill-list-page__header').exists()).toBe(false);
|
||||
expect(workspace.element.children[0]?.classList).toContain('page-side');
|
||||
expect(workspace.element.children[1]?.classList).toContain(
|
||||
'skill-list-page__content',
|
||||
);
|
||||
expect(workspace.get('.skill-list-page__content').classes()).toContain(
|
||||
'skill-list-page__content',
|
||||
);
|
||||
|
||||
const sidebar = workspace.get('#skill-category-sidebar');
|
||||
expect(sidebar.text()).toContain('全部');
|
||||
expect(sidebar.text()).toContain('研发');
|
||||
expect(sidebar.text()).toContain('平台');
|
||||
expect(sidebar.text()).not.toContain('未分类');
|
||||
expect(sidebar.get('.page-side__footer').text()).toContain('添加');
|
||||
|
||||
const developmentNode = sidebar
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('研发'));
|
||||
expect(developmentNode).toBeTruthy();
|
||||
await developmentNode?.trigger('click');
|
||||
expect(pageDataMocks.setQuery).toHaveBeenLastCalledWith({
|
||||
categoryId: 1,
|
||||
displayName: undefined,
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the complete flex height chain for the list area', () => {
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page\s*\{[^}]*display:\s*flex;[^}]*height:\s*100%;[^}]*min-height:\s*0;/,
|
||||
);
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page__workspace\s*\{[^}]*display:\s*grid;[^}]*flex:\s*1;[^}]*min-height:\s*0;/,
|
||||
);
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page__content\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1;[^}]*height:\s*100%;[^}]*min-height:\s*0;/,
|
||||
);
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page\s+:deep\(\.page-data-container\)\s*\{[^}]*height:\s*100%;/,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the compact creation dialog without navigating to a fake detail', async () => {
|
||||
const wrapper = await mountSkillList();
|
||||
const createButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('新建 Skill'));
|
||||
|
||||
await createButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="skill-create-dialog"]')
|
||||
.attributes('data-open'),
|
||||
).toBe('true');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('hides category write actions without category management permission', async () => {
|
||||
permissionMocks.hasPermission.mockImplementation(
|
||||
(permissions: string[]) =>
|
||||
!permissions.includes('/api/v1/skill/category'),
|
||||
);
|
||||
const wrapper = await mountSkillList();
|
||||
const sidebar = wrapper.get('#skill-category-sidebar');
|
||||
|
||||
expect(sidebar.find('.page-side__footer').exists()).toBe(false);
|
||||
expect(sidebar.findAll('.page-side__more')).toHaveLength(0);
|
||||
|
||||
wrapper.unmount();
|
||||
it('shows only the agreed business columns and omits 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('<h1>技能库</h1>');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
|
||||
import workbenchSource from './SkillResourceWorkbench.vue?raw';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createSkillFile: vi.fn(),
|
||||
@@ -58,6 +59,17 @@ const resources = [
|
||||
size: 12,
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
{
|
||||
content: 'const value: string = "ok";\n',
|
||||
isText: true,
|
||||
key: 'scripts/check.ts',
|
||||
language: 'TYPESCRIPT',
|
||||
mediaType: 'application/octet-stream',
|
||||
name: 'check.ts',
|
||||
path: 'scripts/check.ts',
|
||||
size: 28,
|
||||
type: 'SCRIPT',
|
||||
},
|
||||
];
|
||||
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
@@ -147,6 +159,14 @@ describe('skill resource workbench binary resources', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the shared TypeScript highlighter for script resources', async () => {
|
||||
await mountAndSelect('scripts/check.ts');
|
||||
|
||||
expect(
|
||||
wrapper?.find('[data-testid="code-editor"]').attributes('data-language'),
|
||||
).toBe('typescript');
|
||||
});
|
||||
|
||||
it('gives readonly generic text the shared copy and download viewer', async () => {
|
||||
await mountAndSelect('references/data.json', true);
|
||||
|
||||
@@ -310,17 +330,23 @@ describe('skill resource workbench validation', () => {
|
||||
});
|
||||
|
||||
describe('skill resource workbench navigation', () => {
|
||||
it('shows the standard directories and opens a directory-aware create form', async () => {
|
||||
await mountAndSelect('references');
|
||||
it('keeps the current file open when a directory is expanded', async () => {
|
||||
await mountAndSelect('references/data.json');
|
||||
|
||||
await wrapper?.get('[data-tree-path="references"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper?.find('[data-tree-path="scripts"]').exists()).toBe(true);
|
||||
expect(wrapper?.find('[data-tree-path="assets"]').exists()).toBe(true);
|
||||
expect(wrapper?.text()).toContain('此目录暂无内容');
|
||||
expect(wrapper?.text()).toContain('references/data.json');
|
||||
expect(wrapper?.find('[data-testid="code-editor"]').exists()).toBe(true);
|
||||
expect(wrapper?.text()).not.toContain('此目录暂无内容');
|
||||
|
||||
const createButton = wrapper
|
||||
?.findAll('.skill-resource-workbench__directory-state button')
|
||||
.find((button) => button.text().trim() === '新建文件');
|
||||
await createButton?.trigger('click');
|
||||
const referenceRow = wrapper?.get('[data-tree-path="references"]').element
|
||||
.parentElement;
|
||||
referenceRow
|
||||
?.querySelector<HTMLButtonElement>('[aria-label="在此目录新建文件"]')
|
||||
?.click();
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
@@ -334,47 +360,13 @@ describe('skill resource workbench navigation', () => {
|
||||
expect(fileNameInput?.props('modelValue')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps capability binding in the same left navigation and switches panels', async () => {
|
||||
wrapper = mount(SkillResourceWorkbench, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: {
|
||||
MarkdownLiveEditor: {
|
||||
template: '<div data-testid="markdown-live-editor"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
activePanel: 'resources',
|
||||
capabilityAvailable: true,
|
||||
capabilityCount: 3,
|
||||
skillId: 101,
|
||||
},
|
||||
slots: {
|
||||
capability: '<div data-testid="capability-slot">能力配置</div>',
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
it('keeps resource navigation focused on the standard package tree', async () => {
|
||||
await mountAndSelect('references');
|
||||
|
||||
const entry = wrapper.get('.skill-resource-workbench__capability-entry');
|
||||
expect(entry.text()).toContain('能力绑定');
|
||||
expect(entry.text()).toContain('3');
|
||||
expect(
|
||||
wrapper
|
||||
.get('.skill-resource-workbench__capability-area')
|
||||
.attributes('style'),
|
||||
).toContain('display: none');
|
||||
|
||||
await entry.trigger('click');
|
||||
expect(wrapper.emitted('update:activePanel')?.at(-1)).toEqual([
|
||||
'capability',
|
||||
]);
|
||||
await wrapper.setProps({ activePanel: 'capability' });
|
||||
expect(
|
||||
wrapper
|
||||
.get('.skill-resource-workbench__capability-area')
|
||||
.attributes('style'),
|
||||
).toBeUndefined();
|
||||
expect(entry.attributes('aria-current')).toBe('page');
|
||||
expect(wrapper?.text()).not.toContain('能力绑定');
|
||||
expect(workbenchSource).not.toContain('instructionsOnly');
|
||||
expect(workbenchSource).not.toContain('selectSkillFile');
|
||||
expect(workbenchSource).toContain('allow-create');
|
||||
expect(workbenchSource).toContain('placeholder="选择或输入目录"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,7 @@ import type {
|
||||
EditorLanguage,
|
||||
} from '@easyflow-core/editor-ui';
|
||||
|
||||
import type {
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillValidationIssue,
|
||||
} from './types';
|
||||
import type { SkillFileNode, SkillValidationIssue } from './types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
@@ -48,7 +44,6 @@ import {
|
||||
} from '@easyflow-core/editor-ui';
|
||||
|
||||
import {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Download,
|
||||
Fold,
|
||||
@@ -91,18 +86,10 @@ import { useSkillFileBuffers } from './use-skill-file-buffers';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
activePanel?: 'capability' | 'resources';
|
||||
capabilityAvailable?: boolean;
|
||||
capabilityCount?: number;
|
||||
initialContent?: string;
|
||||
readonly?: boolean;
|
||||
skillId: number | string;
|
||||
}>(),
|
||||
{
|
||||
activePanel: 'resources',
|
||||
capabilityAvailable: false,
|
||||
capabilityCount: 0,
|
||||
initialContent: '',
|
||||
readonly: false,
|
||||
},
|
||||
);
|
||||
@@ -110,11 +97,8 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
dirty: [dirty: boolean];
|
||||
issues: [issues: SkillValidationIssue[]];
|
||||
locateCapability: [];
|
||||
newContent: [content: string];
|
||||
requestSave: [];
|
||||
saved: [];
|
||||
'update:activePanel': [panel: 'capability' | 'resources'];
|
||||
}>();
|
||||
|
||||
const MarkdownLiveEditor = defineAsyncComponent(
|
||||
@@ -123,7 +107,6 @@ const MarkdownLiveEditor = defineAsyncComponent(
|
||||
const MAX_TEXT_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const STANDARD_DIRECTORIES = ['references', 'scripts', 'assets'] as const;
|
||||
|
||||
const isNew = computed(() => String(props.skillId) === 'new');
|
||||
const tree = ref<SkillFileNode[]>([]);
|
||||
const selectedPath = ref('SKILL.md');
|
||||
const loading = ref(false);
|
||||
@@ -275,17 +258,22 @@ const wordCount = computed(
|
||||
);
|
||||
const editorLanguage = computed<EditorLanguage>(() => {
|
||||
const language = String(currentFile.value?.language || '').toUpperCase();
|
||||
if (language === 'PYTHON' || currentFile.value?.path.endsWith('.py'))
|
||||
return 'python';
|
||||
if (
|
||||
language === 'JAVASCRIPT' ||
|
||||
/\.(?:js|mjs)$/.test(currentFile.value?.path || '')
|
||||
)
|
||||
const path = (currentFile.value?.path || '').toLowerCase();
|
||||
if (language === 'PYTHON' || path.endsWith('.py')) return 'python';
|
||||
if (language === 'TYPESCRIPT' || /\.(?:ts|tsx)$/.test(path))
|
||||
return 'typescript';
|
||||
if (language === 'JAVASCRIPT' || /\.(?:js|mjs|cjs|jsx)$/.test(path))
|
||||
return 'javascript';
|
||||
if (language === 'SHELL' || currentFile.value?.path.endsWith('.sh'))
|
||||
return 'shell';
|
||||
if (language === 'SHELL' || /\.(?:sh|bash|zsh)$/.test(path)) return 'shell';
|
||||
if (language === 'JSON' || path.endsWith('.json')) return 'json';
|
||||
if (language === 'YAML' || /\.(?:yaml|yml)$/.test(path)) return 'yaml';
|
||||
if (language === 'XML' || path.endsWith('.xml')) return 'xml';
|
||||
if (language === 'HTML' || /\.(?:html|htm|vue|svelte)$/.test(path))
|
||||
return 'html';
|
||||
if (language === 'CSS' || /\.(?:css|scss|less)$/.test(path)) return 'css';
|
||||
if (language === 'JAVA' || /\.(?:java|kt|kts)$/.test(path)) return 'java';
|
||||
if (language === 'SQL' || path.endsWith('.sql')) return 'sql';
|
||||
if (isMarkdown.value) return 'markdown';
|
||||
if (currentFile.value?.mediaType === 'application/json') return 'json';
|
||||
return 'text';
|
||||
});
|
||||
const lineCount = computed(() => contentMetrics.value.lines);
|
||||
@@ -297,9 +285,6 @@ watch(
|
||||
dirtyPaths,
|
||||
(value) => {
|
||||
emit('dirty', value.size > 0);
|
||||
if (value.has('SKILL.md')) {
|
||||
emit('newContent', buffers.get('SKILL.md')?.content || '');
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
@@ -325,16 +310,6 @@ watch([selectedPath, isLargeForLive, isMarkdown], ([, large, markdown]) => {
|
||||
});
|
||||
|
||||
watch(() => props.skillId, init);
|
||||
watch(
|
||||
() => props.initialContent,
|
||||
(content) => {
|
||||
if (!isNew.value) return;
|
||||
const buffer = buffers.get('SKILL.md');
|
||||
if (buffer && content !== buffer.content) {
|
||||
updateContent('SKILL.md', content || '');
|
||||
}
|
||||
},
|
||||
);
|
||||
watch([selectedPath, currentFile], loadAssetPreview);
|
||||
onMounted(init);
|
||||
onKeyStroke('Escape', () => {
|
||||
@@ -365,22 +340,6 @@ async function init() {
|
||||
selectedPath.value = 'SKILL.md';
|
||||
settledPath = 'SKILL.md';
|
||||
try {
|
||||
if (isNew.value) {
|
||||
const content =
|
||||
props.initialContent ||
|
||||
'---\nname: new-skill\ndescription: New skill\n---\n\n# Instructions\n';
|
||||
const file: SkillFileContent = {
|
||||
content,
|
||||
path: 'SKILL.md',
|
||||
type: 'SKILL',
|
||||
};
|
||||
if (!isCurrentRequest(requestedSkillId, request)) return;
|
||||
ensureBuffer(file);
|
||||
tree.value = ensureStandardTree([
|
||||
{ key: 'SKILL.md', name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
if (!(await loadTree(request))) return;
|
||||
await selectFile('SKILL.md', request);
|
||||
} catch {
|
||||
@@ -420,19 +379,13 @@ async function selectFile(
|
||||
) {
|
||||
const path = typeof pathOrNode === 'string' ? pathOrNode : pathOrNode.path;
|
||||
if (typeof pathOrNode !== 'string' && pathOrNode.type === 'DIRECTORY') {
|
||||
fileContentRequest++;
|
||||
loadingRequest++;
|
||||
loading.value = false;
|
||||
loadError.value = '';
|
||||
selectedPath.value = path;
|
||||
settledPath = path;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
const request = ++fileContentRequest;
|
||||
const requestedSkillId = String(props.skillId);
|
||||
const previousPath = settledPath;
|
||||
selectedPath.value = path;
|
||||
if (buffers.has(path) || isNew.value) {
|
||||
if (buffers.has(path)) {
|
||||
settledPath = path;
|
||||
loadingRequest++;
|
||||
loading.value = false;
|
||||
@@ -490,22 +443,13 @@ async function selectFileFromTree(
|
||||
node: { path: string; type?: string },
|
||||
closeTree: () => void,
|
||||
) {
|
||||
emit('update:activePanel', 'resources');
|
||||
await selectFile(node);
|
||||
closeTree();
|
||||
}
|
||||
|
||||
function selectCapabilityPanel(closeTree: () => void) {
|
||||
if (!props.capabilityAvailable) return;
|
||||
inspector.value = undefined;
|
||||
fullscreen.value = false;
|
||||
emit('update:activePanel', 'capability');
|
||||
closeTree();
|
||||
}
|
||||
|
||||
async function saveBuffer(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer || buffer.content === buffer.original || isNew.value) return true;
|
||||
if (!buffer || buffer.content === buffer.original) return true;
|
||||
if (buffer.saveState === 'saving') return false;
|
||||
if (buffer.conflict) {
|
||||
markError(path);
|
||||
@@ -547,7 +491,7 @@ async function saveBuffer(path: string) {
|
||||
|
||||
async function reloadCurrent() {
|
||||
const buffer = currentBuffer.value;
|
||||
if (!buffer || isNew.value || fileMutation.value || savingAll.value) return;
|
||||
if (!buffer || fileMutation.value || savingAll.value) return;
|
||||
fileMutation.value = 'reload';
|
||||
try {
|
||||
if (buffer.content !== buffer.original) {
|
||||
@@ -577,10 +521,6 @@ async function saveAll() {
|
||||
if (savingAll.value || fileMutation.value) return false;
|
||||
savingAll.value = true;
|
||||
try {
|
||||
if (isNew.value) {
|
||||
emit('newContent', buffers.get('SKILL.md')?.content || '');
|
||||
return true;
|
||||
}
|
||||
for (const path of dirtyPaths.value) {
|
||||
if (!(await saveBuffer(path))) return false;
|
||||
}
|
||||
@@ -594,11 +534,6 @@ async function saveAll() {
|
||||
|
||||
async function validateAll() {
|
||||
if (validating.value || savingAll.value || fileMutation.value) return false;
|
||||
if (isNew.value) {
|
||||
issues.value = [];
|
||||
emit('issues', []);
|
||||
return true;
|
||||
}
|
||||
validating.value = true;
|
||||
try {
|
||||
const res = await validateSkillForPublish(props.skillId);
|
||||
@@ -616,11 +551,6 @@ async function validateAll() {
|
||||
|
||||
async function locateIssue(issue: DocumentValidationIssue) {
|
||||
if (!issue.path) return;
|
||||
if (issue.path.startsWith('capabilities')) {
|
||||
inspector.value = undefined;
|
||||
emit('locateCapability');
|
||||
return;
|
||||
}
|
||||
if (!flattenFilePaths(tree.value).includes(issue.path)) {
|
||||
ElMessage.warning(`无法定位校验项:${issue.path}`);
|
||||
return;
|
||||
@@ -638,18 +568,7 @@ async function locateIssue(issue: DocumentValidationIssue) {
|
||||
|
||||
function openCreateFile(node?: DocumentFileNode) {
|
||||
if (props.readonly || fileMutation.value || savingAll.value) return;
|
||||
if (isNew.value) {
|
||||
ElMessage.info('请先保存 Skill,再添加资源文件');
|
||||
return;
|
||||
}
|
||||
const selectedDirectory =
|
||||
node?.type === 'DIRECTORY'
|
||||
? node.path
|
||||
: currentNode.value?.type === 'DIRECTORY'
|
||||
? currentNode.value.path
|
||||
: selectedPath.value.includes('/')
|
||||
? selectedPath.value.slice(0, selectedPath.value.lastIndexOf('/'))
|
||||
: 'references';
|
||||
const selectedDirectory = resolveSelectedDirectory(node, 'references');
|
||||
createFileForm.directory = directoryOptions.value.some(
|
||||
(option) => option.path === selectedDirectory,
|
||||
)
|
||||
@@ -699,19 +618,20 @@ async function submitCreateFile() {
|
||||
|
||||
function chooseUpload(node?: DocumentFileNode) {
|
||||
if (props.readonly || fileMutation.value || savingAll.value) return;
|
||||
if (isNew.value) {
|
||||
ElMessage.info('请先保存 Skill,再上传资源');
|
||||
return;
|
||||
}
|
||||
uploadDirectory.value =
|
||||
node?.type === 'DIRECTORY'
|
||||
? node.path
|
||||
: currentNode.value?.type === 'DIRECTORY'
|
||||
? currentNode.value.path
|
||||
: 'assets';
|
||||
uploadDirectory.value = resolveSelectedDirectory(node, 'assets');
|
||||
uploadInputRef.value?.click();
|
||||
}
|
||||
|
||||
function resolveSelectedDirectory(
|
||||
node: DocumentFileNode | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
if (node?.type === 'DIRECTORY') return node.path;
|
||||
if (currentNode.value?.type === 'DIRECTORY') return currentNode.value.path;
|
||||
const separator = selectedPath.value.lastIndexOf('/');
|
||||
return separator > 0 ? selectedPath.value.slice(0, separator) : fallback;
|
||||
}
|
||||
|
||||
async function handleUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
@@ -802,7 +722,6 @@ async function renameCurrent() {
|
||||
if (
|
||||
!currentFile.value ||
|
||||
currentFile.value.path === 'SKILL.md' ||
|
||||
isNew.value ||
|
||||
fileMutation.value ||
|
||||
savingAll.value
|
||||
)
|
||||
@@ -880,7 +799,6 @@ async function removeCurrent() {
|
||||
if (
|
||||
!currentFile.value ||
|
||||
currentFile.value.path === 'SKILL.md' ||
|
||||
isNew.value ||
|
||||
fileMutation.value ||
|
||||
savingAll.value
|
||||
)
|
||||
@@ -940,7 +858,7 @@ async function downloadNode(node: DocumentFileNode) {
|
||||
}
|
||||
|
||||
async function downloadCurrent() {
|
||||
if (!currentFile.value || isNew.value) return;
|
||||
if (!currentFile.value) return;
|
||||
try {
|
||||
const blob =
|
||||
buildSkillTextDownload(currentFile.value, currentContent.value) ||
|
||||
@@ -972,7 +890,6 @@ async function loadAssetPreview() {
|
||||
assetPreviewError.value = false;
|
||||
if (
|
||||
!isBinaryResource.value ||
|
||||
isNew.value ||
|
||||
(!isImageResource.value && !isPdfResource.value)
|
||||
)
|
||||
return;
|
||||
@@ -1038,7 +955,6 @@ async function resolveMarkdownImage(url: string) {
|
||||
if (/^[a-z][a-z\d+.-]*:|^\/\//i.test(url)) {
|
||||
return EMPTY_IMAGE_DATA_URL;
|
||||
}
|
||||
if (isNew.value) return EMPTY_IMAGE_DATA_URL;
|
||||
const path = resolveSkillRelativePath(selectedPath.value, url);
|
||||
const node = path ? findFileNode(tree.value, path) : undefined;
|
||||
if (!path || !node || !isSafeImageNode(node)) return EMPTY_IMAGE_DATA_URL;
|
||||
@@ -1269,7 +1185,7 @@ defineExpose({
|
||||
:busy="Boolean(fileMutation) || savingAll"
|
||||
:nodes="tree"
|
||||
:readonly="readonly"
|
||||
:current-path="activePanel === 'resources' ? selectedPath : ''"
|
||||
:current-path="selectedPath"
|
||||
:dirty-paths="dirtyPaths"
|
||||
:error-counts="errorCounts"
|
||||
:warning-counts="warningCounts"
|
||||
@@ -1280,20 +1196,6 @@ defineExpose({
|
||||
@rename="renameNode"
|
||||
@upload="chooseUpload"
|
||||
/>
|
||||
<button
|
||||
v-if="capabilityAvailable"
|
||||
type="button"
|
||||
class="skill-resource-workbench__capability-entry"
|
||||
:class="{ 'is-active': activePanel === 'capability' }"
|
||||
:aria-current="activePanel === 'capability' ? 'page' : undefined"
|
||||
@click="selectCapabilityPanel(close)"
|
||||
>
|
||||
<span>
|
||||
<Connection aria-hidden="true" />
|
||||
能力绑定
|
||||
</span>
|
||||
<small>{{ capabilityCount }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
@@ -1303,7 +1205,7 @@ defineExpose({
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="activePanel === 'resources'" #toolbar>
|
||||
<template #toolbar>
|
||||
<div class="skill-resource-workbench__toolbar">
|
||||
<div class="skill-resource-workbench__file-identity">
|
||||
<strong>{{
|
||||
@@ -1409,17 +1311,7 @@ defineExpose({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-show="activePanel === 'capability'"
|
||||
class="skill-resource-workbench__capability-area"
|
||||
>
|
||||
<slot name="capability"></slot>
|
||||
</div>
|
||||
<div
|
||||
v-show="activePanel === 'resources'"
|
||||
v-loading="loading"
|
||||
class="skill-resource-workbench__editor-area"
|
||||
>
|
||||
<div v-loading="loading" class="skill-resource-workbench__editor-area">
|
||||
<template v-if="currentFile">
|
||||
<MarkdownLiveEditor
|
||||
v-if="isMarkdown && markdownMode === 'live' && !isLargeForLive"
|
||||
@@ -1427,7 +1319,7 @@ defineExpose({
|
||||
class="skill-resource-workbench__editor"
|
||||
:readonly="readonly"
|
||||
:resolve-image-url="resolveMarkdownImage"
|
||||
:upload-image="isNew || readonly ? undefined : uploadInlineImage"
|
||||
:upload-image="readonly ? undefined : uploadInlineImage"
|
||||
@error="handleEditorError"
|
||||
@fidelity-loss="handleMarkdownFidelityLoss"
|
||||
@frontmatter-activate="handleFrontmatterActivate"
|
||||
@@ -1540,19 +1432,6 @@ defineExpose({
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else-if="currentNode?.type === 'DIRECTORY'"
|
||||
class="skill-resource-workbench__directory-state"
|
||||
>
|
||||
<strong>{{ currentNode.name }}</strong>
|
||||
<span>此目录暂无内容</span>
|
||||
<div v-if="!readonly">
|
||||
<ElButton type="primary" @click="openCreateFile(currentNode)">
|
||||
新建文件
|
||||
</ElButton>
|
||||
<ElButton @click="chooseUpload(currentNode)">上传资源</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<ElEmpty v-else :description="loadError || '从左侧选择文件'">
|
||||
<ElButton v-if="loadError" @click="init">重新加载</ElButton>
|
||||
</ElEmpty>
|
||||
@@ -1560,7 +1439,8 @@ defineExpose({
|
||||
|
||||
<template #status>
|
||||
<EditorStatusBar
|
||||
v-if="activePanel === 'resources' && currentFile && !isBinaryResource"
|
||||
v-if="currentFile && !isBinaryResource"
|
||||
class="skill-resource-workbench__status"
|
||||
:column="cursor.column"
|
||||
:language="editorLanguage"
|
||||
:line="cursor.line"
|
||||
@@ -1602,7 +1482,13 @@ defineExpose({
|
||||
show-icon
|
||||
/>
|
||||
<ElFormItem label="所属目录" prop="directory">
|
||||
<ElSelect v-model="createFileForm.directory">
|
||||
<ElSelect
|
||||
v-model="createFileForm.directory"
|
||||
allow-create
|
||||
default-first-option
|
||||
filterable
|
||||
placeholder="选择或输入目录"
|
||||
>
|
||||
<ElOption
|
||||
v-for="directory in directoryOptions"
|
||||
:key="directory.path"
|
||||
@@ -1653,58 +1539,6 @@ defineExpose({
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-entry {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-3);
|
||||
margin: var(--space-2);
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-entry > span {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-entry svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-entry small {
|
||||
display: grid;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
place-items: center;
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-entry:hover,
|
||||
.skill-resource-workbench__capability-entry.is-active {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-entry:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
@@ -1734,7 +1568,6 @@ defineExpose({
|
||||
}
|
||||
|
||||
.skill-resource-workbench__editor-area,
|
||||
.skill-resource-workbench__capability-area,
|
||||
.skill-resource-workbench__editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -1742,8 +1575,15 @@ defineExpose({
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__capability-area {
|
||||
overflow: hidden;
|
||||
.skill-resource-workbench__status {
|
||||
position: absolute;
|
||||
right: var(--space-3);
|
||||
bottom: var(--space-3);
|
||||
z-index: 2;
|
||||
min-height: 28px;
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
.skill-resource-workbench__asset {
|
||||
@@ -1841,27 +1681,6 @@ defineExpose({
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.skill-resource-workbench__directory-state {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
color: hsl(var(--text-muted));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__directory-state strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.skill-resource-workbench__directory-state > div {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.skill-resource-workbench__toolbar {
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -59,7 +59,6 @@ describe('skill settings dialog', () => {
|
||||
skill: {
|
||||
categoryId: 8,
|
||||
displayName: '原 Skill',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
@@ -78,7 +77,6 @@ describe('skill settings dialog', () => {
|
||||
expect.objectContaining({
|
||||
categoryId: 8,
|
||||
displayName: '更新后的 Skill',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
visibilityScope: 'DEPT',
|
||||
}),
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import { updateSkill } from './api';
|
||||
@@ -79,7 +78,6 @@ function resetForm() {
|
||||
categoryId: props.skill.categoryId || '',
|
||||
categoryName: props.skill.categoryName,
|
||||
displayName: props.skill.displayName || '',
|
||||
enabled: props.skill.enabled !== false,
|
||||
id: props.skill.id,
|
||||
visibilityScope: props.skill.visibilityScope || 'PRIVATE',
|
||||
});
|
||||
@@ -202,13 +200,6 @@ watch(
|
||||
<ElOption label="全员可见" value="PUBLIC" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="启用状态">
|
||||
<div class="skill-settings-form__switch">
|
||||
<ElSwitch v-model="form.enabled" />
|
||||
<span>{{ form.enabled === false ? '停用' : '启用' }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
@@ -222,8 +213,7 @@ watch(
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-settings-form__category-field,
|
||||
.skill-settings-form__switch {
|
||||
.skill-settings-form__category-field {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
@@ -234,8 +224,4 @@ watch(
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-settings-form__switch {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type {
|
||||
RequestResult,
|
||||
SkillCapabilityBinding,
|
||||
SkillCapabilityCandidate,
|
||||
SkillCapabilityReplaceResult,
|
||||
SkillCapabilityTools,
|
||||
SkillCapabilityType,
|
||||
SkillCategory,
|
||||
SkillCategoryDraft,
|
||||
SkillExportFormat,
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillImportBatchResult,
|
||||
SkillImportConfirmPayload,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillValidationResult,
|
||||
@@ -17,7 +13,6 @@ import type {
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
import { buildCapabilityBindingsPayload } from './skill-capability';
|
||||
import { buildSkillCategoryPayload } from './skill-category';
|
||||
import { buildSkillDraftPayload } from './skill-draft';
|
||||
|
||||
@@ -60,18 +55,14 @@ export function validateSkillForPublish(id: number | string) {
|
||||
export function getSkillCategories() {
|
||||
return api.get<RequestResult<SkillCategory[]>>(
|
||||
'/api/v1/skill/category/visibleList',
|
||||
{
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
},
|
||||
{ params: { sortKey: 'sortNo', sortType: 'asc' } },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCategoryTree() {
|
||||
return api.get<RequestResult<SkillCategory[]>>(
|
||||
'/api/v1/skill/category/tree',
|
||||
{
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
},
|
||||
{ params: { sortKey: 'sortNo', sortType: 'asc' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,10 +84,13 @@ export function deleteSkillCategory(id: number | string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/category/remove', { id });
|
||||
}
|
||||
|
||||
export function submitSkillPublishApproval(id: number | string) {
|
||||
export function submitSkillPublishApproval(
|
||||
id: number | string,
|
||||
applicationReason: string,
|
||||
) {
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitPublishApproval',
|
||||
{ id },
|
||||
{ applicationReason, id },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,9 +117,7 @@ export function getSkillFileTree(skillId: number | string) {
|
||||
export function getSkillFileContent(skillId: number | string, path: string) {
|
||||
return api.get<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/content',
|
||||
{
|
||||
params: { path, skillId },
|
||||
},
|
||||
{ params: { path, skillId } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,9 +188,8 @@ export function uploadSkillFile(
|
||||
const formData = new FormData();
|
||||
formData.append('skillId', String(skillId));
|
||||
formData.append('path', path);
|
||||
if (expectedContentHash) {
|
||||
if (expectedContentHash)
|
||||
formData.append('expectedContentHash', expectedContentHash);
|
||||
}
|
||||
formData.append('file', file);
|
||||
return api.postFile<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/upload',
|
||||
@@ -218,71 +209,18 @@ export function previewSkillFile(skillId: number | string, path: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getSkillCapabilityBindings(skillId: number | string) {
|
||||
return api.get<RequestResult<SkillCapabilityBinding[]>>(
|
||||
'/api/v1/skill/capability/list',
|
||||
{
|
||||
params: { skillId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCapabilityCandidates(
|
||||
type: SkillCapabilityType,
|
||||
keyword = '',
|
||||
) {
|
||||
return api.get<RequestResult<SkillCapabilityCandidate[]>>(
|
||||
'/api/v1/skill/capability/candidates',
|
||||
{ params: { keyword, type } },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCapabilityTools(targetId: number | string) {
|
||||
return api.get<RequestResult<SkillCapabilityTools>>(
|
||||
'/api/v1/skill/capability/tools',
|
||||
{
|
||||
params: { targetId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceSkillCapabilityBindings(
|
||||
skillId: number | string,
|
||||
bindings: SkillCapabilityBinding[],
|
||||
expectedCapabilityHash: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillCapabilityReplaceResult>>(
|
||||
'/api/v1/skill/capability/replace',
|
||||
{
|
||||
bindings: buildCapabilityBindingsPayload(bindings),
|
||||
expectedCapabilityHash,
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillPreview(file: File) {
|
||||
export function importSkillPreviews(files: File[]) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.postFile<RequestResult<SkillImportPreview>>(
|
||||
files.forEach((file) => formData.append('files', file));
|
||||
return api.postFile<RequestResult<SkillImportPreview[]>>(
|
||||
'/api/v1/skill/import/preview',
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillConfirm(payload: {
|
||||
capabilityMappings?: Array<{
|
||||
bindingKey: string;
|
||||
disabled?: boolean;
|
||||
targetId?: number | string;
|
||||
}>;
|
||||
categoryId?: number | string;
|
||||
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
importToken: string;
|
||||
renames?: Record<string, string>;
|
||||
}) {
|
||||
return api.post<RequestResult<SkillInfo[]>>(
|
||||
'/api/v1/skill/import/confirm',
|
||||
export function importSkillConfirmBatch(payload: SkillImportConfirmPayload[]) {
|
||||
return api.post<RequestResult<SkillImportBatchResult[]>>(
|
||||
'/api/v1/skill/import/confirmBatch',
|
||||
payload,
|
||||
);
|
||||
}
|
||||
@@ -293,12 +231,9 @@ export function cancelSkillImport(importToken: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function exportSkills(
|
||||
ids: Array<number | string>,
|
||||
format: SkillExportFormat,
|
||||
) {
|
||||
export function exportSkills(ids: Array<number | string>) {
|
||||
return api.download<Blob>('/api/v1/skill/export', {
|
||||
data: { format, ids },
|
||||
data: { ids },
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isSkillAccessDeniedError,
|
||||
isSkillCapabilityConflictError,
|
||||
isSkillFileConflictError,
|
||||
resolveSkillApiErrorMessage,
|
||||
} from './skill-api-error';
|
||||
@@ -15,23 +14,12 @@ describe('skill API conflict errors', () => {
|
||||
message: '文件已被其他操作更新',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSkillCapabilityConflictError({
|
||||
errorCode: 4093,
|
||||
message: '能力配置已被其他操作更新',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('also recognizes Axios-shaped conflicts and ignores ordinary errors', () => {
|
||||
expect(
|
||||
isSkillFileConflictError({ response: { data: {}, status: 409 } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSkillCapabilityConflictError({
|
||||
response: { data: { errorCode: 4093 }, status: 400 },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isSkillFileConflictError({ errorCode: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -43,16 +43,6 @@ export function isSkillFileConflictError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断能力配置请求是否因版本冲突失败。 */
|
||||
export function isSkillCapabilityConflictError(error: unknown) {
|
||||
const snapshot = errorSnapshot(error);
|
||||
return (
|
||||
snapshot.status === 409 ||
|
||||
snapshot.errorCode === 4093 ||
|
||||
snapshot.message.includes('能力配置已被其他操作更新')
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断 Skill 请求是否被服务端以未登录或无权限拒绝。 */
|
||||
export function isSkillAccessDeniedError(error: unknown) {
|
||||
const snapshot = errorSnapshot(error);
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCapabilityBindingPayload,
|
||||
resolveCapabilityIssueIndex,
|
||||
sanitizeCapabilityOptions,
|
||||
sanitizeHitlConfig,
|
||||
shouldLoadMcpTools,
|
||||
} from './skill-capability';
|
||||
|
||||
describe('skill capability payload', () => {
|
||||
it('only sends writable binding fields', () => {
|
||||
expect(
|
||||
buildCapabilityBindingPayload({
|
||||
capabilityType: 'MCP',
|
||||
enabled: true,
|
||||
executionMode: 'ASYNC',
|
||||
hitlConfigJson: {
|
||||
prompt: ' Continue? ',
|
||||
token: 'must-not-leak',
|
||||
},
|
||||
id: 9,
|
||||
resolvedToolNames: ['server-only'],
|
||||
runtimeName: 'search',
|
||||
selectedToolNamesJson: ['query'],
|
||||
selectionMode: 'SELECTED',
|
||||
sortNo: 0,
|
||||
targetId: 12,
|
||||
targetLogicalRef: 'mcp:search',
|
||||
targetName: 'Search MCP',
|
||||
targetStatus: 'AVAILABLE',
|
||||
optionsJson: {
|
||||
headers: { Authorization: 'secret' },
|
||||
readOnly: true,
|
||||
retryCount: 2,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
capabilityType: 'MCP',
|
||||
enabled: true,
|
||||
executionMode: undefined,
|
||||
hitlConfigJson: { prompt: 'Continue?' },
|
||||
hitlEnabled: undefined,
|
||||
optionsJson: { readOnly: true, retryCount: 2 },
|
||||
runtimeName: 'search',
|
||||
selectedToolNamesJson: ['query'],
|
||||
selectionMode: 'SELECTED',
|
||||
sortNo: 0,
|
||||
targetId: 12,
|
||||
targetLogicalRef: 'mcp:search',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes non-MCP execution mode and drops MCP-only fields', () => {
|
||||
expect(
|
||||
buildCapabilityBindingPayload({
|
||||
capabilityType: 'WORKFLOW',
|
||||
enabled: true,
|
||||
runtimeName: 'review',
|
||||
selectedToolNamesJson: ['ignored'],
|
||||
selectionMode: 'SELECTED',
|
||||
targetId: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
executionMode: 'SYNC',
|
||||
selectedToolNamesJson: undefined,
|
||||
selectionMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('strictly whitelists scalar HITL and execution options', () => {
|
||||
expect(
|
||||
sanitizeHitlConfig({
|
||||
cancelLabel: 'Cancel',
|
||||
nested: { prompt: 'unsafe' },
|
||||
prompt: 'Approve this call?',
|
||||
secret: 'hidden',
|
||||
}),
|
||||
).toEqual({ cancelLabel: 'Cancel', prompt: 'Approve this call?' });
|
||||
expect(
|
||||
sanitizeCapabilityOptions({
|
||||
async: false,
|
||||
credential: 'hidden',
|
||||
readOnly: true,
|
||||
retryCount: 1.9,
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).toEqual({
|
||||
async: false,
|
||||
readOnly: true,
|
||||
retryCount: 1,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('locates a binding from backend validation paths', () => {
|
||||
expect(resolveCapabilityIssueIndex('capabilities[2].runtimeName')).toBe(2);
|
||||
expect(
|
||||
resolveCapabilityIssueIndex('skills[demo].capabilities[1].targetId'),
|
||||
).toBe(1);
|
||||
expect(resolveCapabilityIssueIndex('SKILL.md')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads MCP tools only for a selected scope with a concrete target', () => {
|
||||
expect(shouldLoadMcpTools('MCP', 'ALL', 12)).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED')).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED', '')).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED', ' ')).toBe(false);
|
||||
expect(shouldLoadMcpTools('WORKFLOW', 'SELECTED', 12)).toBe(false);
|
||||
expect(shouldLoadMcpTools('PLUGIN_ITEM', 'SELECTED', 12)).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED', 12)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import type { SkillCapabilityBinding } from './types';
|
||||
|
||||
export interface SkillCapabilityBindingPayload {
|
||||
capabilityType: SkillCapabilityBinding['capabilityType'];
|
||||
enabled: boolean;
|
||||
executionMode?: SkillCapabilityBinding['executionMode'];
|
||||
hitlConfigJson?: Record<string, unknown>;
|
||||
hitlEnabled?: boolean;
|
||||
optionsJson?: Record<string, unknown>;
|
||||
runtimeName?: string;
|
||||
selectedToolNamesJson?: string[];
|
||||
selectionMode?: SkillCapabilityBinding['selectionMode'];
|
||||
sortNo?: number;
|
||||
targetId?: number | string;
|
||||
targetLogicalRef?: string;
|
||||
}
|
||||
|
||||
/** Build the narrow capability DTO accepted by replace and validation APIs. */
|
||||
export function buildCapabilityBindingPayload(
|
||||
binding: SkillCapabilityBinding,
|
||||
): SkillCapabilityBindingPayload {
|
||||
const isMcp = binding.capabilityType === 'MCP';
|
||||
return {
|
||||
capabilityType: binding.capabilityType,
|
||||
enabled: binding.enabled,
|
||||
executionMode: isMcp
|
||||
? undefined
|
||||
: normalizeExecutionMode(binding.executionMode),
|
||||
hitlConfigJson: sanitizeHitlConfig(binding.hitlConfigJson),
|
||||
hitlEnabled: binding.hitlEnabled,
|
||||
optionsJson: sanitizeCapabilityOptions(binding.optionsJson),
|
||||
runtimeName: binding.runtimeName,
|
||||
selectedToolNamesJson: isMcp ? binding.selectedToolNamesJson : undefined,
|
||||
selectionMode: isMcp ? binding.selectionMode : undefined,
|
||||
sortNo: binding.sortNo,
|
||||
targetId: binding.targetId,
|
||||
targetLogicalRef: binding.targetLogicalRef,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps only the non-sensitive HITL strings supported by the backend contract. */
|
||||
export function sanitizeHitlConfig(source?: Record<string, unknown>) {
|
||||
return compactRecord({
|
||||
cancelLabel: readString(source?.cancelLabel, 32),
|
||||
confirmLabel: readString(source?.confirmLabel, 32),
|
||||
description: readString(source?.description, 500),
|
||||
prompt: readString(source?.prompt, 1000),
|
||||
title: readString(source?.title, 128),
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps only scalar, non-sensitive execution options supported by the contract. */
|
||||
export function sanitizeCapabilityOptions(source?: Record<string, unknown>) {
|
||||
return compactRecord({
|
||||
async: readBoolean(source?.async),
|
||||
readOnly: readBoolean(source?.readOnly),
|
||||
retryCount: readInteger(source?.retryCount),
|
||||
timeoutMs: readInteger(source?.timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExecutionMode(value?: 'ASYNC' | 'SYNC') {
|
||||
return value === 'ASYNC' ? 'ASYNC' : 'SYNC';
|
||||
}
|
||||
|
||||
function readString(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().slice(0, maxLength);
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown) {
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
function readInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function compactRecord(source: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCapabilityBindingsPayload(
|
||||
bindings?: SkillCapabilityBinding[],
|
||||
) {
|
||||
return bindings?.map(buildCapabilityBindingPayload);
|
||||
}
|
||||
|
||||
/** MCP tools are fetched only when a concrete target uses an explicit allowlist. */
|
||||
export function shouldLoadMcpTools(
|
||||
capabilityType: SkillCapabilityBinding['capabilityType'],
|
||||
selectionMode: SkillCapabilityBinding['selectionMode'],
|
||||
targetId?: number | string,
|
||||
): targetId is number | string {
|
||||
return (
|
||||
capabilityType === 'MCP' &&
|
||||
selectionMode === 'SELECTED' &&
|
||||
targetId !== undefined &&
|
||||
targetId !== null &&
|
||||
String(targetId).trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** 从结构化校验路径中解析能力绑定序号。 */
|
||||
export function resolveCapabilityIssueIndex(path?: string) {
|
||||
const match = path?.match(/(?:^|\.)capabilities\[(\d+)\](?:\.|$)/);
|
||||
return match?.[1] === undefined ? undefined : Number(match[1]);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ describe('skill creation draft', () => {
|
||||
categoryId: 8,
|
||||
description: '研究助手',
|
||||
displayName: '研究助手',
|
||||
enabled: true,
|
||||
publishStatus: 'DRAFT',
|
||||
visibilityScope: 'DEPT',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { SkillInfo } from './types';
|
||||
import type { SkillInfo, SkillVisibilityScope } from './types';
|
||||
|
||||
import { syncSkillMarkdownFrontmatter } from './skill-markdown';
|
||||
|
||||
export type SkillCreateIntent = 'DRAFT' | 'PUBLISH';
|
||||
|
||||
/** Build a hidden, standards-compliant canonical name for a new Skill. */
|
||||
export function createSkillCanonicalName(
|
||||
displayName: string,
|
||||
@@ -24,7 +22,7 @@ export function createSkillCanonicalName(
|
||||
export function buildInitialSkillDraft(input: {
|
||||
categoryId?: number | string;
|
||||
displayName: string;
|
||||
visibilityScope: string;
|
||||
visibilityScope: SkillVisibilityScope;
|
||||
}): SkillInfo {
|
||||
const displayName = input.displayName.trim();
|
||||
const name = createSkillCanonicalName(displayName);
|
||||
@@ -37,7 +35,6 @@ export function buildInitialSkillDraft(input: {
|
||||
categoryId: input.categoryId || undefined,
|
||||
description: displayName,
|
||||
displayName,
|
||||
enabled: true,
|
||||
name,
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent: content,
|
||||
|
||||
@@ -10,11 +10,9 @@ describe('skill draft payload', () => {
|
||||
categoryId: 3,
|
||||
description: 'description',
|
||||
displayName: 'Display name',
|
||||
enabled: true,
|
||||
id: 9,
|
||||
name: 'standard-name',
|
||||
packageHash: 'must-not-leak',
|
||||
resourceCount: 7,
|
||||
resources: [{ contentRef: 'must-not-leak', path: 'secret.md' }],
|
||||
skillContent: 'content',
|
||||
visibilityScope: 'PRIVATE',
|
||||
@@ -25,7 +23,6 @@ describe('skill draft payload', () => {
|
||||
expect(payload).toEqual({
|
||||
categoryId: 3,
|
||||
displayName: 'Display name',
|
||||
enabled: true,
|
||||
skillContent: 'content',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
@@ -37,7 +34,6 @@ describe('skill draft payload', () => {
|
||||
).toEqual({
|
||||
categoryId: undefined,
|
||||
displayName: undefined,
|
||||
enabled: undefined,
|
||||
id: 9,
|
||||
visibilityScope: undefined,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { SkillInfo } from './types';
|
||||
export interface SkillDraftPayload {
|
||||
categoryId?: number | string;
|
||||
displayName?: string;
|
||||
enabled?: boolean;
|
||||
id?: number | string;
|
||||
skillContent?: string;
|
||||
visibilityScope?: string;
|
||||
@@ -25,7 +24,6 @@ export function buildSkillDraftPayload(
|
||||
const payload: SkillDraftPayload = {
|
||||
categoryId: skill.categoryId,
|
||||
displayName: skill.displayName,
|
||||
enabled: skill.enabled,
|
||||
visibilityScope: skill.visibilityScope,
|
||||
};
|
||||
if (includeId) payload.id = skill.id;
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildSkillImportConfirmPayload,
|
||||
countBlockingImportIssues,
|
||||
invalidateConsumedImportPreview,
|
||||
resolveSkillImportConflictReasonLabel,
|
||||
resolveSkillImportStep,
|
||||
} from './skill-import';
|
||||
|
||||
describe('skill import confirmation', () => {
|
||||
it('confirms with the preview token and never requires the uploaded file again', () => {
|
||||
const payload = buildSkillImportConfirmPayload(
|
||||
{
|
||||
format: 'EASYFLOW',
|
||||
importToken: 'one-time-token',
|
||||
skills: [{ conflict: false, name: 'research', packageId: 'pkg-1' }],
|
||||
},
|
||||
{
|
||||
categoryId: 12,
|
||||
conflictStrategy: 'RENAME',
|
||||
renames: { 'pkg-1': 'research-copy' },
|
||||
},
|
||||
[
|
||||
{
|
||||
bindingKey: 'binding-1',
|
||||
capabilityType: 'WORKFLOW',
|
||||
disabled: false,
|
||||
status: 'RESOLVED',
|
||||
targetId: 42,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(payload.importToken).toBe('one-time-token');
|
||||
expect(payload).not.toHaveProperty('file');
|
||||
expect(payload.capabilityMappings).toEqual([
|
||||
{ bindingKey: 'binding-1', disabled: false, targetId: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks package and item-level validation errors', () => {
|
||||
expect(
|
||||
countBlockingImportIssues({
|
||||
format: 'STANDARD',
|
||||
importToken: 'token',
|
||||
issues: [{ message: 'unsafe zip', severity: 'ERROR' }],
|
||||
skills: [
|
||||
{
|
||||
conflict: false,
|
||||
name: 'research',
|
||||
packageId: 'pkg-1',
|
||||
validationIssues: [{ message: 'missing name', severity: 'ERROR' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('removes an auto-resolved target when the mapping is explicitly disabled', () => {
|
||||
const payload = buildSkillImportConfirmPayload(
|
||||
{
|
||||
format: 'EASYFLOW',
|
||||
importToken: 'one-time-token',
|
||||
skills: [{ conflict: false, name: 'research', packageId: 'pkg-1' }],
|
||||
},
|
||||
{ conflictStrategy: 'REJECT' },
|
||||
[
|
||||
{
|
||||
bindingKey: 'binding-1',
|
||||
capabilityType: 'WORKFLOW',
|
||||
disabled: true,
|
||||
status: 'RESOLVED',
|
||||
targetId: 42,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(payload.capabilityMappings).toEqual([
|
||||
{ bindingKey: 'binding-1', disabled: true, targetId: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('invalidates a consumed preview token after any confirm failure', () => {
|
||||
const preview = {
|
||||
format: 'STANDARD' as const,
|
||||
importToken: 'one-time-token',
|
||||
skills: [{ conflict: false, name: 'research', packageId: 'pkg-1' }],
|
||||
};
|
||||
|
||||
expect(invalidateConsumedImportPreview(preview)).toEqual({
|
||||
...preview,
|
||||
importToken: '',
|
||||
});
|
||||
expect(preview.importToken).toBe('one-time-token');
|
||||
});
|
||||
|
||||
it('redacts inaccessible and unknown conflict reasons as name unavailable', () => {
|
||||
expect(resolveSkillImportConflictReasonLabel('NAME_UNAVAILABLE')).toBe(
|
||||
'名称不可用',
|
||||
);
|
||||
expect(resolveSkillImportConflictReasonLabel('NO_PERMISSION')).toBe(
|
||||
'名称不可用',
|
||||
);
|
||||
expect(resolveSkillImportConflictReasonLabel('NOT_DRAFT')).toBe(
|
||||
'当前状态不可覆盖',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the empty capability step for a standard package', () => {
|
||||
expect(resolveSkillImportStep(0, 'next', false)).toBe(2);
|
||||
expect(resolveSkillImportStep(2, 'visible', false)).toBe(1);
|
||||
expect(resolveSkillImportStep(2, 'previous', false)).toBe(0);
|
||||
expect(resolveSkillImportStep(0, 'next', true)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { SkillCapabilityMapping, SkillImportPreview } from './types';
|
||||
|
||||
export interface SkillImportConfirmState {
|
||||
categoryId?: number | string;
|
||||
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
renames?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function buildSkillImportConfirmPayload(
|
||||
preview: SkillImportPreview,
|
||||
state: SkillImportConfirmState,
|
||||
mappings: SkillCapabilityMapping[],
|
||||
) {
|
||||
return {
|
||||
capabilityMappings: mappings.map((item) => ({
|
||||
bindingKey: item.bindingKey,
|
||||
disabled: item.disabled,
|
||||
targetId: item.disabled ? undefined : item.targetId,
|
||||
})),
|
||||
categoryId: state.categoryId || undefined,
|
||||
conflictStrategy: state.conflictStrategy,
|
||||
importToken: preview.importToken,
|
||||
renames: state.conflictStrategy === 'RENAME' ? state.renames : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function countBlockingImportIssues(preview?: SkillImportPreview) {
|
||||
if (!preview) return 0;
|
||||
return [
|
||||
...(preview.issues || []),
|
||||
...preview.skills.flatMap((item) => item.validationIssues || []),
|
||||
].filter((issue) => issue.severity === 'ERROR').length;
|
||||
}
|
||||
|
||||
export function invalidateConsumedImportPreview(
|
||||
preview: SkillImportPreview,
|
||||
): SkillImportPreview {
|
||||
return { ...preview, importToken: '' };
|
||||
}
|
||||
|
||||
export function resolveSkillImportConflictReasonLabel(reason?: string) {
|
||||
return reason === 'NOT_DRAFT' ? '当前状态不可覆盖' : '名称不可用';
|
||||
}
|
||||
|
||||
export function resolveSkillImportStep(
|
||||
current: number,
|
||||
direction: 'next' | 'previous' | 'visible',
|
||||
hasCapabilityMappings: boolean,
|
||||
) {
|
||||
if (direction === 'visible') {
|
||||
return hasCapabilityMappings || current < 2 ? current : 1;
|
||||
}
|
||||
if (direction === 'next') {
|
||||
return current === 0 && !hasCapabilityMappings
|
||||
? 2
|
||||
: Math.min(current + 1, 2);
|
||||
}
|
||||
return current === 2 && !hasCapabilityMappings ? 0 : Math.max(current - 1, 0);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ interface SkillListRouteState {
|
||||
keyword: string;
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
publishStatus: string;
|
||||
visibilityScope: string;
|
||||
}
|
||||
|
||||
const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
|
||||
@@ -18,9 +20,11 @@ const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
|
||||
keyword: stringListRouteField(),
|
||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||
pageSize: positiveIntegerListRouteField({
|
||||
allowedValues: [12, 24, 48],
|
||||
defaultValue: 12,
|
||||
allowedValues: [10, 20, 50],
|
||||
defaultValue: 10,
|
||||
}),
|
||||
publishStatus: stringListRouteField(),
|
||||
visibilityScope: stringListRouteField(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,16 +4,11 @@ export interface RequestResult<T = unknown> {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type SkillCapabilityType = 'MCP' | 'PLUGIN_ITEM' | 'WORKFLOW';
|
||||
export type SkillExportFormat = 'EASYFLOW' | 'STANDARD';
|
||||
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
||||
export type SkillVisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
||||
|
||||
export interface SkillInfo {
|
||||
approvalPending?: boolean;
|
||||
assetCount?: number;
|
||||
bindings?: SkillCapabilityBinding[];
|
||||
capabilityCount?: number;
|
||||
capabilityHash?: string;
|
||||
categoryId?: number | string;
|
||||
categoryName?: string;
|
||||
created?: string;
|
||||
@@ -23,22 +18,17 @@ export interface SkillInfo {
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
displayPublishStatus?: string;
|
||||
enabled?: boolean;
|
||||
id?: number | string;
|
||||
manageable?: boolean;
|
||||
metadataJson?: Record<string, unknown>;
|
||||
modified?: string;
|
||||
name?: string;
|
||||
packageHash?: string;
|
||||
publishStatus?: string;
|
||||
referenceCount?: number;
|
||||
resourceCount?: number;
|
||||
readable?: boolean;
|
||||
resources?: SkillResource[];
|
||||
scriptCount?: number;
|
||||
skillContent?: string;
|
||||
sourceType?: string;
|
||||
visibilityScope?: string;
|
||||
[key: string]: unknown;
|
||||
snapshotHash?: string;
|
||||
visibilityScope?: SkillVisibilityScope;
|
||||
}
|
||||
|
||||
export interface SkillResource {
|
||||
@@ -48,9 +38,7 @@ export interface SkillResource {
|
||||
id?: number | string;
|
||||
isText?: boolean;
|
||||
kind?: string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
metadataJson?: Record<string, unknown>;
|
||||
path: string;
|
||||
size?: number;
|
||||
}
|
||||
@@ -94,67 +82,6 @@ export interface SkillValidationResult {
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityBinding {
|
||||
bindingKey?: string;
|
||||
capabilityType: SkillCapabilityType;
|
||||
enabled: boolean;
|
||||
executionMode?: 'ASYNC' | 'SYNC';
|
||||
hitlConfigJson?: Record<string, unknown>;
|
||||
hitlEnabled?: boolean;
|
||||
id?: number | string;
|
||||
optionsJson?: Record<string, unknown>;
|
||||
runtimeName?: string;
|
||||
resolvedToolNames?: string[];
|
||||
selectedToolNamesJson?: string[];
|
||||
selectionMode?: 'ALL' | 'SELECTED';
|
||||
sortNo?: number;
|
||||
targetLogicalRef?: string;
|
||||
targetStatus?: 'AVAILABLE' | 'NO_PERMISSION' | 'UNAVAILABLE' | 'UNRESOLVED';
|
||||
targetId?: number | string;
|
||||
targetName?: string;
|
||||
targetSummary?: string;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityCandidate {
|
||||
capabilityType: SkillCapabilityType;
|
||||
description?: string;
|
||||
logicalRef?: string;
|
||||
name: string;
|
||||
revision?: string;
|
||||
status?: string;
|
||||
targetId: number | string;
|
||||
toolNames?: string[];
|
||||
}
|
||||
|
||||
export interface SkillCapabilityTools {
|
||||
status: string;
|
||||
targetId: number | string;
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
export interface SkillCapabilityReplaceResult {
|
||||
bindings: SkillCapabilityBinding[];
|
||||
capabilityHash: string;
|
||||
}
|
||||
|
||||
export interface SkillImportPreviewItem {
|
||||
assetCount?: number;
|
||||
conflict: boolean;
|
||||
conflictReason?: 'NAME_UNAVAILABLE' | 'NOT_DRAFT';
|
||||
description?: string;
|
||||
files?: SkillImportPreviewFile[];
|
||||
name: string;
|
||||
packageHash?: string;
|
||||
packageId: string;
|
||||
packageRoot?: string;
|
||||
overwriteAllowed?: boolean;
|
||||
referenceCount?: number;
|
||||
resourceCount?: number;
|
||||
scriptCount?: number;
|
||||
suggestedName?: string;
|
||||
validationIssues?: SkillValidationIssue[];
|
||||
}
|
||||
|
||||
export interface SkillImportPreviewFile {
|
||||
kind: 'ASSET' | 'EXAMPLE' | 'OTHER' | 'REFERENCE' | 'SCRIPT' | 'SKILL';
|
||||
mediaType?: string;
|
||||
@@ -163,27 +90,40 @@ export interface SkillImportPreviewFile {
|
||||
text: boolean;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityMapping {
|
||||
bindingKey: string;
|
||||
capabilityType: SkillCapabilityType;
|
||||
disabled?: boolean;
|
||||
export interface SkillImportPreviewItem {
|
||||
conflict: boolean;
|
||||
conflictReason?: 'NAME_UNAVAILABLE' | 'NOT_DRAFT';
|
||||
description?: string;
|
||||
files?: SkillImportPreviewFile[];
|
||||
name: string;
|
||||
overwriteAllowed?: boolean;
|
||||
packageHash?: string;
|
||||
packageId: string;
|
||||
packageRoot?: string;
|
||||
runtimeName?: string;
|
||||
status: 'RESOLVED' | 'UNRESOLVED';
|
||||
targetId?: number | string;
|
||||
targetLogicalRef?: string;
|
||||
targetName?: string;
|
||||
}
|
||||
|
||||
export interface SkillImportPreview {
|
||||
capabilityMappings?: SkillCapabilityMapping[];
|
||||
expiresAt?: string;
|
||||
format: SkillExportFormat;
|
||||
importToken: string;
|
||||
importToken?: string;
|
||||
issues?: SkillValidationIssue[];
|
||||
skills: SkillImportPreviewItem[];
|
||||
}
|
||||
|
||||
export interface SkillImportConfirmPayload {
|
||||
categoryId?: number | string;
|
||||
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
importToken: string;
|
||||
renames?: Record<string, string>;
|
||||
visibilityScope: SkillVisibilityScope;
|
||||
}
|
||||
|
||||
export interface SkillImportBatchResult {
|
||||
importToken?: string;
|
||||
message?: string;
|
||||
skills: SkillInfo[];
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface SkillFileBuffer {
|
||||
content: string;
|
||||
conflict?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user