发布 v1.10 #5
@@ -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';
|
describe('skill create and import contract', () => {
|
||||||
|
it('offers manual authoring and independent standard ZIP batch import', () => {
|
||||||
const apiMocks = vi.hoisted(() => ({
|
expect(dialogSource).toContain('value="manual">自己编写');
|
||||||
saveSkill: vi.fn(),
|
expect(dialogSource).toContain('value="import">批量导入');
|
||||||
}));
|
expect(dialogSource).toContain('accept=".zip,application/zip"');
|
||||||
|
expect(dialogSource).toContain('multiple');
|
||||||
vi.mock('./api', () => apiMocks);
|
expect(dialogSource).toContain('单次最多导入 20 个技能');
|
||||||
vi.mock('@easyflow/common-ui', async () => {
|
expect(dialogSource).toContain('importSkillConfirmBatch');
|
||||||
const { defineComponent, h } = await import('vue');
|
expect(dialogSource).not.toContain('.efskill');
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a valid draft from the compact business form', async () => {
|
it('requires one shared category and visibility scope for the operation', () => {
|
||||||
const wrapper = mount(SkillCreateDialog, {
|
expect(dialogSource).toContain('v-model="form.categoryId"');
|
||||||
props: {
|
expect(dialogSource).toContain('v-model="form.visibilityScope"');
|
||||||
canPublish: true,
|
expect(dialogSource).toContain('visibilityScope: form.visibilityScope');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,65 +1,92 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance, FormRules } from 'element-plus';
|
import type { FormInstance, FormRules } from 'element-plus';
|
||||||
|
|
||||||
import type { SkillCreateIntent } from './skill-create';
|
import type {
|
||||||
import type { SkillCategory, SkillInfo } from './types';
|
SkillCategory,
|
||||||
|
SkillImportPreview,
|
||||||
|
SkillInfo,
|
||||||
|
SkillVisibilityScope,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import { Document, UploadFilled } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ElAlert,
|
ElButton,
|
||||||
|
ElDialog,
|
||||||
ElForm,
|
ElForm,
|
||||||
ElFormItem,
|
ElFormItem,
|
||||||
|
ElIcon,
|
||||||
ElInput,
|
ElInput,
|
||||||
ElMessage,
|
ElMessage,
|
||||||
ElOption,
|
ElOption,
|
||||||
ElRadioButton,
|
ElRadioButton,
|
||||||
ElRadioGroup,
|
ElRadioGroup,
|
||||||
ElSelect,
|
ElSelect,
|
||||||
|
ElTag,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { saveSkill } from './api';
|
import {
|
||||||
|
cancelSkillImport,
|
||||||
|
importSkillConfirmBatch,
|
||||||
|
importSkillPreviews,
|
||||||
|
saveSkill,
|
||||||
|
} from './api';
|
||||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||||
import { flattenSkillCategories } from './skill-category';
|
import { flattenSkillCategories } from './skill-category';
|
||||||
import { buildInitialSkillDraft } from './skill-create';
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
canPublish?: boolean;
|
|
||||||
categories: SkillCategory[];
|
categories: SkillCategory[];
|
||||||
categoriesLoading?: boolean;
|
categoriesLoading?: boolean;
|
||||||
defaultCategoryId?: number | string;
|
defaultCategoryId?: number | string;
|
||||||
|
defaultMode?: CreateMode;
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
canPublish: false,
|
|
||||||
categoriesLoading: false,
|
categoriesLoading: false,
|
||||||
defaultCategoryId: '',
|
defaultCategoryId: '',
|
||||||
|
defaultMode: 'manual',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
created: [payload: { intent: SkillCreateIntent; skill: SkillInfo }];
|
created: [skill: SkillInfo];
|
||||||
|
imported: [count: number];
|
||||||
'update:modelValue': [value: boolean];
|
'update:modelValue': [value: boolean];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
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 actionError = ref('');
|
||||||
|
const importRows = ref<ImportRow[]>([]);
|
||||||
|
const importFinished = ref(false);
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
categoryId: '' as number | string,
|
categoryId: '' as number | string,
|
||||||
displayName: '',
|
displayName: '',
|
||||||
intent: 'DRAFT' as SkillCreateIntent,
|
visibilityScope: 'PRIVATE' as SkillVisibilityScope,
|
||||||
visibilityScope: 'PRIVATE',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const dialogVisible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
if (!value && saving.value) {
|
if (!value && busy.value) {
|
||||||
ElMessage.info('请等待当前操作完成');
|
ElMessage.info('请等待当前操作完成');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -71,165 +98,481 @@ const categoryOptions = computed(() =>
|
|||||||
);
|
);
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
displayName: [
|
displayName: [
|
||||||
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
|
{ message: '请输入技能名称', required: true, trigger: 'blur' },
|
||||||
{
|
{ max: 128, message: '技能名称不能超过 128 个字符', trigger: 'blur' },
|
||||||
max: 128,
|
|
||||||
message: 'Skill 名称不能超过 128 个字符',
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
const confirmText = computed(() =>
|
||||||
function resetForm() {
|
mode.value === 'manual' ? '创建并进入编辑' : '导入',
|
||||||
Object.assign(form, {
|
);
|
||||||
categoryId: props.defaultCategoryId || '',
|
const importReady = computed(
|
||||||
displayName: '',
|
() =>
|
||||||
intent: 'DRAFT',
|
importRows.value.length > 0 &&
|
||||||
visibilityScope: 'PRIVATE',
|
importRows.value.every((row) => {
|
||||||
});
|
const item = row.preview?.skills[0];
|
||||||
actionError.value = '';
|
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
|
||||||
formRef.value?.clearValidate();
|
return false;
|
||||||
void nextTick(() => nameInputRef.value?.focus());
|
if (!item.conflict) return true;
|
||||||
}
|
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
|
||||||
|
if (row.strategy === 'RENAME') return canonicalName(row.rename);
|
||||||
async function submit() {
|
return false;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(visible) => {
|
(open) => {
|
||||||
if (visible) resetForm();
|
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 },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<EasyFlowFormModal
|
<ElDialog
|
||||||
v-model:open="dialogVisible"
|
v-model="visible"
|
||||||
width="520px"
|
class="skill-create-dialog"
|
||||||
:closable="!saving"
|
width="min(720px, calc(100vw - 32px))"
|
||||||
:confirm-loading="saving"
|
:close-on-click-modal="false"
|
||||||
confirm-text="创建并进入详情"
|
:close-on-press-escape="!busy"
|
||||||
:submitting="saving || categoriesLoading"
|
:show-close="!busy"
|
||||||
title="新建 Skill"
|
title="新建技能"
|
||||||
@confirm="submit"
|
@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
|
<ElForm
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
class="skill-create-form easyflow-modal-form easyflow-modal-form--compact"
|
class="skill-create-dialog__form"
|
||||||
label-position="top"
|
label-position="top"
|
||||||
:model="form"
|
:model="form"
|
||||||
:rules="rules"
|
:rules="rules"
|
||||||
>
|
>
|
||||||
<ElAlert
|
<ElFormItem v-if="mode === 'manual'" label="技能名称" prop="displayName">
|
||||||
v-if="actionError"
|
|
||||||
class="skill-create-form__alert"
|
|
||||||
:closable="false"
|
|
||||||
:title="actionError"
|
|
||||||
type="error"
|
|
||||||
show-icon
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ElFormItem label="Skill 名称" prop="displayName">
|
|
||||||
<ElInput
|
<ElInput
|
||||||
ref="nameInputRef"
|
ref="nameInputRef"
|
||||||
v-model="form.displayName"
|
v-model="form.displayName"
|
||||||
maxlength="128"
|
maxlength="128"
|
||||||
placeholder="请输入 Skill 名称"
|
placeholder="请输入技能名称"
|
||||||
show-word-limit
|
|
||||||
@keyup.enter="submit"
|
@keyup.enter="submit"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<div class="skill-create-dialog__fields">
|
||||||
<ElFormItem label="分类">
|
<ElFormItem label="分类">
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-model="form.categoryId"
|
v-model="form.categoryId"
|
||||||
clearable
|
clearable
|
||||||
:loading="categoriesLoading"
|
:loading="categoriesLoading"
|
||||||
placeholder="未分类"
|
placeholder="未分类"
|
||||||
>
|
>
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="category in categoryOptions"
|
v-for="category in categoryOptions"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
|
:label="`${'\u3000'.repeat(category.depth)}${category.categoryName}`"
|
||||||
:value="category.id"
|
:value="category.id"
|
||||||
:disabled="category.status !== 1"
|
:disabled="category.status !== 1"
|
||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElFormItem label="范围">
|
||||||
<ElFormItem label="可见范围">
|
<ElSelect v-model="form.visibilityScope">
|
||||||
<ElSelect v-model="form.visibilityScope">
|
<ElOption
|
||||||
<ElOption label="仅自己" value="PRIVATE" />
|
v-for="scope in [
|
||||||
<ElOption label="本部门" value="DEPT" />
|
'PRIVATE',
|
||||||
<ElOption label="全员可见" value="PUBLIC" />
|
'DEPT',
|
||||||
</ElSelect>
|
'PUBLIC',
|
||||||
</ElFormItem>
|
] as SkillVisibilityScope[]"
|
||||||
|
:key="scope"
|
||||||
<ElFormItem label="发布状态">
|
:label="scopeLabel(scope)"
|
||||||
<div class="skill-create-form__publish-field">
|
:value="scope"
|
||||||
<ElRadioGroup v-model="form.intent">
|
/>
|
||||||
<ElRadioButton value="DRAFT">草稿</ElRadioButton>
|
</ElSelect>
|
||||||
<ElRadioButton value="PUBLISH" :disabled="!canPublish">
|
</ElFormItem>
|
||||||
创建后发布
|
</div>
|
||||||
</ElRadioButton>
|
|
||||||
</ElRadioGroup>
|
|
||||||
<span v-if="form.intent === 'PUBLISH'">
|
|
||||||
进入详情完善内容,通过校验后提交发布。
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElForm>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.skill-create-form__alert {
|
.skill-create-dialog__mode {
|
||||||
margin-bottom: var(--space-4);
|
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%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-create-form__publish-field {
|
.skill-create-dialog__error {
|
||||||
display: grid;
|
padding: var(--space-3);
|
||||||
gap: var(--space-2);
|
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 {
|
.skill-create-dialog__fields {
|
||||||
font-size: 12px;
|
display: grid;
|
||||||
line-height: 1.5;
|
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));
|
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>
|
</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 detailSource from './SkillDetail.vue?raw';
|
||||||
import { flushPromises, mount } from '@vue/test-utils';
|
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
describe('skill studio contract', () => {
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
it('uses one unified file workbench and one publish modal', () => {
|
||||||
|
expect(detailSource).not.toContain('activeTab');
|
||||||
import SkillDetail from './SkillDetail.vue';
|
expect(detailSource).not.toContain('instructionsOnly');
|
||||||
|
expect(detailSource).not.toContain('skill-detail-page__tabs');
|
||||||
const apiMocks = vi.hoisted(() => ({
|
expect(detailSource.match(/<SkillResourceWorkbench/g)).toHaveLength(1);
|
||||||
getSkillCategories: vi.fn(),
|
expect(detailSource).toContain('title="发布技能"');
|
||||||
getSkillDetail: vi.fn(),
|
expect(detailSource).toContain('发布说明');
|
||||||
saveSkill: vi.fn(),
|
expect(detailSource).toContain('maxlength="500"');
|
||||||
submitSkillDeleteApproval: vi.fn(),
|
expect(detailSource).toContain(
|
||||||
submitSkillOfflineApproval: vi.fn(),
|
'submitSkillPublishApproval(skillId.value, reason)',
|
||||||
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;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
vi.spyOn(ElMessageBox, 'confirm').mockImplementation(async () => {
|
expect(detailSource).not.toContain('能力绑定');
|
||||||
calls.push('confirm');
|
expect(detailSource).not.toContain('SkillCapabilityPanel');
|
||||||
return { action: 'confirm' } as Awaited<
|
});
|
||||||
ReturnType<typeof ElMessageBox.confirm>
|
|
||||||
>;
|
it('keeps the detail shell compact and uses the neutral content surface', () => {
|
||||||
});
|
expect(detailSource).toContain('min-height: 48px');
|
||||||
apiMocks.submitSkillPublishApproval.mockImplementation(
|
expect(detailSource).toContain('background: hsl(var(--background))');
|
||||||
() =>
|
expect(detailSource).not.toContain(
|
||||||
new Promise((resolve) => {
|
'background: hsl(var(--surface-canvas))',
|
||||||
calls.push('submit');
|
|
||||||
resolveSubmit = resolve;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
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 () => {
|
it('saves dirty files and validates the package before opening publish', () => {
|
||||||
childMocks.resourceValidate.mockResolvedValue(false);
|
const saveAt = detailSource.indexOf('await saveFiles(false)');
|
||||||
const confirm = vi.spyOn(ElMessageBox, 'confirm');
|
const validateAt = detailSource.indexOf(
|
||||||
const wrapper = await mountDetail();
|
'resourceWorkbenchRef.value?.validateAll()',
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
const dialogAt = detailSource.indexOf('publishDialogOpen.value = true');
|
||||||
|
|
||||||
expect(wrapper.text()).not.toContain('设置');
|
expect(saveAt).toBeGreaterThan(0);
|
||||||
expect(wrapper.text()).not.toContain('校验');
|
expect(validateAt).toBeGreaterThan(saveAt);
|
||||||
const lifecycleDropdown = wrapper.findComponent({ name: 'ElDropdown' });
|
expect(dialogAt).toBeGreaterThan(validateAt);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,216 +1,26 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import listSource from './SkillList.vue?raw';
|
||||||
|
|
||||||
import SkillList from './SkillList.vue';
|
describe('skill list contract', () => {
|
||||||
import skillListSource from './SkillList.vue?raw';
|
it('uses the shared list layout with classification, fuzzy search, and compact filters', () => {
|
||||||
|
expect(listSource).toContain('<PageSide');
|
||||||
/* eslint-disable vue/one-component-per-file -- Inline stubs keep this layout test isolated. */
|
expect(listSource).toContain('<HeaderSearch');
|
||||||
|
expect(listSource).toContain('搜索技能名称、用途或创建人');
|
||||||
const apiMocks = vi.hoisted(() => ({
|
expect(listSource).toContain('placeholder="范围:全部"');
|
||||||
deleteSkillCategory: vi.fn(),
|
expect(listSource).toContain('placeholder="状态:全部"');
|
||||||
getSkillCategories: vi.fn(),
|
expect(listSource).toContain('placeholder="分类:全部"');
|
||||||
}));
|
expect(listSource).toContain('skill-list-page__mobile-category');
|
||||||
const permissionMocks = vi.hoisted(() => ({
|
expect(listSource).toContain("text: '批量导入'");
|
||||||
hasPermission: vi.fn((_permissions: string[]) => true),
|
expect(listSource).toContain("text: '新建技能'");
|
||||||
}));
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('places the toolbar above a full-height category and list workspace', async () => {
|
it('shows only the agreed business columns and omits technical source data', () => {
|
||||||
const wrapper = await mountSkillList();
|
for (const label of ['技能', '用途', '创建人', '范围', '状态', '操作']) {
|
||||||
|
expect(listSource).toContain(`label="${label}"`);
|
||||||
const header = wrapper.get('.skill-list-page__header');
|
}
|
||||||
const toolbar = header.get('.custom-header');
|
expect(listSource).not.toContain('label="来源"');
|
||||||
expect(toolbar.text()).toContain('导入');
|
expect(listSource).not.toContain('label="能力"');
|
||||||
expect(toolbar.text()).toContain('导出');
|
expect(listSource).not.toContain('<h1>技能库</h1>');
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
|
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
|
||||||
|
import workbenchSource from './SkillResourceWorkbench.vue?raw';
|
||||||
|
|
||||||
const apiMocks = vi.hoisted(() => ({
|
const apiMocks = vi.hoisted(() => ({
|
||||||
createSkillFile: vi.fn(),
|
createSkillFile: vi.fn(),
|
||||||
@@ -58,6 +59,17 @@ const resources = [
|
|||||||
size: 12,
|
size: 12,
|
||||||
type: 'REFERENCE',
|
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;
|
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 () => {
|
it('gives readonly generic text the shared copy and download viewer', async () => {
|
||||||
await mountAndSelect('references/data.json', true);
|
await mountAndSelect('references/data.json', true);
|
||||||
|
|
||||||
@@ -310,17 +330,23 @@ describe('skill resource workbench validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('skill resource workbench navigation', () => {
|
describe('skill resource workbench navigation', () => {
|
||||||
it('shows the standard directories and opens a directory-aware create form', async () => {
|
it('keeps the current file open when a directory is expanded', async () => {
|
||||||
await mountAndSelect('references');
|
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="scripts"]').exists()).toBe(true);
|
||||||
expect(wrapper?.find('[data-tree-path="assets"]').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
|
const referenceRow = wrapper?.get('[data-tree-path="references"]').element
|
||||||
?.findAll('.skill-resource-workbench__directory-state button')
|
.parentElement;
|
||||||
.find((button) => button.text().trim() === '新建文件');
|
referenceRow
|
||||||
await createButton?.trigger('click');
|
?.querySelector<HTMLButtonElement>('[aria-label="在此目录新建文件"]')
|
||||||
|
?.click();
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
@@ -334,47 +360,13 @@ describe('skill resource workbench navigation', () => {
|
|||||||
expect(fileNameInput?.props('modelValue')).toBe('');
|
expect(fileNameInput?.props('modelValue')).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps capability binding in the same left navigation and switches panels', async () => {
|
it('keeps resource navigation focused on the standard package tree', async () => {
|
||||||
wrapper = mount(SkillResourceWorkbench, {
|
await mountAndSelect('references');
|
||||||
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();
|
|
||||||
|
|
||||||
const entry = wrapper.get('.skill-resource-workbench__capability-entry');
|
expect(wrapper?.text()).not.toContain('能力绑定');
|
||||||
expect(entry.text()).toContain('能力绑定');
|
expect(workbenchSource).not.toContain('instructionsOnly');
|
||||||
expect(entry.text()).toContain('3');
|
expect(workbenchSource).not.toContain('selectSkillFile');
|
||||||
expect(
|
expect(workbenchSource).toContain('allow-create');
|
||||||
wrapper
|
expect(workbenchSource).toContain('placeholder="选择或输入目录"');
|
||||||
.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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ import type {
|
|||||||
EditorLanguage,
|
EditorLanguage,
|
||||||
} from '@easyflow-core/editor-ui';
|
} from '@easyflow-core/editor-ui';
|
||||||
|
|
||||||
import type {
|
import type { SkillFileNode, SkillValidationIssue } from './types';
|
||||||
SkillFileContent,
|
|
||||||
SkillFileNode,
|
|
||||||
SkillValidationIssue,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
@@ -48,7 +44,6 @@ import {
|
|||||||
} from '@easyflow-core/editor-ui';
|
} from '@easyflow-core/editor-ui';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Connection,
|
|
||||||
CopyDocument,
|
CopyDocument,
|
||||||
Download,
|
Download,
|
||||||
Fold,
|
Fold,
|
||||||
@@ -91,18 +86,10 @@ import { useSkillFileBuffers } from './use-skill-file-buffers';
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
activePanel?: 'capability' | 'resources';
|
|
||||||
capabilityAvailable?: boolean;
|
|
||||||
capabilityCount?: number;
|
|
||||||
initialContent?: string;
|
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
skillId: number | string;
|
skillId: number | string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
activePanel: 'resources',
|
|
||||||
capabilityAvailable: false,
|
|
||||||
capabilityCount: 0,
|
|
||||||
initialContent: '',
|
|
||||||
readonly: false,
|
readonly: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -110,11 +97,8 @@ const props = withDefaults(
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
dirty: [dirty: boolean];
|
dirty: [dirty: boolean];
|
||||||
issues: [issues: SkillValidationIssue[]];
|
issues: [issues: SkillValidationIssue[]];
|
||||||
locateCapability: [];
|
|
||||||
newContent: [content: string];
|
|
||||||
requestSave: [];
|
requestSave: [];
|
||||||
saved: [];
|
saved: [];
|
||||||
'update:activePanel': [panel: 'capability' | 'resources'];
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const MarkdownLiveEditor = defineAsyncComponent(
|
const MarkdownLiveEditor = defineAsyncComponent(
|
||||||
@@ -123,7 +107,6 @@ const MarkdownLiveEditor = defineAsyncComponent(
|
|||||||
const MAX_TEXT_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TEXT_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
const STANDARD_DIRECTORIES = ['references', 'scripts', 'assets'] as const;
|
const STANDARD_DIRECTORIES = ['references', 'scripts', 'assets'] as const;
|
||||||
|
|
||||||
const isNew = computed(() => String(props.skillId) === 'new');
|
|
||||||
const tree = ref<SkillFileNode[]>([]);
|
const tree = ref<SkillFileNode[]>([]);
|
||||||
const selectedPath = ref('SKILL.md');
|
const selectedPath = ref('SKILL.md');
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -275,17 +258,22 @@ const wordCount = computed(
|
|||||||
);
|
);
|
||||||
const editorLanguage = computed<EditorLanguage>(() => {
|
const editorLanguage = computed<EditorLanguage>(() => {
|
||||||
const language = String(currentFile.value?.language || '').toUpperCase();
|
const language = String(currentFile.value?.language || '').toUpperCase();
|
||||||
if (language === 'PYTHON' || currentFile.value?.path.endsWith('.py'))
|
const path = (currentFile.value?.path || '').toLowerCase();
|
||||||
return 'python';
|
if (language === 'PYTHON' || path.endsWith('.py')) return 'python';
|
||||||
if (
|
if (language === 'TYPESCRIPT' || /\.(?:ts|tsx)$/.test(path))
|
||||||
language === 'JAVASCRIPT' ||
|
return 'typescript';
|
||||||
/\.(?:js|mjs)$/.test(currentFile.value?.path || '')
|
if (language === 'JAVASCRIPT' || /\.(?:js|mjs|cjs|jsx)$/.test(path))
|
||||||
)
|
|
||||||
return 'javascript';
|
return 'javascript';
|
||||||
if (language === 'SHELL' || currentFile.value?.path.endsWith('.sh'))
|
if (language === 'SHELL' || /\.(?:sh|bash|zsh)$/.test(path)) return 'shell';
|
||||||
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 (isMarkdown.value) return 'markdown';
|
||||||
if (currentFile.value?.mediaType === 'application/json') return 'json';
|
|
||||||
return 'text';
|
return 'text';
|
||||||
});
|
});
|
||||||
const lineCount = computed(() => contentMetrics.value.lines);
|
const lineCount = computed(() => contentMetrics.value.lines);
|
||||||
@@ -297,9 +285,6 @@ watch(
|
|||||||
dirtyPaths,
|
dirtyPaths,
|
||||||
(value) => {
|
(value) => {
|
||||||
emit('dirty', value.size > 0);
|
emit('dirty', value.size > 0);
|
||||||
if (value.has('SKILL.md')) {
|
|
||||||
emit('newContent', buffers.get('SKILL.md')?.content || '');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
@@ -325,16 +310,6 @@ watch([selectedPath, isLargeForLive, isMarkdown], ([, large, markdown]) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(() => props.skillId, init);
|
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);
|
watch([selectedPath, currentFile], loadAssetPreview);
|
||||||
onMounted(init);
|
onMounted(init);
|
||||||
onKeyStroke('Escape', () => {
|
onKeyStroke('Escape', () => {
|
||||||
@@ -365,22 +340,6 @@ async function init() {
|
|||||||
selectedPath.value = 'SKILL.md';
|
selectedPath.value = 'SKILL.md';
|
||||||
settledPath = 'SKILL.md';
|
settledPath = 'SKILL.md';
|
||||||
try {
|
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;
|
if (!(await loadTree(request))) return;
|
||||||
await selectFile('SKILL.md', request);
|
await selectFile('SKILL.md', request);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -420,19 +379,13 @@ async function selectFile(
|
|||||||
) {
|
) {
|
||||||
const path = typeof pathOrNode === 'string' ? pathOrNode : pathOrNode.path;
|
const path = typeof pathOrNode === 'string' ? pathOrNode : pathOrNode.path;
|
||||||
if (typeof pathOrNode !== 'string' && pathOrNode.type === 'DIRECTORY') {
|
if (typeof pathOrNode !== 'string' && pathOrNode.type === 'DIRECTORY') {
|
||||||
fileContentRequest++;
|
return false;
|
||||||
loadingRequest++;
|
|
||||||
loading.value = false;
|
|
||||||
loadError.value = '';
|
|
||||||
selectedPath.value = path;
|
|
||||||
settledPath = path;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
const request = ++fileContentRequest;
|
const request = ++fileContentRequest;
|
||||||
const requestedSkillId = String(props.skillId);
|
const requestedSkillId = String(props.skillId);
|
||||||
const previousPath = settledPath;
|
const previousPath = settledPath;
|
||||||
selectedPath.value = path;
|
selectedPath.value = path;
|
||||||
if (buffers.has(path) || isNew.value) {
|
if (buffers.has(path)) {
|
||||||
settledPath = path;
|
settledPath = path;
|
||||||
loadingRequest++;
|
loadingRequest++;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -490,22 +443,13 @@ async function selectFileFromTree(
|
|||||||
node: { path: string; type?: string },
|
node: { path: string; type?: string },
|
||||||
closeTree: () => void,
|
closeTree: () => void,
|
||||||
) {
|
) {
|
||||||
emit('update:activePanel', 'resources');
|
|
||||||
await selectFile(node);
|
await selectFile(node);
|
||||||
closeTree();
|
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) {
|
async function saveBuffer(path: string) {
|
||||||
const buffer = buffers.get(path);
|
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.saveState === 'saving') return false;
|
||||||
if (buffer.conflict) {
|
if (buffer.conflict) {
|
||||||
markError(path);
|
markError(path);
|
||||||
@@ -547,7 +491,7 @@ async function saveBuffer(path: string) {
|
|||||||
|
|
||||||
async function reloadCurrent() {
|
async function reloadCurrent() {
|
||||||
const buffer = currentBuffer.value;
|
const buffer = currentBuffer.value;
|
||||||
if (!buffer || isNew.value || fileMutation.value || savingAll.value) return;
|
if (!buffer || fileMutation.value || savingAll.value) return;
|
||||||
fileMutation.value = 'reload';
|
fileMutation.value = 'reload';
|
||||||
try {
|
try {
|
||||||
if (buffer.content !== buffer.original) {
|
if (buffer.content !== buffer.original) {
|
||||||
@@ -577,10 +521,6 @@ async function saveAll() {
|
|||||||
if (savingAll.value || fileMutation.value) return false;
|
if (savingAll.value || fileMutation.value) return false;
|
||||||
savingAll.value = true;
|
savingAll.value = true;
|
||||||
try {
|
try {
|
||||||
if (isNew.value) {
|
|
||||||
emit('newContent', buffers.get('SKILL.md')?.content || '');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const path of dirtyPaths.value) {
|
for (const path of dirtyPaths.value) {
|
||||||
if (!(await saveBuffer(path))) return false;
|
if (!(await saveBuffer(path))) return false;
|
||||||
}
|
}
|
||||||
@@ -594,11 +534,6 @@ async function saveAll() {
|
|||||||
|
|
||||||
async function validateAll() {
|
async function validateAll() {
|
||||||
if (validating.value || savingAll.value || fileMutation.value) return false;
|
if (validating.value || savingAll.value || fileMutation.value) return false;
|
||||||
if (isNew.value) {
|
|
||||||
issues.value = [];
|
|
||||||
emit('issues', []);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
validating.value = true;
|
validating.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await validateSkillForPublish(props.skillId);
|
const res = await validateSkillForPublish(props.skillId);
|
||||||
@@ -616,11 +551,6 @@ async function validateAll() {
|
|||||||
|
|
||||||
async function locateIssue(issue: DocumentValidationIssue) {
|
async function locateIssue(issue: DocumentValidationIssue) {
|
||||||
if (!issue.path) return;
|
if (!issue.path) return;
|
||||||
if (issue.path.startsWith('capabilities')) {
|
|
||||||
inspector.value = undefined;
|
|
||||||
emit('locateCapability');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!flattenFilePaths(tree.value).includes(issue.path)) {
|
if (!flattenFilePaths(tree.value).includes(issue.path)) {
|
||||||
ElMessage.warning(`无法定位校验项:${issue.path}`);
|
ElMessage.warning(`无法定位校验项:${issue.path}`);
|
||||||
return;
|
return;
|
||||||
@@ -638,18 +568,7 @@ async function locateIssue(issue: DocumentValidationIssue) {
|
|||||||
|
|
||||||
function openCreateFile(node?: DocumentFileNode) {
|
function openCreateFile(node?: DocumentFileNode) {
|
||||||
if (props.readonly || fileMutation.value || savingAll.value) return;
|
if (props.readonly || fileMutation.value || savingAll.value) return;
|
||||||
if (isNew.value) {
|
const selectedDirectory = resolveSelectedDirectory(node, 'references');
|
||||||
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';
|
|
||||||
createFileForm.directory = directoryOptions.value.some(
|
createFileForm.directory = directoryOptions.value.some(
|
||||||
(option) => option.path === selectedDirectory,
|
(option) => option.path === selectedDirectory,
|
||||||
)
|
)
|
||||||
@@ -699,19 +618,20 @@ async function submitCreateFile() {
|
|||||||
|
|
||||||
function chooseUpload(node?: DocumentFileNode) {
|
function chooseUpload(node?: DocumentFileNode) {
|
||||||
if (props.readonly || fileMutation.value || savingAll.value) return;
|
if (props.readonly || fileMutation.value || savingAll.value) return;
|
||||||
if (isNew.value) {
|
uploadDirectory.value = resolveSelectedDirectory(node, 'assets');
|
||||||
ElMessage.info('请先保存 Skill,再上传资源');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadDirectory.value =
|
|
||||||
node?.type === 'DIRECTORY'
|
|
||||||
? node.path
|
|
||||||
: currentNode.value?.type === 'DIRECTORY'
|
|
||||||
? currentNode.value.path
|
|
||||||
: 'assets';
|
|
||||||
uploadInputRef.value?.click();
|
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) {
|
async function handleUpload(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -802,7 +722,6 @@ async function renameCurrent() {
|
|||||||
if (
|
if (
|
||||||
!currentFile.value ||
|
!currentFile.value ||
|
||||||
currentFile.value.path === 'SKILL.md' ||
|
currentFile.value.path === 'SKILL.md' ||
|
||||||
isNew.value ||
|
|
||||||
fileMutation.value ||
|
fileMutation.value ||
|
||||||
savingAll.value
|
savingAll.value
|
||||||
)
|
)
|
||||||
@@ -880,7 +799,6 @@ async function removeCurrent() {
|
|||||||
if (
|
if (
|
||||||
!currentFile.value ||
|
!currentFile.value ||
|
||||||
currentFile.value.path === 'SKILL.md' ||
|
currentFile.value.path === 'SKILL.md' ||
|
||||||
isNew.value ||
|
|
||||||
fileMutation.value ||
|
fileMutation.value ||
|
||||||
savingAll.value
|
savingAll.value
|
||||||
)
|
)
|
||||||
@@ -940,7 +858,7 @@ async function downloadNode(node: DocumentFileNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadCurrent() {
|
async function downloadCurrent() {
|
||||||
if (!currentFile.value || isNew.value) return;
|
if (!currentFile.value) return;
|
||||||
try {
|
try {
|
||||||
const blob =
|
const blob =
|
||||||
buildSkillTextDownload(currentFile.value, currentContent.value) ||
|
buildSkillTextDownload(currentFile.value, currentContent.value) ||
|
||||||
@@ -972,7 +890,6 @@ async function loadAssetPreview() {
|
|||||||
assetPreviewError.value = false;
|
assetPreviewError.value = false;
|
||||||
if (
|
if (
|
||||||
!isBinaryResource.value ||
|
!isBinaryResource.value ||
|
||||||
isNew.value ||
|
|
||||||
(!isImageResource.value && !isPdfResource.value)
|
(!isImageResource.value && !isPdfResource.value)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -1038,7 +955,6 @@ async function resolveMarkdownImage(url: string) {
|
|||||||
if (/^[a-z][a-z\d+.-]*:|^\/\//i.test(url)) {
|
if (/^[a-z][a-z\d+.-]*:|^\/\//i.test(url)) {
|
||||||
return EMPTY_IMAGE_DATA_URL;
|
return EMPTY_IMAGE_DATA_URL;
|
||||||
}
|
}
|
||||||
if (isNew.value) return EMPTY_IMAGE_DATA_URL;
|
|
||||||
const path = resolveSkillRelativePath(selectedPath.value, url);
|
const path = resolveSkillRelativePath(selectedPath.value, url);
|
||||||
const node = path ? findFileNode(tree.value, path) : undefined;
|
const node = path ? findFileNode(tree.value, path) : undefined;
|
||||||
if (!path || !node || !isSafeImageNode(node)) return EMPTY_IMAGE_DATA_URL;
|
if (!path || !node || !isSafeImageNode(node)) return EMPTY_IMAGE_DATA_URL;
|
||||||
@@ -1269,7 +1185,7 @@ defineExpose({
|
|||||||
:busy="Boolean(fileMutation) || savingAll"
|
:busy="Boolean(fileMutation) || savingAll"
|
||||||
:nodes="tree"
|
:nodes="tree"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:current-path="activePanel === 'resources' ? selectedPath : ''"
|
:current-path="selectedPath"
|
||||||
:dirty-paths="dirtyPaths"
|
:dirty-paths="dirtyPaths"
|
||||||
:error-counts="errorCounts"
|
:error-counts="errorCounts"
|
||||||
:warning-counts="warningCounts"
|
:warning-counts="warningCounts"
|
||||||
@@ -1280,20 +1196,6 @@ defineExpose({
|
|||||||
@rename="renameNode"
|
@rename="renameNode"
|
||||||
@upload="chooseUpload"
|
@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>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref="uploadInputRef"
|
ref="uploadInputRef"
|
||||||
@@ -1303,7 +1205,7 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="activePanel === 'resources'" #toolbar>
|
<template #toolbar>
|
||||||
<div class="skill-resource-workbench__toolbar">
|
<div class="skill-resource-workbench__toolbar">
|
||||||
<div class="skill-resource-workbench__file-identity">
|
<div class="skill-resource-workbench__file-identity">
|
||||||
<strong>{{
|
<strong>{{
|
||||||
@@ -1409,17 +1311,7 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div v-loading="loading" class="skill-resource-workbench__editor-area">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<template v-if="currentFile">
|
<template v-if="currentFile">
|
||||||
<MarkdownLiveEditor
|
<MarkdownLiveEditor
|
||||||
v-if="isMarkdown && markdownMode === 'live' && !isLargeForLive"
|
v-if="isMarkdown && markdownMode === 'live' && !isLargeForLive"
|
||||||
@@ -1427,7 +1319,7 @@ defineExpose({
|
|||||||
class="skill-resource-workbench__editor"
|
class="skill-resource-workbench__editor"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
:resolve-image-url="resolveMarkdownImage"
|
:resolve-image-url="resolveMarkdownImage"
|
||||||
:upload-image="isNew || readonly ? undefined : uploadInlineImage"
|
:upload-image="readonly ? undefined : uploadInlineImage"
|
||||||
@error="handleEditorError"
|
@error="handleEditorError"
|
||||||
@fidelity-loss="handleMarkdownFidelityLoss"
|
@fidelity-loss="handleMarkdownFidelityLoss"
|
||||||
@frontmatter-activate="handleFrontmatterActivate"
|
@frontmatter-activate="handleFrontmatterActivate"
|
||||||
@@ -1540,19 +1432,6 @@ defineExpose({
|
|||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 || '从左侧选择文件'">
|
<ElEmpty v-else :description="loadError || '从左侧选择文件'">
|
||||||
<ElButton v-if="loadError" @click="init">重新加载</ElButton>
|
<ElButton v-if="loadError" @click="init">重新加载</ElButton>
|
||||||
</ElEmpty>
|
</ElEmpty>
|
||||||
@@ -1560,7 +1439,8 @@ defineExpose({
|
|||||||
|
|
||||||
<template #status>
|
<template #status>
|
||||||
<EditorStatusBar
|
<EditorStatusBar
|
||||||
v-if="activePanel === 'resources' && currentFile && !isBinaryResource"
|
v-if="currentFile && !isBinaryResource"
|
||||||
|
class="skill-resource-workbench__status"
|
||||||
:column="cursor.column"
|
:column="cursor.column"
|
||||||
:language="editorLanguage"
|
:language="editorLanguage"
|
||||||
:line="cursor.line"
|
:line="cursor.line"
|
||||||
@@ -1602,7 +1482,13 @@ defineExpose({
|
|||||||
show-icon
|
show-icon
|
||||||
/>
|
/>
|
||||||
<ElFormItem label="所属目录" prop="directory">
|
<ElFormItem label="所属目录" prop="directory">
|
||||||
<ElSelect v-model="createFileForm.directory">
|
<ElSelect
|
||||||
|
v-model="createFileForm.directory"
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
filterable
|
||||||
|
placeholder="选择或输入目录"
|
||||||
|
>
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="directory in directoryOptions"
|
v-for="directory in directoryOptions"
|
||||||
:key="directory.path"
|
:key="directory.path"
|
||||||
@@ -1653,58 +1539,6 @@ defineExpose({
|
|||||||
min-height: 0;
|
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 {
|
.skill-resource-workbench__toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
@@ -1734,7 +1568,6 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skill-resource-workbench__editor-area,
|
.skill-resource-workbench__editor-area,
|
||||||
.skill-resource-workbench__capability-area,
|
|
||||||
.skill-resource-workbench__editor {
|
.skill-resource-workbench__editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1742,8 +1575,15 @@ defineExpose({
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-resource-workbench__capability-area {
|
.skill-resource-workbench__status {
|
||||||
overflow: hidden;
|
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 {
|
.skill-resource-workbench__asset {
|
||||||
@@ -1841,27 +1681,6 @@ defineExpose({
|
|||||||
color: hsl(var(--foreground));
|
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) {
|
@media (max-width: 720px) {
|
||||||
.skill-resource-workbench__toolbar {
|
.skill-resource-workbench__toolbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ describe('skill settings dialog', () => {
|
|||||||
skill: {
|
skill: {
|
||||||
categoryId: 8,
|
categoryId: 8,
|
||||||
displayName: '原 Skill',
|
displayName: '原 Skill',
|
||||||
enabled: true,
|
|
||||||
id: 101,
|
id: 101,
|
||||||
visibilityScope: 'PRIVATE',
|
visibilityScope: 'PRIVATE',
|
||||||
},
|
},
|
||||||
@@ -78,7 +77,6 @@ describe('skill settings dialog', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
categoryId: 8,
|
categoryId: 8,
|
||||||
displayName: '更新后的 Skill',
|
displayName: '更新后的 Skill',
|
||||||
enabled: true,
|
|
||||||
id: 101,
|
id: 101,
|
||||||
visibilityScope: 'DEPT',
|
visibilityScope: 'DEPT',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
ElMessage,
|
ElMessage,
|
||||||
ElOption,
|
ElOption,
|
||||||
ElSelect,
|
ElSelect,
|
||||||
ElSwitch,
|
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { updateSkill } from './api';
|
import { updateSkill } from './api';
|
||||||
@@ -79,7 +78,6 @@ function resetForm() {
|
|||||||
categoryId: props.skill.categoryId || '',
|
categoryId: props.skill.categoryId || '',
|
||||||
categoryName: props.skill.categoryName,
|
categoryName: props.skill.categoryName,
|
||||||
displayName: props.skill.displayName || '',
|
displayName: props.skill.displayName || '',
|
||||||
enabled: props.skill.enabled !== false,
|
|
||||||
id: props.skill.id,
|
id: props.skill.id,
|
||||||
visibilityScope: props.skill.visibilityScope || 'PRIVATE',
|
visibilityScope: props.skill.visibilityScope || 'PRIVATE',
|
||||||
});
|
});
|
||||||
@@ -202,13 +200,6 @@ watch(
|
|||||||
<ElOption label="全员可见" value="PUBLIC" />
|
<ElOption label="全员可见" value="PUBLIC" />
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
|
||||||
<ElFormItem label="启用状态">
|
|
||||||
<div class="skill-settings-form__switch">
|
|
||||||
<ElSwitch v-model="form.enabled" />
|
|
||||||
<span>{{ form.enabled === false ? '停用' : '启用' }}</span>
|
|
||||||
</div>
|
|
||||||
</ElFormItem>
|
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</EasyFlowFormModal>
|
</EasyFlowFormModal>
|
||||||
</template>
|
</template>
|
||||||
@@ -222,8 +213,7 @@ watch(
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-settings-form__category-field,
|
.skill-settings-form__category-field {
|
||||||
.skill-settings-form__switch {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -234,8 +224,4 @@ watch(
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-settings-form__switch {
|
|
||||||
color: hsl(var(--text-muted));
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
RequestResult,
|
RequestResult,
|
||||||
SkillCapabilityBinding,
|
|
||||||
SkillCapabilityCandidate,
|
|
||||||
SkillCapabilityReplaceResult,
|
|
||||||
SkillCapabilityTools,
|
|
||||||
SkillCapabilityType,
|
|
||||||
SkillCategory,
|
SkillCategory,
|
||||||
SkillCategoryDraft,
|
SkillCategoryDraft,
|
||||||
SkillExportFormat,
|
|
||||||
SkillFileContent,
|
SkillFileContent,
|
||||||
SkillFileNode,
|
SkillFileNode,
|
||||||
|
SkillImportBatchResult,
|
||||||
|
SkillImportConfirmPayload,
|
||||||
SkillImportPreview,
|
SkillImportPreview,
|
||||||
SkillInfo,
|
SkillInfo,
|
||||||
SkillValidationResult,
|
SkillValidationResult,
|
||||||
@@ -17,7 +13,6 @@ import type {
|
|||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
|
|
||||||
import { buildCapabilityBindingsPayload } from './skill-capability';
|
|
||||||
import { buildSkillCategoryPayload } from './skill-category';
|
import { buildSkillCategoryPayload } from './skill-category';
|
||||||
import { buildSkillDraftPayload } from './skill-draft';
|
import { buildSkillDraftPayload } from './skill-draft';
|
||||||
|
|
||||||
@@ -60,18 +55,14 @@ export function validateSkillForPublish(id: number | string) {
|
|||||||
export function getSkillCategories() {
|
export function getSkillCategories() {
|
||||||
return api.get<RequestResult<SkillCategory[]>>(
|
return api.get<RequestResult<SkillCategory[]>>(
|
||||||
'/api/v1/skill/category/visibleList',
|
'/api/v1/skill/category/visibleList',
|
||||||
{
|
{ params: { sortKey: 'sortNo', sortType: 'asc' } },
|
||||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSkillCategoryTree() {
|
export function getSkillCategoryTree() {
|
||||||
return api.get<RequestResult<SkillCategory[]>>(
|
return api.get<RequestResult<SkillCategory[]>>(
|
||||||
'/api/v1/skill/category/tree',
|
'/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 });
|
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>>(
|
return api.post<RequestResult<null | number | string>>(
|
||||||
'/api/v1/skill/submitPublishApproval',
|
'/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) {
|
export function getSkillFileContent(skillId: number | string, path: string) {
|
||||||
return api.get<RequestResult<SkillFileContent>>(
|
return api.get<RequestResult<SkillFileContent>>(
|
||||||
'/api/v1/skill/file/content',
|
'/api/v1/skill/file/content',
|
||||||
{
|
{ params: { path, skillId } },
|
||||||
params: { path, skillId },
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +188,8 @@ export function uploadSkillFile(
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('skillId', String(skillId));
|
formData.append('skillId', String(skillId));
|
||||||
formData.append('path', path);
|
formData.append('path', path);
|
||||||
if (expectedContentHash) {
|
if (expectedContentHash)
|
||||||
formData.append('expectedContentHash', expectedContentHash);
|
formData.append('expectedContentHash', expectedContentHash);
|
||||||
}
|
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return api.postFile<RequestResult<SkillFileContent>>(
|
return api.postFile<RequestResult<SkillFileContent>>(
|
||||||
'/api/v1/skill/file/upload',
|
'/api/v1/skill/file/upload',
|
||||||
@@ -218,71 +209,18 @@ export function previewSkillFile(skillId: number | string, path: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSkillCapabilityBindings(skillId: number | string) {
|
export function importSkillPreviews(files: File[]) {
|
||||||
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) {
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
files.forEach((file) => formData.append('files', file));
|
||||||
return api.postFile<RequestResult<SkillImportPreview>>(
|
return api.postFile<RequestResult<SkillImportPreview[]>>(
|
||||||
'/api/v1/skill/import/preview',
|
'/api/v1/skill/import/preview',
|
||||||
formData,
|
formData,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importSkillConfirm(payload: {
|
export function importSkillConfirmBatch(payload: SkillImportConfirmPayload[]) {
|
||||||
capabilityMappings?: Array<{
|
return api.post<RequestResult<SkillImportBatchResult[]>>(
|
||||||
bindingKey: string;
|
'/api/v1/skill/import/confirmBatch',
|
||||||
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',
|
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,12 +231,9 @@ export function cancelSkillImport(importToken: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportSkills(
|
export function exportSkills(ids: Array<number | string>) {
|
||||||
ids: Array<number | string>,
|
|
||||||
format: SkillExportFormat,
|
|
||||||
) {
|
|
||||||
return api.download<Blob>('/api/v1/skill/export', {
|
return api.download<Blob>('/api/v1/skill/export', {
|
||||||
data: { format, ids },
|
data: { ids },
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
isSkillAccessDeniedError,
|
isSkillAccessDeniedError,
|
||||||
isSkillCapabilityConflictError,
|
|
||||||
isSkillFileConflictError,
|
isSkillFileConflictError,
|
||||||
resolveSkillApiErrorMessage,
|
resolveSkillApiErrorMessage,
|
||||||
} from './skill-api-error';
|
} from './skill-api-error';
|
||||||
@@ -15,23 +14,12 @@ describe('skill API conflict errors', () => {
|
|||||||
message: '文件已被其他操作更新',
|
message: '文件已被其他操作更新',
|
||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
|
||||||
isSkillCapabilityConflictError({
|
|
||||||
errorCode: 4093,
|
|
||||||
message: '能力配置已被其他操作更新',
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('also recognizes Axios-shaped conflicts and ignores ordinary errors', () => {
|
it('also recognizes Axios-shaped conflicts and ignores ordinary errors', () => {
|
||||||
expect(
|
expect(
|
||||||
isSkillFileConflictError({ response: { data: {}, status: 409 } }),
|
isSkillFileConflictError({ response: { data: {}, status: 409 } }),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
|
||||||
isSkillCapabilityConflictError({
|
|
||||||
response: { data: { errorCode: 4093 }, status: 400 },
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
expect(isSkillFileConflictError({ errorCode: 500 })).toBe(false);
|
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 请求是否被服务端以未登录或无权限拒绝。 */
|
/** 判断 Skill 请求是否被服务端以未登录或无权限拒绝。 */
|
||||||
export function isSkillAccessDeniedError(error: unknown) {
|
export function isSkillAccessDeniedError(error: unknown) {
|
||||||
const snapshot = errorSnapshot(error);
|
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,
|
categoryId: 8,
|
||||||
description: '研究助手',
|
description: '研究助手',
|
||||||
displayName: '研究助手',
|
displayName: '研究助手',
|
||||||
enabled: true,
|
|
||||||
publishStatus: 'DRAFT',
|
publishStatus: 'DRAFT',
|
||||||
visibilityScope: 'DEPT',
|
visibilityScope: 'DEPT',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { SkillInfo } from './types';
|
import type { SkillInfo, SkillVisibilityScope } from './types';
|
||||||
|
|
||||||
import { syncSkillMarkdownFrontmatter } from './skill-markdown';
|
import { syncSkillMarkdownFrontmatter } from './skill-markdown';
|
||||||
|
|
||||||
export type SkillCreateIntent = 'DRAFT' | 'PUBLISH';
|
|
||||||
|
|
||||||
/** Build a hidden, standards-compliant canonical name for a new Skill. */
|
/** Build a hidden, standards-compliant canonical name for a new Skill. */
|
||||||
export function createSkillCanonicalName(
|
export function createSkillCanonicalName(
|
||||||
displayName: string,
|
displayName: string,
|
||||||
@@ -24,7 +22,7 @@ export function createSkillCanonicalName(
|
|||||||
export function buildInitialSkillDraft(input: {
|
export function buildInitialSkillDraft(input: {
|
||||||
categoryId?: number | string;
|
categoryId?: number | string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
visibilityScope: string;
|
visibilityScope: SkillVisibilityScope;
|
||||||
}): SkillInfo {
|
}): SkillInfo {
|
||||||
const displayName = input.displayName.trim();
|
const displayName = input.displayName.trim();
|
||||||
const name = createSkillCanonicalName(displayName);
|
const name = createSkillCanonicalName(displayName);
|
||||||
@@ -37,7 +35,6 @@ export function buildInitialSkillDraft(input: {
|
|||||||
categoryId: input.categoryId || undefined,
|
categoryId: input.categoryId || undefined,
|
||||||
description: displayName,
|
description: displayName,
|
||||||
displayName,
|
displayName,
|
||||||
enabled: true,
|
|
||||||
name,
|
name,
|
||||||
publishStatus: 'DRAFT',
|
publishStatus: 'DRAFT',
|
||||||
skillContent: content,
|
skillContent: content,
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ describe('skill draft payload', () => {
|
|||||||
categoryId: 3,
|
categoryId: 3,
|
||||||
description: 'description',
|
description: 'description',
|
||||||
displayName: 'Display name',
|
displayName: 'Display name',
|
||||||
enabled: true,
|
|
||||||
id: 9,
|
id: 9,
|
||||||
name: 'standard-name',
|
name: 'standard-name',
|
||||||
packageHash: 'must-not-leak',
|
packageHash: 'must-not-leak',
|
||||||
resourceCount: 7,
|
|
||||||
resources: [{ contentRef: 'must-not-leak', path: 'secret.md' }],
|
resources: [{ contentRef: 'must-not-leak', path: 'secret.md' }],
|
||||||
skillContent: 'content',
|
skillContent: 'content',
|
||||||
visibilityScope: 'PRIVATE',
|
visibilityScope: 'PRIVATE',
|
||||||
@@ -25,7 +23,6 @@ describe('skill draft payload', () => {
|
|||||||
expect(payload).toEqual({
|
expect(payload).toEqual({
|
||||||
categoryId: 3,
|
categoryId: 3,
|
||||||
displayName: 'Display name',
|
displayName: 'Display name',
|
||||||
enabled: true,
|
|
||||||
skillContent: 'content',
|
skillContent: 'content',
|
||||||
visibilityScope: 'PRIVATE',
|
visibilityScope: 'PRIVATE',
|
||||||
});
|
});
|
||||||
@@ -37,7 +34,6 @@ describe('skill draft payload', () => {
|
|||||||
).toEqual({
|
).toEqual({
|
||||||
categoryId: undefined,
|
categoryId: undefined,
|
||||||
displayName: undefined,
|
displayName: undefined,
|
||||||
enabled: undefined,
|
|
||||||
id: 9,
|
id: 9,
|
||||||
visibilityScope: undefined,
|
visibilityScope: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { SkillInfo } from './types';
|
|||||||
export interface SkillDraftPayload {
|
export interface SkillDraftPayload {
|
||||||
categoryId?: number | string;
|
categoryId?: number | string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
enabled?: boolean;
|
|
||||||
id?: number | string;
|
id?: number | string;
|
||||||
skillContent?: string;
|
skillContent?: string;
|
||||||
visibilityScope?: string;
|
visibilityScope?: string;
|
||||||
@@ -25,7 +24,6 @@ export function buildSkillDraftPayload(
|
|||||||
const payload: SkillDraftPayload = {
|
const payload: SkillDraftPayload = {
|
||||||
categoryId: skill.categoryId,
|
categoryId: skill.categoryId,
|
||||||
displayName: skill.displayName,
|
displayName: skill.displayName,
|
||||||
enabled: skill.enabled,
|
|
||||||
visibilityScope: skill.visibilityScope,
|
visibilityScope: skill.visibilityScope,
|
||||||
};
|
};
|
||||||
if (includeId) payload.id = skill.id;
|
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;
|
keyword: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
publishStatus: string;
|
||||||
|
visibilityScope: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
|
const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
|
||||||
@@ -18,9 +20,11 @@ const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
|
|||||||
keyword: stringListRouteField(),
|
keyword: stringListRouteField(),
|
||||||
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
|
||||||
pageSize: positiveIntegerListRouteField({
|
pageSize: positiveIntegerListRouteField({
|
||||||
allowedValues: [12, 24, 48],
|
allowedValues: [10, 20, 50],
|
||||||
defaultValue: 12,
|
defaultValue: 10,
|
||||||
}),
|
}),
|
||||||
|
publishStatus: stringListRouteField(),
|
||||||
|
visibilityScope: stringListRouteField(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ export interface RequestResult<T = unknown> {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SkillCapabilityType = 'MCP' | 'PLUGIN_ITEM' | 'WORKFLOW';
|
|
||||||
export type SkillExportFormat = 'EASYFLOW' | 'STANDARD';
|
|
||||||
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
||||||
|
export type SkillVisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
|
||||||
|
|
||||||
export interface SkillInfo {
|
export interface SkillInfo {
|
||||||
approvalPending?: boolean;
|
approvalPending?: boolean;
|
||||||
assetCount?: number;
|
|
||||||
bindings?: SkillCapabilityBinding[];
|
|
||||||
capabilityCount?: number;
|
|
||||||
capabilityHash?: string;
|
|
||||||
categoryId?: number | string;
|
categoryId?: number | string;
|
||||||
categoryName?: string;
|
categoryName?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
@@ -23,22 +18,17 @@ export interface SkillInfo {
|
|||||||
description?: string;
|
description?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
displayPublishStatus?: string;
|
displayPublishStatus?: string;
|
||||||
enabled?: boolean;
|
|
||||||
id?: number | string;
|
id?: number | string;
|
||||||
manageable?: boolean;
|
manageable?: boolean;
|
||||||
metadataJson?: Record<string, unknown>;
|
|
||||||
modified?: string;
|
modified?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
packageHash?: string;
|
packageHash?: string;
|
||||||
publishStatus?: string;
|
publishStatus?: string;
|
||||||
referenceCount?: number;
|
readable?: boolean;
|
||||||
resourceCount?: number;
|
|
||||||
resources?: SkillResource[];
|
resources?: SkillResource[];
|
||||||
scriptCount?: number;
|
|
||||||
skillContent?: string;
|
skillContent?: string;
|
||||||
sourceType?: string;
|
snapshotHash?: string;
|
||||||
visibilityScope?: string;
|
visibilityScope?: SkillVisibilityScope;
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillResource {
|
export interface SkillResource {
|
||||||
@@ -48,9 +38,7 @@ export interface SkillResource {
|
|||||||
id?: number | string;
|
id?: number | string;
|
||||||
isText?: boolean;
|
isText?: boolean;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
language?: string;
|
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
metadataJson?: Record<string, unknown>;
|
|
||||||
path: string;
|
path: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
@@ -94,67 +82,6 @@ export interface SkillValidationResult {
|
|||||||
valid: boolean;
|
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 {
|
export interface SkillImportPreviewFile {
|
||||||
kind: 'ASSET' | 'EXAMPLE' | 'OTHER' | 'REFERENCE' | 'SCRIPT' | 'SKILL';
|
kind: 'ASSET' | 'EXAMPLE' | 'OTHER' | 'REFERENCE' | 'SCRIPT' | 'SKILL';
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
@@ -163,27 +90,40 @@ export interface SkillImportPreviewFile {
|
|||||||
text: boolean;
|
text: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillCapabilityMapping {
|
export interface SkillImportPreviewItem {
|
||||||
bindingKey: string;
|
conflict: boolean;
|
||||||
capabilityType: SkillCapabilityType;
|
conflictReason?: 'NAME_UNAVAILABLE' | 'NOT_DRAFT';
|
||||||
disabled?: boolean;
|
description?: string;
|
||||||
|
files?: SkillImportPreviewFile[];
|
||||||
|
name: string;
|
||||||
|
overwriteAllowed?: boolean;
|
||||||
|
packageHash?: string;
|
||||||
|
packageId: string;
|
||||||
packageRoot?: string;
|
packageRoot?: string;
|
||||||
runtimeName?: string;
|
|
||||||
status: 'RESOLVED' | 'UNRESOLVED';
|
|
||||||
targetId?: number | string;
|
|
||||||
targetLogicalRef?: string;
|
|
||||||
targetName?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillImportPreview {
|
export interface SkillImportPreview {
|
||||||
capabilityMappings?: SkillCapabilityMapping[];
|
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
format: SkillExportFormat;
|
importToken?: string;
|
||||||
importToken: string;
|
|
||||||
issues?: SkillValidationIssue[];
|
issues?: SkillValidationIssue[];
|
||||||
skills: SkillImportPreviewItem[];
|
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 {
|
export interface SkillFileBuffer {
|
||||||
content: string;
|
content: string;
|
||||||
conflict?: boolean;
|
conflict?: boolean;
|
||||||
|
|||||||
45
easyflow-ui-admin/packages/@core/ui-kit/editor-ui/README.md
Normal file
45
easyflow-ui-admin/packages/@core/ui-kit/editor-ui/README.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Editor UI
|
||||||
|
|
||||||
|
`@easyflow-core/editor-ui` 提供管理端通用的代码与 Markdown 编辑组件。组件样式随组件一起加载,并统一消费 EasyFlow 设计 Token;业务页面无需覆盖 Milkdown 或 CodeMirror 内部样式。
|
||||||
|
|
||||||
|
## Markdown 实时编辑器
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { MarkdownLiveEditor } from '@easyflow-core/editor-ui';
|
||||||
|
|
||||||
|
const content = ref('# 标题');
|
||||||
|
const readonly = ref(false);
|
||||||
|
const props = defineProps<{
|
||||||
|
saveDocument: (content: string) => Promise<void>;
|
||||||
|
resolveImageUrl: (url: string) => Promise<string> | string;
|
||||||
|
uploadImage: (file: File) => Promise<string>;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MarkdownLiveEditor
|
||||||
|
v-model="content"
|
||||||
|
:readonly="readonly"
|
||||||
|
placeholder="输入 Markdown 内容…"
|
||||||
|
:resolve-image-url="props.resolveImageUrl"
|
||||||
|
:upload-image="props.uploadImage"
|
||||||
|
@save="props.saveDocument(content)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
主要接口:
|
||||||
|
|
||||||
|
- `v-model`:Markdown 原文。
|
||||||
|
- `readonly`:只读模式。
|
||||||
|
- `placeholder`:空文档占位文案。
|
||||||
|
- `resolveImageUrl`:将 Markdown 图片地址解析为可预览地址。
|
||||||
|
- `uploadImage`:上传图片并返回写入 Markdown 的地址。
|
||||||
|
- `save`:用户按下 `Ctrl/Cmd + S`。
|
||||||
|
- `linkClick`:用户点击相对链接。
|
||||||
|
- `fidelityLoss`:内容包含实时模式无法保真的语法,调用方应切换源码模式。
|
||||||
|
|
||||||
|
源码编辑场景可直接使用 `MarkdownSourceEditor`。
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
foldGutter,
|
foldGutter,
|
||||||
foldKeymap,
|
foldKeymap,
|
||||||
indentOnInput,
|
indentOnInput,
|
||||||
|
StreamLanguage,
|
||||||
syntaxHighlighting,
|
syntaxHighlighting,
|
||||||
} from '@codemirror/language';
|
} from '@codemirror/language';
|
||||||
import {
|
import {
|
||||||
@@ -77,25 +78,53 @@ let languageRequest = 0;
|
|||||||
let syncingFromOutside = false;
|
let syncingFromOutside = false;
|
||||||
|
|
||||||
async function loadLanguage(language: EditorLanguage): Promise<Extension> {
|
async function loadLanguage(language: EditorLanguage): Promise<Extension> {
|
||||||
|
if (language === 'css') {
|
||||||
|
const { css } = await import('@codemirror/legacy-modes/mode/css');
|
||||||
|
return StreamLanguage.define(css);
|
||||||
|
}
|
||||||
|
if (language === 'html') {
|
||||||
|
const { html } = await import('@codemirror/legacy-modes/mode/xml');
|
||||||
|
return StreamLanguage.define(html);
|
||||||
|
}
|
||||||
|
if (language === 'java') {
|
||||||
|
const { java } = await import('@codemirror/legacy-modes/mode/clike');
|
||||||
|
return StreamLanguage.define(java);
|
||||||
|
}
|
||||||
if (language === 'python') {
|
if (language === 'python') {
|
||||||
const { python } = await import('@codemirror/lang-python');
|
const { python } = await import('@codemirror/lang-python');
|
||||||
return python();
|
return python();
|
||||||
}
|
}
|
||||||
if (language === 'javascript' || language === 'json') {
|
if (language === 'json') {
|
||||||
|
const { json } = await import('@codemirror/legacy-modes/mode/javascript');
|
||||||
|
return StreamLanguage.define(json);
|
||||||
|
}
|
||||||
|
if (language === 'javascript' || language === 'typescript') {
|
||||||
const { javascript } = await import('@codemirror/lang-javascript');
|
const { javascript } = await import('@codemirror/lang-javascript');
|
||||||
return javascript({ jsx: language === 'javascript', typescript: true });
|
return javascript({
|
||||||
|
jsx: true,
|
||||||
|
typescript: language === 'typescript',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (language === 'markdown') {
|
if (language === 'markdown') {
|
||||||
const { markdown } = await import('@codemirror/lang-markdown');
|
const { markdown } = await import('@codemirror/lang-markdown');
|
||||||
return markdown();
|
return markdown();
|
||||||
}
|
}
|
||||||
if (language === 'shell') {
|
if (language === 'shell') {
|
||||||
const [{ StreamLanguage }, { shell }] = await Promise.all([
|
const { shell } = await import('@codemirror/legacy-modes/mode/shell');
|
||||||
import('@codemirror/language'),
|
|
||||||
import('@codemirror/legacy-modes/mode/shell'),
|
|
||||||
]);
|
|
||||||
return StreamLanguage.define(shell);
|
return StreamLanguage.define(shell);
|
||||||
}
|
}
|
||||||
|
if (language === 'sql') {
|
||||||
|
const { standardSQL } = await import('@codemirror/legacy-modes/mode/sql');
|
||||||
|
return StreamLanguage.define(standardSQL);
|
||||||
|
}
|
||||||
|
if (language === 'xml') {
|
||||||
|
const { xml } = await import('@codemirror/legacy-modes/mode/xml');
|
||||||
|
return StreamLanguage.define(xml);
|
||||||
|
}
|
||||||
|
if (language === 'yaml') {
|
||||||
|
const { yaml } = await import('@codemirror/legacy-modes/mode/yaml');
|
||||||
|
return StreamLanguage.define(yaml);
|
||||||
|
}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe('markdown live editor DOM safety', () => {
|
|||||||
'.milkdown-frontmatter',
|
'.milkdown-frontmatter',
|
||||||
);
|
);
|
||||||
expect(frontmatterNode).not.toBeNull();
|
expect(frontmatterNode).not.toBeNull();
|
||||||
expect(wrapper.text()).toContain('Skill 元数据');
|
expect(wrapper.text()).toContain('YAML 元数据');
|
||||||
expect(wrapper.text()).toContain('name: research-skill');
|
expect(wrapper.text()).toContain('name: research-skill');
|
||||||
expect(wrapper.text()).toContain('Use this skill');
|
expect(wrapper.text()).toContain('Use this skill');
|
||||||
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');
|
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');
|
||||||
|
|||||||
@@ -4,7 +4,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
|
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
|
||||||
|
|
||||||
|
interface MockFeatureConfig {
|
||||||
|
advancedGroup?: Record<string, unknown>;
|
||||||
|
listGroup?: Record<string, unknown>;
|
||||||
|
onUpload?: (file: File) => Promise<string>;
|
||||||
|
proxyDomURL?: (url: string) => Promise<string> | string;
|
||||||
|
text?: string;
|
||||||
|
textGroup?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockCrepeConfig {
|
||||||
|
defaultValue?: string;
|
||||||
|
featureConfigs?: Record<string, MockFeatureConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
const mockState = vi.hoisted(() => ({
|
const mockState = vi.hoisted(() => ({
|
||||||
|
config: undefined as MockCrepeConfig | undefined,
|
||||||
dispatch: vi.fn(),
|
dispatch: vi.fn(),
|
||||||
destroy: vi.fn(async () => undefined),
|
destroy: vi.fn(async () => undefined),
|
||||||
listSpreadNodes: [] as Array<{
|
listSpreadNodes: [] as Array<{
|
||||||
@@ -14,16 +29,30 @@ const mockState = vi.hoisted(() => ({
|
|||||||
type: { name: string };
|
type: { name: string };
|
||||||
}>,
|
}>,
|
||||||
createPromise: undefined as Promise<void> | undefined,
|
createPromise: undefined as Promise<void> | undefined,
|
||||||
|
firstNode: undefined as
|
||||||
|
| undefined
|
||||||
|
| { nodeSize: number; type: { name: string } },
|
||||||
markdown: '# Initial',
|
markdown: '# Initial',
|
||||||
markdownUpdated: undefined as
|
markdownUpdated: undefined as
|
||||||
| ((ctx: unknown, value: string) => void)
|
| ((ctx: unknown, value: string) => void)
|
||||||
| undefined,
|
| undefined,
|
||||||
replaceAll: vi.fn(),
|
replaceAll: vi.fn(),
|
||||||
|
resolve: vi.fn((position: number) => ({ position })),
|
||||||
|
selectionFrom: 2,
|
||||||
|
setSelection: vi.fn(),
|
||||||
setNodeMarkup: vi.fn(),
|
setNodeMarkup: vi.fn(),
|
||||||
setReadonly: vi.fn(),
|
setReadonly: vi.fn(),
|
||||||
setTransactionMeta: vi.fn(),
|
setTransactionMeta: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@milkdown/kit/prose/state', () => ({
|
||||||
|
TextSelection: {
|
||||||
|
near: (position: { position: number }) => ({
|
||||||
|
position: position.position,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@milkdown/kit/utils', () => ({
|
vi.mock('@milkdown/kit/utils', () => ({
|
||||||
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
|
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
|
||||||
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
|
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
|
||||||
@@ -39,6 +68,7 @@ vi.mock('@milkdown/crepe', () => {
|
|||||||
class MockCrepe {
|
class MockCrepe {
|
||||||
static Feature = {
|
static Feature = {
|
||||||
AI: 'ai',
|
AI: 'ai',
|
||||||
|
BlockEdit: 'block-edit',
|
||||||
ImageBlock: 'image-block',
|
ImageBlock: 'image-block',
|
||||||
Latex: 'latex',
|
Latex: 'latex',
|
||||||
Placeholder: 'placeholder',
|
Placeholder: 'placeholder',
|
||||||
@@ -56,6 +86,8 @@ vi.mock('@milkdown/crepe', () => {
|
|||||||
if (slice.name === 'editorState') {
|
if (slice.name === 'editorState') {
|
||||||
return {
|
return {
|
||||||
doc: {
|
doc: {
|
||||||
|
firstChild: mockState.firstNode,
|
||||||
|
resolve: mockState.resolve,
|
||||||
descendants: (
|
descendants: (
|
||||||
callback: (
|
callback: (
|
||||||
node: (typeof mockState.listSpreadNodes)[number],
|
node: (typeof mockState.listSpreadNodes)[number],
|
||||||
@@ -66,9 +98,11 @@ vi.mock('@milkdown/crepe', () => {
|
|||||||
callback(node, node.position),
|
callback(node, node.position),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
selection: { from: mockState.selectionFrom },
|
||||||
tr: {
|
tr: {
|
||||||
setMeta: mockState.setTransactionMeta,
|
setMeta: mockState.setTransactionMeta,
|
||||||
setNodeMarkup: mockState.setNodeMarkup,
|
setNodeMarkup: mockState.setNodeMarkup,
|
||||||
|
setSelection: mockState.setSelection,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -82,7 +116,8 @@ vi.mock('@milkdown/crepe', () => {
|
|||||||
use: () => undefined,
|
use: () => undefined,
|
||||||
};
|
};
|
||||||
setReadonly = mockState.setReadonly;
|
setReadonly = mockState.setReadonly;
|
||||||
constructor(config: { defaultValue?: string }) {
|
constructor(config: MockCrepeConfig) {
|
||||||
|
mockState.config = config;
|
||||||
mockState.markdown = config.defaultValue || '';
|
mockState.markdown = config.defaultValue || '';
|
||||||
}
|
}
|
||||||
getMarkdown = () => mockState.markdown;
|
getMarkdown = () => mockState.markdown;
|
||||||
@@ -101,10 +136,15 @@ vi.mock('@milkdown/crepe', () => {
|
|||||||
|
|
||||||
describe('markdownLiveEditor', () => {
|
describe('markdownLiveEditor', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockState.config = undefined;
|
||||||
mockState.destroy.mockClear();
|
mockState.destroy.mockClear();
|
||||||
mockState.createPromise = undefined;
|
mockState.createPromise = undefined;
|
||||||
mockState.dispatch.mockClear();
|
mockState.dispatch.mockClear();
|
||||||
|
mockState.firstNode = undefined;
|
||||||
mockState.replaceAll.mockClear();
|
mockState.replaceAll.mockClear();
|
||||||
|
mockState.resolve.mockClear();
|
||||||
|
mockState.selectionFrom = 2;
|
||||||
|
mockState.setSelection.mockClear();
|
||||||
mockState.setReadonly.mockClear();
|
mockState.setReadonly.mockClear();
|
||||||
mockState.markdown = '# Initial';
|
mockState.markdown = '# Initial';
|
||||||
mockState.markdownUpdated = undefined;
|
mockState.markdownUpdated = undefined;
|
||||||
@@ -115,6 +155,133 @@ describe('markdownLiveEditor', () => {
|
|||||||
|
|
||||||
afterEach(() => vi.useRealTimers());
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
it('uses generic Markdown copy by default', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockState.config?.featureConfigs?.placeholder?.text).toBe(
|
||||||
|
'输入 Markdown 内容…',
|
||||||
|
);
|
||||||
|
|
||||||
|
const upload = mockState.config?.featureConfigs?.['image-block']?.onUpload;
|
||||||
|
await expect(upload?.(new File(['image'], 'image.png'))).rejects.toThrow(
|
||||||
|
'当前编辑器未配置图片上传',
|
||||||
|
);
|
||||||
|
expect(wrapper.emitted('error')?.[0]?.[0]).toEqual(
|
||||||
|
new Error('当前编辑器未配置图片上传'),
|
||||||
|
);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes reusable placeholder and image callbacks to Crepe', async () => {
|
||||||
|
const resolveImageUrl = vi.fn((url: string) => `/preview/${url}`);
|
||||||
|
const uploadImage = vi.fn(async (file: File) => `/uploads/${file.name}`);
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: {
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: '编写发布说明…',
|
||||||
|
resolveImageUrl,
|
||||||
|
uploadImage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const config = mockState.config?.featureConfigs;
|
||||||
|
expect(config?.placeholder?.text).toBe('编写发布说明…');
|
||||||
|
expect(await config?.['image-block']?.proxyDomURL?.('asset.png')).toBe(
|
||||||
|
'/preview/asset.png',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await config?.['image-block']?.onUpload?.(
|
||||||
|
new File(['image'], 'asset.png'),
|
||||||
|
),
|
||||||
|
).toBe('/uploads/asset.png');
|
||||||
|
expect(resolveImageUrl).toHaveBeenCalledWith('asset.png');
|
||||||
|
expect(uploadImage).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('localizes the block menu and exposes one keyboard menu trigger', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(mockState.config?.featureConfigs?.['block-edit']).toMatchObject({
|
||||||
|
advancedGroup: {
|
||||||
|
codeBlock: { label: '代码块' },
|
||||||
|
image: { label: '图片' },
|
||||||
|
label: '更多',
|
||||||
|
table: { label: '表格' },
|
||||||
|
},
|
||||||
|
listGroup: {
|
||||||
|
bulletList: { label: '无序列表' },
|
||||||
|
label: '列表',
|
||||||
|
orderedList: { label: '有序列表' },
|
||||||
|
},
|
||||||
|
textGroup: {
|
||||||
|
h1: { label: '一级标题' },
|
||||||
|
label: '文本',
|
||||||
|
text: { label: '正文' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const host = wrapper.get('.easyflow-markdown-live-editor__host').element;
|
||||||
|
const handle = document.createElement('div');
|
||||||
|
handle.className = 'milkdown-block-handle';
|
||||||
|
handle.innerHTML =
|
||||||
|
'<div class="operation-item"></div><div class="operation-item"></div>';
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'milkdown-slash-menu';
|
||||||
|
host.append(handle, menu);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
const hiddenAddButton = handle.firstElementChild as HTMLElement;
|
||||||
|
const trigger = handle.lastElementChild as HTMLElement;
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(hiddenAddButton.getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(trigger.getAttribute('aria-label')).toBe('打开内容块菜单');
|
||||||
|
expect(trigger.getAttribute('role')).toBe('button');
|
||||||
|
expect(trigger.tabIndex).toBe(0);
|
||||||
|
expect(menu.getAttribute('aria-label')).toBe('内容块菜单');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pointerup = vi.fn();
|
||||||
|
hiddenAddButton.addEventListener('pointerup', pointerup);
|
||||||
|
trigger.click();
|
||||||
|
expect(pointerup).toHaveBeenCalledOnce();
|
||||||
|
trigger.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
|
||||||
|
);
|
||||||
|
expect(pointerup).toHaveBeenCalledTimes(2);
|
||||||
|
mockState.markdownUpdated?.({}, '# Menu edit');
|
||||||
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
||||||
|
'# Menu edit',
|
||||||
|
]);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the save shortcut only while the editor is writable', async () => {
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const host = wrapper.get('.easyflow-markdown-live-editor__host');
|
||||||
|
|
||||||
|
await host.trigger('keydown', { key: 's', metaKey: true });
|
||||||
|
expect(wrapper.emitted('save')).toHaveLength(1);
|
||||||
|
|
||||||
|
await wrapper.setProps({ readonly: true });
|
||||||
|
await host.trigger('keydown', { ctrlKey: true, key: 's' });
|
||||||
|
expect(wrapper.emitted('save')).toHaveLength(1);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses a flush replace for external values without emitting a delayed model loop', async () => {
|
it('uses a flush replace for external values without emitting a delayed model loop', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const wrapper = mount(MarkdownLiveEditor, {
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
@@ -129,7 +296,7 @@ describe('markdownLiveEditor', () => {
|
|||||||
|
|
||||||
await wrapper
|
await wrapper
|
||||||
.get('.easyflow-markdown-live-editor__host')
|
.get('.easyflow-markdown-live-editor__host')
|
||||||
.trigger('beforeinput');
|
.trigger('keydown', { key: 'Enter' });
|
||||||
mockState.markdownUpdated?.({}, '# User edit');
|
mockState.markdownUpdated?.({}, '# User edit');
|
||||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
||||||
'# User edit',
|
'# User edit',
|
||||||
@@ -158,6 +325,27 @@ describe('markdownLiveEditor', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redirects body input after immutable leading frontmatter', async () => {
|
||||||
|
mockState.firstNode = {
|
||||||
|
nodeSize: 1,
|
||||||
|
type: { name: 'frontmatter' },
|
||||||
|
};
|
||||||
|
mockState.selectionFrom = 0;
|
||||||
|
mockState.setSelection.mockReturnValue({ redirected: true });
|
||||||
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
|
props: { modelValue: '---\nname: review\n---\n\n# Initial' },
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
await wrapper
|
||||||
|
.get('.easyflow-markdown-live-editor__host')
|
||||||
|
.trigger('beforeinput');
|
||||||
|
|
||||||
|
expect(mockState.resolve).toHaveBeenCalledWith(1);
|
||||||
|
expect(mockState.setSelection).toHaveBeenCalledWith({ position: 1 });
|
||||||
|
expect(mockState.dispatch).toHaveBeenCalledWith({ redirected: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores lossless normalization emitted after the editor is ready', async () => {
|
it('ignores lossless normalization emitted after the editor is ready', async () => {
|
||||||
const wrapper = mount(MarkdownLiveEditor, {
|
const wrapper = mount(MarkdownLiveEditor, {
|
||||||
props: { modelValue: '- first\n- second' },
|
props: { modelValue: '- first\n- second' },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
|||||||
|
|
||||||
import { Crepe } from '@milkdown/crepe';
|
import { Crepe } from '@milkdown/crepe';
|
||||||
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
|
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
|
||||||
|
import { TextSelection } from '@milkdown/kit/prose/state';
|
||||||
import { replaceAll } from '@milkdown/kit/utils';
|
import { replaceAll } from '@milkdown/kit/utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +27,7 @@ const props = withDefaults(
|
|||||||
uploadImage?: (file: File) => Promise<string>;
|
uploadImage?: (file: File) => Promise<string>;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
placeholder: '输入 Skill 指令…',
|
placeholder: '输入 Markdown 内容…',
|
||||||
readonly: false,
|
readonly: false,
|
||||||
resolveImageUrl: undefined,
|
resolveImageUrl: undefined,
|
||||||
uploadImage: undefined,
|
uploadImage: undefined,
|
||||||
@@ -64,12 +65,37 @@ async function createEditor() {
|
|||||||
const crepe = new Crepe({
|
const crepe = new Crepe({
|
||||||
defaultValue: initialModelValue,
|
defaultValue: initialModelValue,
|
||||||
featureConfigs: {
|
featureConfigs: {
|
||||||
|
[Crepe.Feature.BlockEdit]: {
|
||||||
|
textGroup: {
|
||||||
|
label: '文本',
|
||||||
|
text: { label: '正文' },
|
||||||
|
h1: { label: '一级标题' },
|
||||||
|
h2: { label: '二级标题' },
|
||||||
|
h3: { label: '三级标题' },
|
||||||
|
h4: { label: '四级标题' },
|
||||||
|
h5: { label: '五级标题' },
|
||||||
|
h6: { label: '六级标题' },
|
||||||
|
quote: { label: '引用' },
|
||||||
|
divider: { label: '分隔线' },
|
||||||
|
},
|
||||||
|
listGroup: {
|
||||||
|
label: '列表',
|
||||||
|
bulletList: { label: '无序列表' },
|
||||||
|
orderedList: { label: '有序列表' },
|
||||||
|
taskList: { label: '任务列表' },
|
||||||
|
},
|
||||||
|
advancedGroup: {
|
||||||
|
label: '更多',
|
||||||
|
image: { label: '图片' },
|
||||||
|
codeBlock: { label: '代码块' },
|
||||||
|
table: { label: '表格' },
|
||||||
|
math: { label: '公式' },
|
||||||
|
},
|
||||||
|
},
|
||||||
[Crepe.Feature.ImageBlock]: {
|
[Crepe.Feature.ImageBlock]: {
|
||||||
onUpload: async (file) => {
|
onUpload: async (file) => {
|
||||||
if (props.uploadImage) return props.uploadImage(file);
|
if (props.uploadImage) return props.uploadImage(file);
|
||||||
const error = new Error(
|
const error = new Error('当前编辑器未配置图片上传');
|
||||||
'当前上下文不支持直接上传图片,请从资源文件树上传',
|
|
||||||
);
|
|
||||||
emit('error', error);
|
emit('error', error);
|
||||||
throw error;
|
throw error;
|
||||||
},
|
},
|
||||||
@@ -199,32 +225,36 @@ watch(
|
|||||||
|
|
||||||
onMounted(createEditor);
|
onMounted(createEditor);
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
hostRef.value?.addEventListener('click', handleBlockMenuClick);
|
||||||
hostRef.value?.addEventListener('click', handleLinkClick);
|
hostRef.value?.addEventListener('click', handleLinkClick);
|
||||||
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
|
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
|
||||||
hostRef.value?.addEventListener('beforeinput', markUserEdited);
|
hostRef.value?.addEventListener('beforeinput', prepareUserEdit, true);
|
||||||
hostRef.value?.addEventListener('drop', markUserEdited);
|
hostRef.value?.addEventListener('drop', markUserEdited);
|
||||||
hostRef.value?.addEventListener('keydown', handleKeydown);
|
hostRef.value?.addEventListener('keydown', handleKeydown, true);
|
||||||
hostRef.value?.addEventListener('paste', markUserEdited);
|
hostRef.value?.addEventListener('paste', prepareUserEdit, true);
|
||||||
|
hostRef.value?.addEventListener('pointerup', prepareBlockMenuEdit, true);
|
||||||
if (hostRef.value) {
|
if (hostRef.value) {
|
||||||
domObserver = new MutationObserver(sanitizeRenderedLinks);
|
domObserver = new MutationObserver(refreshRenderedContent);
|
||||||
domObserver.observe(hostRef.value, {
|
domObserver.observe(hostRef.value, {
|
||||||
attributeFilter: ['href'],
|
attributeFilter: ['href'],
|
||||||
attributes: true,
|
attributes: true,
|
||||||
childList: true,
|
childList: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
});
|
});
|
||||||
sanitizeRenderedLinks();
|
refreshRenderedContent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onBeforeUnmount(async () => {
|
onBeforeUnmount(async () => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
ready = false;
|
ready = false;
|
||||||
|
hostRef.value?.removeEventListener('click', handleBlockMenuClick);
|
||||||
hostRef.value?.removeEventListener('click', handleLinkClick);
|
hostRef.value?.removeEventListener('click', handleLinkClick);
|
||||||
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
|
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
|
||||||
hostRef.value?.removeEventListener('beforeinput', markUserEdited);
|
hostRef.value?.removeEventListener('beforeinput', prepareUserEdit, true);
|
||||||
hostRef.value?.removeEventListener('drop', markUserEdited);
|
hostRef.value?.removeEventListener('drop', markUserEdited);
|
||||||
hostRef.value?.removeEventListener('keydown', handleKeydown);
|
hostRef.value?.removeEventListener('keydown', handleKeydown, true);
|
||||||
hostRef.value?.removeEventListener('paste', markUserEdited);
|
hostRef.value?.removeEventListener('paste', prepareUserEdit, true);
|
||||||
|
hostRef.value?.removeEventListener('pointerup', prepareBlockMenuEdit, true);
|
||||||
domObserver?.disconnect();
|
domObserver?.disconnect();
|
||||||
domObserver = undefined;
|
domObserver = undefined;
|
||||||
await crepeRef.value?.destroy();
|
await crepeRef.value?.destroy();
|
||||||
@@ -260,10 +290,67 @@ function handleFrontmatterActivate(event: Event) {
|
|||||||
emit('frontmatterActivate');
|
emit('frontmatterActivate');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleBlockMenuClick(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const trigger = target?.closest<HTMLElement>(
|
||||||
|
'.milkdown-block-handle .operation-item:last-child',
|
||||||
|
);
|
||||||
|
if (!trigger) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
activateBlockMenu(trigger);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateBlockMenu(trigger: HTMLElement) {
|
||||||
|
trigger.parentElement
|
||||||
|
?.querySelector<HTMLElement>('.operation-item:first-child')
|
||||||
|
?.dispatchEvent(
|
||||||
|
new Event('pointerup', { bubbles: true, cancelable: true }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function markUserEdited() {
|
function markUserEdited() {
|
||||||
hasUserEdited = true;
|
hasUserEdited = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareBlockMenuEdit(event: PointerEvent) {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
target?.closest(
|
||||||
|
'.milkdown-block-handle .operation-item, .milkdown-slash-menu .menu-group li',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
markUserEdited();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects typing and paste operations after the immutable frontmatter atom.
|
||||||
|
* ProseMirror otherwise allows a gap selection before the first atom, which
|
||||||
|
* would serialize body content ahead of the required YAML metadata block.
|
||||||
|
*/
|
||||||
|
function prepareUserEdit() {
|
||||||
|
markUserEdited();
|
||||||
|
const crepe = crepeRef.value;
|
||||||
|
if (!crepe) return;
|
||||||
|
crepe.editor.action((ctx) => {
|
||||||
|
const state = ctx.get(editorStateCtx);
|
||||||
|
const firstNode = state.doc.firstChild;
|
||||||
|
if (
|
||||||
|
firstNode?.type.name !== 'frontmatter' ||
|
||||||
|
state.selection.from > firstNode.nodeSize
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const view = ctx.get(editorViewCtx);
|
||||||
|
const bodySelection = TextSelection.near(
|
||||||
|
state.doc.resolve(firstNode.nodeSize),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
view.dispatch(state.tr.setSelection(bodySelection));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeRenderedLinks() {
|
function sanitizeRenderedLinks() {
|
||||||
hostRef.value
|
hostRef.value
|
||||||
?.querySelectorAll<HTMLAnchorElement>('a[href]')
|
?.querySelectorAll<HTMLAnchorElement>('a[href]')
|
||||||
@@ -281,11 +368,53 @@ function sanitizeRenderedLinks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshRenderedContent() {
|
||||||
|
sanitizeRenderedLinks();
|
||||||
|
enhanceBlockMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enhanceBlockMenu() {
|
||||||
|
const host = hostRef.value;
|
||||||
|
if (!host) return;
|
||||||
|
host
|
||||||
|
.querySelectorAll<HTMLElement>(
|
||||||
|
'.milkdown-block-handle .operation-item:first-child',
|
||||||
|
)
|
||||||
|
.forEach((hiddenAddButton) => {
|
||||||
|
hiddenAddButton.setAttribute('aria-hidden', 'true');
|
||||||
|
hiddenAddButton.setAttribute('tabindex', '-1');
|
||||||
|
});
|
||||||
|
host
|
||||||
|
.querySelectorAll<HTMLElement>(
|
||||||
|
'.milkdown-block-handle .operation-item:last-child',
|
||||||
|
)
|
||||||
|
.forEach((trigger) => {
|
||||||
|
trigger.setAttribute('aria-label', '打开内容块菜单');
|
||||||
|
trigger.setAttribute('role', 'button');
|
||||||
|
trigger.setAttribute('tabindex', '0');
|
||||||
|
trigger.setAttribute('title', '打开内容块菜单');
|
||||||
|
});
|
||||||
|
host.querySelectorAll<HTMLElement>('.milkdown-slash-menu').forEach((menu) => {
|
||||||
|
menu.setAttribute('aria-label', '内容块菜单');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
const blockMenuTrigger = target?.closest<HTMLElement>(
|
||||||
|
'.milkdown-block-handle .operation-item:last-child',
|
||||||
|
);
|
||||||
|
if (blockMenuTrigger && (event.key === 'Enter' || event.key === ' ')) {
|
||||||
|
event.preventDefault();
|
||||||
|
activateBlockMenu(blockMenuTrigger);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!props.readonly) emit('save');
|
if (!props.readonly) emit('save');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
prepareUserEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function reportFidelityLoss(crepe: Crepe) {
|
function reportFidelityLoss(crepe: Crepe) {
|
||||||
@@ -370,12 +499,14 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
background: hsl(var(--surface-panel));
|
background: hsl(var(--surface-panel));
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor__host {
|
.easyflow-markdown-live-editor__host {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +536,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
--crepe-color-on-secondary: hsl(var(--nav-item-active-foreground));
|
--crepe-color-on-secondary: hsl(var(--nav-item-active-foreground));
|
||||||
--crepe-color-inverse: hsl(var(--foreground));
|
--crepe-color-inverse: hsl(var(--foreground));
|
||||||
--crepe-color-on-inverse: hsl(var(--surface-panel));
|
--crepe-color-on-inverse: hsl(var(--surface-panel));
|
||||||
--crepe-color-inline-code: hsl(var(--destructive));
|
--crepe-color-inline-code: hsl(var(--nav-item-active-foreground));
|
||||||
--crepe-color-error: hsl(var(--destructive));
|
--crepe-color-error: hsl(var(--destructive));
|
||||||
--crepe-color-hover: hsl(var(--nav-item-hover));
|
--crepe-color-hover: hsl(var(--nav-item-hover));
|
||||||
--crepe-color-selected: hsl(var(--nav-item-active));
|
--crepe-color-selected: hsl(var(--nav-item-active));
|
||||||
@@ -431,62 +562,108 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.76;
|
line-height: 1.76;
|
||||||
color: hsl(var(--text-strong));
|
color: hsl(var(--text-strong));
|
||||||
|
caret-color: hsl(var(--primary));
|
||||||
background: transparent;
|
background: transparent;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(.ProseMirror:focus-visible) {
|
.easyflow-markdown-live-editor :deep(.ProseMirror::selection),
|
||||||
outline: none;
|
.easyflow-markdown-live-editor :deep(.ProseMirror *::selection) {
|
||||||
box-shadow: inset 3px 0 hsl(var(--primary) / 28%);
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--nav-item-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.ProseMirror .ProseMirror-selectednode) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 64%);
|
||||||
|
outline-offset: 2px;
|
||||||
|
background: hsl(var(--nav-item-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.ProseMirror img.ProseMirror-selectednode) {
|
||||||
|
outline-color: hsl(var(--primary));
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.ProseMirror-focused) {
|
||||||
|
--prosemirror-virtual-cursor-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.ProseMirror-gapcursor::after) {
|
||||||
|
border-top: 2px solid hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
|
||||||
|
height: 2px !important;
|
||||||
|
background-color: hsl(var(--primary)) !important;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
opacity: 0.9 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter) {
|
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-block: var(--space-4);
|
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
|
margin-block: var(--space-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: hsl(var(--surface-subtle));
|
background: hsl(var(--surface-subtle));
|
||||||
border: 0;
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
border-left: 3px solid hsl(var(--primary) / 36%);
|
border-left: 3px solid hsl(var(--primary) / 58%);
|
||||||
border-radius: 0;
|
border-radius: var(--radius-control);
|
||||||
transition:
|
transition:
|
||||||
border-color var(--motion-duration-fast) var(--motion-ease-standard),
|
border-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
background-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||||
|
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:hover),
|
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:hover),
|
||||||
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:focus-visible) {
|
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:focus-visible) {
|
||||||
background: hsl(var(--surface-contrast-soft));
|
background: hsl(var(--surface-contrast-soft));
|
||||||
border-left-color: hsl(var(--primary));
|
border-left-color: hsl(var(--primary));
|
||||||
outline: none;
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:focus-visible) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 72%);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__title) {
|
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__title) {
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-1);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
color: hsl(var(--text-muted));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
|
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
|
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: hsl(var(--text-strong));
|
color: hsl(var(--text-strong));
|
||||||
|
overflow-wrap: anywhere;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(a) {
|
.easyflow-markdown-live-editor :deep(a) {
|
||||||
|
font-weight: 520;
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--primary));
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
text-underline-offset: 3px;
|
text-underline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(a:hover) {
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(a:focus-visible) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 72%);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(img) {
|
.easyflow-markdown-live-editor :deep(img) {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -500,6 +677,20 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
letter-spacing: -0.012em;
|
letter-spacing: -0.012em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(strong) {
|
||||||
|
font-weight: 680;
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(em) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(del) {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(h1) {
|
.easyflow-markdown-live-editor :deep(h1) {
|
||||||
padding-bottom: var(--space-3);
|
padding-bottom: var(--space-3);
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -528,24 +719,119 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(blockquote) {
|
.easyflow-markdown-live-editor :deep(blockquote) {
|
||||||
padding: var(--space-2) var(--space-4);
|
padding: var(--space-2) var(--space-4);
|
||||||
color: hsl(var(--text-muted));
|
color: hsl(var(--foreground));
|
||||||
background: hsl(var(--surface-subtle));
|
background: hsl(var(--surface-subtle));
|
||||||
border-left: 3px solid hsl(var(--primary) / 48%);
|
border-left: 3px solid hsl(var(--primary) / 64%);
|
||||||
|
border-radius: 0 var(--radius-control) var(--radius-control) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(pre) {
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block),
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.ProseMirror pre:not(.milkdown-frontmatter__code)) {
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: hsl(var(--surface-contrast-soft));
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
border: 1px solid hsl(var(--line-subtle));
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
border-radius: var(--radius-control);
|
border-radius: var(--radius-control);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block) {
|
||||||
|
padding: var(--space-2) var(--space-4) var(--space-4);
|
||||||
|
box-shadow: var(--shadow-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block.selected) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 64%);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-editor),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-gutters) {
|
||||||
|
color: hsl(var(--text-strong));
|
||||||
|
background: hsl(var(--surface-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-gutters) {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
border-right: 1px solid hsl(var(--line-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-code-block .cm-cursor) {
|
||||||
|
border-left: 2px solid hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-code-block .cm-selectionBackground) {
|
||||||
|
background: hsl(var(--nav-item-active)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(code) {
|
.easyflow-markdown-live-editor :deep(code) {
|
||||||
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
|
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.ProseMirror :not(pre) > code) {
|
||||||
|
display: inline;
|
||||||
|
padding: 2px 5px;
|
||||||
|
font-weight: 560;
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
background: hsl(var(--surface-contrast-soft));
|
||||||
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(ul > li::marker),
|
||||||
|
.easyflow-markdown-live-editor :deep(ol > li::marker) {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-list-item-block .label-wrapper) {
|
||||||
|
min-height: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-list-item-block .label-wrapper svg) {
|
||||||
|
color: currentcolor;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-list-item-block .label-wrapper .label) {
|
||||||
|
height: auto;
|
||||||
|
padding-block: 0;
|
||||||
|
line-height: 1.76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(
|
||||||
|
.milkdown-list-item-block > .list-item > .children > [data-content-dom] > p
|
||||||
|
) {
|
||||||
|
margin-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(
|
||||||
|
.milkdown-list-item-block
|
||||||
|
> .list-item
|
||||||
|
> .children
|
||||||
|
> [data-content-dom]
|
||||||
|
> p
|
||||||
|
+ p
|
||||||
|
) {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(hr) {
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin-block: var(--space-6);
|
||||||
|
background: hsl(var(--line-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(table) {
|
.easyflow-markdown-live-editor :deep(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -563,6 +849,155 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
background: hsl(var(--surface-subtle));
|
background: hsl(var(--surface-subtle));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.crepe-placeholder::before) {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-toolbar),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-link-preview),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-link-edit),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu),
|
||||||
|
.easyflow-markdown-live-editor :deep(.language-picker) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--surface-elevated));
|
||||||
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
|
border-radius: var(--radius-toolbar);
|
||||||
|
box-shadow: var(--shadow-toolbar);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-block-handle) {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-block-handle .operation-item:first-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-block-handle .operation-item:last-child) {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-block-handle .operation-item:last-child svg) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-block-handle .operation-item:last-child:hover),
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-block-handle .operation-item:last-child:focus-visible) {
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
background: hsl(var(--nav-item-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-block-handle .operation-item:last-child:focus-visible) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 72%);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu) {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: min(320px, calc(100vw - var(--space-8)));
|
||||||
|
background: hsl(var(--surface-elevated));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group) {
|
||||||
|
padding: var(--space-2) var(--space-2) 0;
|
||||||
|
border-bottom-color: hsl(var(--line-subtle));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group ul) {
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group li) {
|
||||||
|
padding: 6px var(--space-2);
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group li:hover) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--nav-item-hover));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-slash-menu .tab-group li.selected) {
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
background: hsl(var(--nav-item-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-slash-menu .menu-groups .menu-group h6) {
|
||||||
|
padding: 10px var(--space-2);
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-groups) {
|
||||||
|
max-height: min(216px, 28vh);
|
||||||
|
padding: 0 var(--space-2) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-group li) {
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 248px;
|
||||||
|
padding: 10px var(--space-2);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-group li:hover),
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-slash-menu .menu-group li.hover) {
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
background: hsl(var(--nav-item-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .menu-group li svg) {
|
||||||
|
color: hsl(var(--text-muted));
|
||||||
|
fill: hsl(var(--text-muted));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-slash-menu .menu-group li:hover svg),
|
||||||
|
.easyflow-markdown-live-editor
|
||||||
|
:deep(.milkdown-slash-menu .menu-group li.hover svg) {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
fill: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown button),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown input) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown button:hover),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown button:active) {
|
||||||
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
|
background: hsl(var(--nav-item-active));
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown button:focus-visible),
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown input:focus-visible) {
|
||||||
|
outline: 2px solid hsl(var(--primary) / 72%);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.easyflow-markdown-live-editor :deep(.milkdown button:disabled) {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
@supports not (color: color-mix(in srgb, red, blue)) {
|
@supports not (color: color-mix(in srgb, red, blue)) {
|
||||||
.easyflow-markdown-live-editor :deep(.ProseMirror code),
|
.easyflow-markdown-live-editor :deep(.ProseMirror code),
|
||||||
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
|
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
|
||||||
@@ -599,7 +1034,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
:deep(.milkdown-image-block .image-edit .link-importer .placeholder),
|
:deep(.milkdown-image-block .image-edit .link-importer .placeholder),
|
||||||
.easyflow-markdown-live-editor
|
.easyflow-markdown-live-editor
|
||||||
:deep(.milkdown-image-inline .link-importer .placeholder) {
|
:deep(.milkdown-image-inline .link-importer .placeholder) {
|
||||||
color: var(--crepe-color-on-background);
|
color: hsl(var(--text-muted));
|
||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor
|
.easyflow-markdown-live-editor
|
||||||
@@ -610,7 +1045,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
|
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
|
||||||
background-color: var(--crepe-color-outline);
|
background-color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,4 +64,38 @@ describe('live markdown normalization', () => {
|
|||||||
const source = '# Title\n\n<br />\n\nText\n';
|
const source = '# Title\n\n<br />\n\nText\n';
|
||||||
expect(normalizeLiveMarkdownUpdate(source, source)).toBe(source);
|
expect(normalizeLiveMarkdownUpdate(source, source)).toBe(source);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('moves body input after immutable leading frontmatter', () => {
|
||||||
|
const source = [
|
||||||
|
'---',
|
||||||
|
'name: review',
|
||||||
|
'description: Review contracts',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Instructions',
|
||||||
|
].join('\n');
|
||||||
|
const editorMarkdown = [
|
||||||
|
'New paragraph',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
'name: review',
|
||||||
|
'description: Review contracts',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'# Instructions',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(normalizeLiveMarkdownUpdate(source, editorMarkdown)).toBe(
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'name: review',
|
||||||
|
'description: Review contracts',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'New paragraph',
|
||||||
|
'',
|
||||||
|
'# Instructions',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { unified } from 'unified';
|
|||||||
|
|
||||||
const markdownParser = unified().use(remarkParse).use(remarkGfm);
|
const markdownParser = unified().use(remarkParse).use(remarkGfm);
|
||||||
const STANDALONE_BREAK_LINE = /^[\t ]*<br\s*\/?>[\t ]*$/im;
|
const STANDALONE_BREAK_LINE = /^[\t ]*<br\s*\/?>[\t ]*$/im;
|
||||||
|
const LEADING_FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares Markdown by parsed GFM structure so harmless marker formatting is
|
* Compares Markdown by parsed GFM structure so harmless marker formatting is
|
||||||
@@ -25,11 +26,40 @@ export function hasEquivalentMarkdownSemantics(
|
|||||||
* changing an explicit HTML break that already exists in the source.
|
* changing an explicit HTML break that already exists in the source.
|
||||||
*/
|
*/
|
||||||
export function normalizeLiveMarkdownUpdate(source: string, markdown: string) {
|
export function normalizeLiveMarkdownUpdate(source: string, markdown: string) {
|
||||||
if (STANDALONE_BREAK_LINE.test(source)) return markdown;
|
const orderedMarkdown = keepFrontmatterFirst(source, markdown);
|
||||||
return markdown
|
if (STANDALONE_BREAK_LINE.test(source)) return orderedMarkdown;
|
||||||
.replace(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
|
return orderedMarkdown
|
||||||
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '\n\n')
|
.replaceAll(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
|
||||||
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*$/gi, '\n');
|
.replaceAll(/\n{2,}[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '\n\n')
|
||||||
|
.replaceAll(/\n{2,}[\t ]*<br\s*\/?>[\t ]*$/gi, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps immutable YAML frontmatter at the start when an editor selection is
|
||||||
|
* placed before its atom node. The typed body is preserved immediately after
|
||||||
|
* the metadata block, so the saved file remains valid standard Markdown.
|
||||||
|
*/
|
||||||
|
function keepFrontmatterFirst(source: string, markdown: string) {
|
||||||
|
const sourceMatch = LEADING_FRONTMATTER.exec(source);
|
||||||
|
if (!sourceMatch) return markdown;
|
||||||
|
|
||||||
|
const normalizedMarkdown = markdown
|
||||||
|
.replaceAll('\r\n', '\n')
|
||||||
|
.replaceAll('\r', '\n');
|
||||||
|
const frontmatter = `---\n${(sourceMatch[1] || '')
|
||||||
|
.replaceAll('\r\n', '\n')
|
||||||
|
.replaceAll('\r', '\n')}\n---`;
|
||||||
|
const frontmatterIndex = normalizedMarkdown.indexOf(frontmatter);
|
||||||
|
if (frontmatterIndex <= 0) return normalizedMarkdown;
|
||||||
|
|
||||||
|
const leadingBody = normalizedMarkdown.slice(0, frontmatterIndex).trim();
|
||||||
|
const trailingBody = normalizedMarkdown
|
||||||
|
.slice(frontmatterIndex + frontmatter.length)
|
||||||
|
.replace(/^\n+/, '');
|
||||||
|
const body = [leadingBody, trailingBody]
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.join('\n\n');
|
||||||
|
return body ? `${frontmatter}\n\n${body}` : `${frontmatter}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function semanticTree(markdown: string) {
|
function semanticTree(markdown: string) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const remarkFrontmatterMilkdown = $remark(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
|
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
|
||||||
* 序列化时再写回 mdast `yaml` 节点。这样 SKILL.md 的 frontmatter 在实时
|
* 序列化时再写回 mdast `yaml` 节点。这样 Markdown 文档的 frontmatter 在实时
|
||||||
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
|
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
|
||||||
*/
|
*/
|
||||||
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
|
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
|
||||||
@@ -91,9 +91,9 @@ export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
|
|||||||
role: 'button',
|
role: 'button',
|
||||||
tabindex: '0',
|
tabindex: '0',
|
||||||
title: '点击进入源码模式编辑',
|
title: '点击进入源码模式编辑',
|
||||||
'aria-label': 'Skill 元数据,点击进入源码模式编辑',
|
'aria-label': 'YAML 元数据,点击进入源码模式编辑',
|
||||||
},
|
},
|
||||||
['div', { class: 'milkdown-frontmatter__title' }, 'Skill 元数据'],
|
['div', { class: 'milkdown-frontmatter__title' }, 'YAML 元数据'],
|
||||||
['pre', { class: 'milkdown-frontmatter__code' }, node.attrs.value],
|
['pre', { class: 'milkdown-frontmatter__code' }, node.attrs.value],
|
||||||
],
|
],
|
||||||
parseMarkdown: {
|
parseMarkdown: {
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
export type EditorLanguage =
|
export type EditorLanguage =
|
||||||
|
| 'css'
|
||||||
|
| 'html'
|
||||||
|
| 'java'
|
||||||
| 'javascript'
|
| 'javascript'
|
||||||
| 'json'
|
| 'json'
|
||||||
| 'markdown'
|
| 'markdown'
|
||||||
| 'python'
|
| 'python'
|
||||||
| 'shell'
|
| 'shell'
|
||||||
| 'text';
|
| 'sql'
|
||||||
|
| 'text'
|
||||||
|
| 'typescript'
|
||||||
|
| 'xml'
|
||||||
|
| 'yaml';
|
||||||
|
|
||||||
export interface EditorCursorPosition {
|
export interface EditorCursorPosition {
|
||||||
column: number;
|
column: number;
|
||||||
|
|||||||
@@ -164,8 +164,7 @@ onBeforeUnmount(() =>
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: hsl(var(--surface-panel));
|
background: hsl(var(--surface-panel));
|
||||||
border: 1px solid hsl(var(--line-subtle));
|
border: 1px solid hsl(var(--line-subtle));
|
||||||
border-radius: var(--radius-panel);
|
border-radius: var(--radius-toolbar);
|
||||||
box-shadow: var(--shadow-subtle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-editor-workbench__tree {
|
.document-editor-workbench__tree {
|
||||||
@@ -177,6 +176,7 @@ onBeforeUnmount(() =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
.document-editor-workbench__main {
|
.document-editor-workbench__main {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { computed, ref } from 'vue';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Ellipsis,
|
||||||
File,
|
File,
|
||||||
FileCode2,
|
FileCode2,
|
||||||
FileImage,
|
FileImage,
|
||||||
FileText,
|
FileText,
|
||||||
FolderClosed,
|
FolderClosed,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Ellipsis,
|
|
||||||
Plus,
|
Plus,
|
||||||
} from '@easyflow/icons';
|
} from '@easyflow/icons';
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ function activate() {
|
|||||||
emit('focusPath', props.node.path);
|
emit('focusPath', props.node.path);
|
||||||
if (isDirectory.value) {
|
if (isDirectory.value) {
|
||||||
expanded.value = !expanded.value;
|
expanded.value = !expanded.value;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
emit('select', props.node);
|
emit('select', props.node);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ describe('file tree panel keyboard navigation', () => {
|
|||||||
expectFocused('references/nested/deep.md');
|
expectFocused('references/nested/deep.md');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects files and directories with Enter or Space', async () => {
|
it('selects files while directory activation only toggles expansion', async () => {
|
||||||
const wrapper = mountTree();
|
const wrapper = mountTree();
|
||||||
|
|
||||||
treeItem('references/guide.md')?.dispatchEvent(
|
treeItem('references/guide.md')?.dispatchEvent(
|
||||||
@@ -153,10 +153,7 @@ describe('file tree panel keyboard navigation', () => {
|
|||||||
);
|
);
|
||||||
await nextTick();
|
await nextTick();
|
||||||
expect(treeItem('references')?.getAttribute('aria-expanded')).toBe('false');
|
expect(treeItem('references')?.getAttribute('aria-expanded')).toBe('false');
|
||||||
expect(wrapper.emitted('select')).toHaveLength(2);
|
expect(wrapper.emitted('select')).toHaveLength(1);
|
||||||
expect(wrapper.emitted('select')?.[1]?.[0]).toMatchObject({
|
|
||||||
path: 'references',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits contextual create and file operations from row actions', async () => {
|
it('emits contextual create and file operations from row actions', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user