发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
38 changed files with 2354 additions and 5810 deletions
Showing only changes of commit 5de3b209c1 - Show all commits

View File

@@ -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('已保存');
});
});

View File

@@ -1,106 +1,21 @@
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import dialogSource from './SkillCreateDialog.vue?raw';
import SkillCreateDialog from './SkillCreateDialog.vue';
const apiMocks = vi.hoisted(() => ({
saveSkill: vi.fn(),
}));
vi.mock('./api', () => apiMocks);
vi.mock('@easyflow/common-ui', async () => {
const { defineComponent, h } = await import('vue');
return {
EasyFlowFormModal: defineComponent({
props: {
open: Boolean,
submitting: Boolean,
title: { default: '', type: String },
},
emits: ['confirm', 'update:open'],
setup(props, { emit, slots }) {
return () =>
props.open
? h('section', { class: 'form-modal-stub' }, [
h('h2', props.title),
slots.default?.(),
h(
'button',
{
class: 'form-modal-confirm',
disabled: props.submitting,
onClick: () => emit('confirm'),
type: 'button',
},
'创建并进入详情',
),
])
: null;
},
}),
};
});
describe('skill create dialog', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMocks.saveSkill.mockResolvedValue({
data: {
displayName: '研究助手',
id: 101,
publishStatus: 'DRAFT',
},
errorCode: 0,
});
describe('skill create and import contract', () => {
it('offers manual authoring and independent standard ZIP batch import', () => {
expect(dialogSource).toContain('value="manual">自己编写');
expect(dialogSource).toContain('value="import">批量导入');
expect(dialogSource).toContain('accept=".zip,application/zip"');
expect(dialogSource).toContain('multiple');
expect(dialogSource).toContain('单次最多导入 20 个技能');
expect(dialogSource).toContain('importSkillConfirmBatch');
expect(dialogSource).not.toContain('.efskill');
});
it('creates a valid draft from the compact business form', async () => {
const wrapper = mount(SkillCreateDialog, {
props: {
canPublish: true,
categories: [{ categoryName: '研发', id: 8, status: 1 }],
defaultCategoryId: 8,
modelValue: true,
},
});
await flushPromises();
expect(wrapper.get('h2').text()).toBe('新建 Skill');
await wrapper.get('input').setValue('研究助手');
const radioGroup = wrapper.getComponent({ name: 'ElRadioGroup' });
radioGroup.vm.$emit('update:modelValue', 'PUBLISH');
await wrapper.get('.form-modal-confirm').trigger('click');
await flushPromises();
expect(apiMocks.saveSkill).toHaveBeenCalledWith(
expect.objectContaining({
categoryId: 8,
displayName: '研究助手',
enabled: true,
publishStatus: 'DRAFT',
visibilityScope: 'PRIVATE',
}),
);
expect(apiMocks.saveSkill.mock.calls[0]?.[0].skillContent).toContain(
'# Instructions',
);
expect(wrapper.emitted('created')?.[0]?.[0]).toMatchObject({
intent: 'PUBLISH',
skill: { id: 101 },
});
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]);
});
it('keeps publish intent unavailable without publish permission', async () => {
const wrapper = mount(SkillCreateDialog, {
props: { categories: [], modelValue: true },
});
await flushPromises();
const publishOption = wrapper
.findAllComponents({ name: 'ElRadioButton' })
.find((option) => option.text().includes('创建后发布'));
expect(publishOption?.props('disabled')).toBe(true);
it('requires one shared category and visibility scope for the operation', () => {
expect(dialogSource).toContain('v-model="form.categoryId"');
expect(dialogSource).toContain('v-model="form.visibilityScope"');
expect(dialogSource).toContain('visibilityScope: form.visibilityScope');
});
});

View File

@@ -1,65 +1,92 @@
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus';
import type { SkillCreateIntent } from './skill-create';
import type { SkillCategory, SkillInfo } from './types';
import type {
SkillCategory,
SkillImportPreview,
SkillInfo,
SkillVisibilityScope,
} from './types';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
import { Document, UploadFilled } from '@element-plus/icons-vue';
import {
ElAlert,
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElOption,
ElRadioButton,
ElRadioGroup,
ElSelect,
ElTag,
} from 'element-plus';
import { saveSkill } from './api';
import {
cancelSkillImport,
importSkillConfirmBatch,
importSkillPreviews,
saveSkill,
} from './api';
import { resolveSkillApiErrorMessage } from './skill-api-error';
import { flattenSkillCategories } from './skill-category';
import { buildInitialSkillDraft } from './skill-create';
type CreateMode = 'import' | 'manual';
type ConflictStrategy = 'OVERWRITE' | 'REJECT' | 'RENAME';
interface ImportRow {
file: File;
message?: string;
preview?: SkillImportPreview;
rename: string;
result?: 'failed' | 'success';
strategy: ConflictStrategy;
}
const props = withDefaults(
defineProps<{
canPublish?: boolean;
categories: SkillCategory[];
categoriesLoading?: boolean;
defaultCategoryId?: number | string;
defaultMode?: CreateMode;
modelValue: boolean;
}>(),
{
canPublish: false,
categoriesLoading: false,
defaultCategoryId: '',
defaultMode: 'manual',
},
);
const emit = defineEmits<{
created: [payload: { intent: SkillCreateIntent; skill: SkillInfo }];
created: [skill: SkillInfo];
imported: [count: number];
'update:modelValue': [value: boolean];
}>();
const formRef = ref<FormInstance>();
const nameInputRef = ref<InstanceType<typeof ElInput>>();
const saving = ref(false);
const fileInputRef = ref<HTMLInputElement>();
const mode = ref<CreateMode>('manual');
const busy = ref(false);
const actionError = ref('');
const importRows = ref<ImportRow[]>([]);
const importFinished = ref(false);
const form = reactive({
categoryId: '' as number | string,
displayName: '',
intent: 'DRAFT' as SkillCreateIntent,
visibilityScope: 'PRIVATE',
visibilityScope: 'PRIVATE' as SkillVisibilityScope,
});
const dialogVisible = computed({
const visible = computed({
get: () => props.modelValue,
set: (value) => {
if (!value && saving.value) {
if (!value && busy.value) {
ElMessage.info('请等待当前操作完成');
return;
}
@@ -71,106 +98,245 @@ const categoryOptions = computed(() =>
);
const rules: FormRules = {
displayName: [
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
{
max: 128,
message: 'Skill 名称不能超过 128 个字符',
trigger: 'blur',
},
{ message: '请输入技能名称', required: true, trigger: 'blur' },
{ max: 128, message: '技能名称不能超过 128 个字符', trigger: 'blur' },
],
};
function resetForm() {
Object.assign(form, {
categoryId: props.defaultCategoryId || '',
displayName: '',
intent: 'DRAFT',
visibilityScope: 'PRIVATE',
});
actionError.value = '';
formRef.value?.clearValidate();
void nextTick(() => nameInputRef.value?.focus());
}
async function submit() {
if (saving.value || props.categoriesLoading) return;
form.displayName = form.displayName.trim();
if (!(await formRef.value?.validate().catch(() => false))) return;
saving.value = true;
actionError.value = '';
try {
const response = await saveSkill(
buildInitialSkillDraft({
categoryId: form.categoryId || undefined,
displayName: form.displayName,
visibilityScope: form.visibilityScope,
const confirmText = computed(() =>
mode.value === 'manual' ? '创建并进入编辑' : '导入',
);
const importReady = computed(
() =>
importRows.value.length > 0 &&
importRows.value.every((row) => {
const item = row.preview?.skills[0];
if (!row.preview?.importToken || !item || importErrorCount(row.preview))
return false;
if (!item.conflict) return true;
if (row.strategy === 'OVERWRITE') return item.overwriteAllowed === true;
if (row.strategy === 'RENAME') return canonicalName(row.rename);
return false;
}),
);
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(
() => props.modelValue,
(visible) => {
if (visible) resetForm();
(open) => {
if (!open) return;
mode.value = props.defaultMode;
Object.assign(form, {
categoryId: props.defaultCategoryId || '',
displayName: '',
visibilityScope: 'PRIVATE',
});
importRows.value = [];
importFinished.value = false;
actionError.value = '';
formRef.value?.clearValidate();
if (mode.value === 'manual')
void nextTick(() => nameInputRef.value?.focus());
},
{ immediate: true },
);
async function submit() {
if (busy.value) return;
await (mode.value === 'manual' ? createManualSkill() : confirmImports());
}
async function createManualSkill() {
form.displayName = form.displayName.trim();
if (!(await formRef.value?.validate().catch(() => false))) return;
busy.value = true;
actionError.value = '';
try {
const response = await saveSkill(buildInitialSkillDraft(form));
if (response.errorCode !== 0 || !response.data?.id) {
actionError.value = response.message || '技能创建失败,请重试';
return;
}
emit('update:modelValue', false);
emit('created', response.data);
} catch (error) {
actionError.value = resolveSkillApiErrorMessage(
error,
'技能创建失败,请重试',
);
} finally {
busy.value = false;
}
}
function chooseFiles() {
if (!busy.value) fileInputRef.value?.click();
}
async function handleFiles(event: Event) {
const input = event.target as HTMLInputElement;
const files = [...(input.files || [])];
input.value = '';
await previewFiles(files);
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
await previewFiles([...(event.dataTransfer?.files || [])]);
}
async function previewFiles(files: File[]) {
if (busy.value || files.length === 0) return;
if (files.length > 20) {
ElMessage.warning('单次最多导入 20 个技能');
return;
}
if (files.some((file) => !file.name.toLowerCase().endsWith('.zip'))) {
ElMessage.warning('仅支持标准 Skill ZIP');
return;
}
await cancelPendingImports();
busy.value = true;
actionError.value = '';
try {
const response = await importSkillPreviews(files);
if (response.errorCode !== 0) {
actionError.value = response.message || '导入预检失败';
return;
}
importRows.value = files.map((file, index) => {
const preview = response.data[index];
const item = preview?.skills[0];
return {
file,
preview,
rename: item ? `${item.name}-copy` : '',
strategy: item?.conflict ? 'RENAME' : 'REJECT',
};
});
} catch (error) {
actionError.value = resolveSkillApiErrorMessage(
error,
'导入预检失败,请重试',
);
} finally {
busy.value = false;
}
}
async function confirmImports() {
if (!importReady.value) {
ElMessage.warning('请先处理导入错误和名称冲突');
return;
}
busy.value = true;
actionError.value = '';
try {
const response = await importSkillConfirmBatch(
importRows.value.map((row) => {
const item = row.preview!.skills[0]!;
return {
categoryId: form.categoryId || undefined,
conflictStrategy: row.strategy,
importToken: row.preview!.importToken!,
renames:
row.strategy === 'RENAME'
? { [item.packageRoot || item.packageId]: row.rename.trim() }
: undefined,
visibilityScope: form.visibilityScope,
};
}),
);
if (response.errorCode !== 0) {
actionError.value = response.message || '导入失败';
return;
}
let succeeded = 0;
response.data.forEach((result, index) => {
const row = importRows.value[index];
if (!row) return;
row.result = result.success ? 'success' : 'failed';
row.message = result.message;
if (result.success) succeeded += result.skills.length;
});
importFinished.value = true;
emit('imported', succeeded);
if (succeeded === importRows.value.length) {
ElMessage.success(`已导入 ${succeeded} 个技能`);
emit('update:modelValue', false);
}
} catch (error) {
actionError.value = resolveSkillApiErrorMessage(error, '导入失败,请重试');
} finally {
busy.value = false;
}
}
async function requestClose() {
if (busy.value) return;
await cancelPendingImports();
emit('update:modelValue', false);
}
async function cancelPendingImports() {
if (importFinished.value) return;
const tokens = importRows.value
.map((row) => row.preview?.importToken)
.filter((token): token is string => typeof token === 'string');
await Promise.all(
tokens.map((token) => cancelSkillImport(token).catch(() => undefined)),
);
}
function importErrorCount(preview?: SkillImportPreview) {
return (preview?.issues || []).filter((issue) => issue.severity === 'ERROR')
.length;
}
function canonicalName(value: string) {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim());
}
function scopeLabel(scope: SkillVisibilityScope) {
return { DEPT: '部门', PRIVATE: '个人', PUBLIC: '全部' }[scope];
}
</script>
<template>
<EasyFlowFormModal
v-model:open="dialogVisible"
width="520px"
:closable="!saving"
:confirm-loading="saving"
confirm-text="创建并进入详情"
:submitting="saving || categoriesLoading"
title="新建 Skill"
@confirm="submit"
<ElDialog
v-model="visible"
class="skill-create-dialog"
width="min(720px, calc(100vw - 32px))"
:close-on-click-modal="false"
:close-on-press-escape="!busy"
:show-close="!busy"
title="新建技能"
@closed="cancelPendingImports"
>
<ElRadioGroup v-model="mode" class="skill-create-dialog__mode">
<ElRadioButton value="manual">自己编写</ElRadioButton>
<ElRadioButton value="import">批量导入</ElRadioButton>
</ElRadioGroup>
<p v-if="actionError" class="skill-create-dialog__error" role="alert">
{{ actionError }}
</p>
<ElForm
ref="formRef"
class="skill-create-form easyflow-modal-form easyflow-modal-form--compact"
class="skill-create-dialog__form"
label-position="top"
:model="form"
:rules="rules"
>
<ElAlert
v-if="actionError"
class="skill-create-form__alert"
:closable="false"
:title="actionError"
type="error"
show-icon
/>
<ElFormItem label="Skill 名称" prop="displayName">
<ElFormItem v-if="mode === 'manual'" label="技能名称" prop="displayName">
<ElInput
ref="nameInputRef"
v-model="form.displayName"
maxlength="128"
placeholder="请输入 Skill 名称"
show-word-limit
placeholder="请输入技能名称"
@keyup.enter="submit"
/>
</ElFormItem>
<div class="skill-create-dialog__fields">
<ElFormItem label="分类">
<ElSelect
v-model="form.categoryId"
@@ -181,55 +347,232 @@ watch(
<ElOption
v-for="category in categoryOptions"
:key="category.id"
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
:label="`${'\u3000'.repeat(category.depth)}${category.categoryName}`"
:value="category.id"
:disabled="category.status !== 1"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="可见范围">
<ElFormItem label="范围">
<ElSelect v-model="form.visibilityScope">
<ElOption label="仅自己" value="PRIVATE" />
<ElOption label="本部门" value="DEPT" />
<ElOption label="全员可见" value="PUBLIC" />
<ElOption
v-for="scope in [
'PRIVATE',
'DEPT',
'PUBLIC',
] as SkillVisibilityScope[]"
:key="scope"
:label="scopeLabel(scope)"
:value="scope"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="发布状态">
<div class="skill-create-form__publish-field">
<ElRadioGroup v-model="form.intent">
<ElRadioButton value="DRAFT">草稿</ElRadioButton>
<ElRadioButton value="PUBLISH" :disabled="!canPublish">
创建后发布
</ElRadioButton>
</ElRadioGroup>
<span v-if="form.intent === 'PUBLISH'">
进入详情完善内容通过校验后提交发布
</span>
</div>
</ElFormItem>
</ElForm>
</EasyFlowFormModal>
<template v-if="mode === 'import'">
<input
ref="fileInputRef"
class="skill-create-dialog__file-input"
type="file"
accept=".zip,application/zip"
multiple
@change="handleFiles"
/>
<button
type="button"
class="skill-create-dialog__dropzone"
:disabled="busy"
@click="chooseFiles"
@dragover.prevent
@drop="handleDrop"
>
<ElIcon><UploadFilled /></ElIcon>
<span>选择或拖入标准 Skill ZIP</span>
<small>最多 20 个,每个 ZIP 包含一个技能</small>
</button>
<div v-if="importRows.length > 0" class="skill-create-dialog__imports">
<article v-for="row in importRows" :key="row.file.name">
<ElIcon><Document /></ElIcon>
<div>
<strong>{{ row.preview?.skills[0]?.name || row.file.name }}</strong>
<small v-if="importErrorCount(row.preview)">
{{ row.preview?.issues?.[0]?.message || '包内容不符合规范' }}
</small>
<small v-else-if="row.result === 'failed'">{{ row.message }}</small>
<small v-else>{{ row.file.name }}</small>
</div>
<ElTag v-if="row.result === 'success'" type="success" effect="plain">
成功
</ElTag>
<ElTag
v-else-if="row.result === 'failed'"
type="danger"
effect="plain"
>
失败
</ElTag>
<ElTag
v-else-if="importErrorCount(row.preview)"
type="danger"
effect="plain"
>
错误
</ElTag>
<template v-else-if="row.preview?.skills[0]?.conflict">
<ElSelect
v-model="row.strategy"
class="skill-create-dialog__strategy"
>
<ElOption label="重命名" value="RENAME" />
<ElOption
label="覆盖草稿"
value="OVERWRITE"
:disabled="!row.preview.skills[0].overwriteAllowed"
/>
</ElSelect>
<ElInput
v-if="row.strategy === 'RENAME'"
v-model="row.rename"
class="skill-create-dialog__rename"
placeholder="新标准名称"
/>
</template>
<ElTag v-else type="success" effect="plain">可导入</ElTag>
</article>
</div>
</template>
<template #footer>
<ElButton :disabled="busy" @click="requestClose">取消</ElButton>
<ElButton
type="primary"
:loading="busy"
:disabled="mode === 'import' && !importReady"
@click="submit"
>
{{ confirmText }}
</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.skill-create-form__alert {
margin-bottom: var(--space-4);
.skill-create-dialog__mode {
width: 100%;
margin-bottom: var(--space-5);
}
.skill-create-form :deep(.el-select) {
.skill-create-dialog__mode :deep(.el-radio-button) {
flex: 1;
}
.skill-create-dialog__mode :deep(.el-radio-button__inner) {
width: 100%;
}
.skill-create-form__publish-field {
display: grid;
gap: var(--space-2);
.skill-create-dialog__error {
padding: var(--space-3);
margin: 0 0 var(--space-4);
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 8%);
border-radius: var(--radius-control);
}
.skill-create-form__publish-field > span {
font-size: 12px;
line-height: 1.5;
.skill-create-dialog__fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
.skill-create-dialog__form :deep(.el-select) {
width: 100%;
}
.skill-create-dialog__file-input {
display: none;
}
.skill-create-dialog__dropzone {
display: grid;
gap: var(--space-2);
place-items: center;
width: 100%;
min-height: 136px;
color: hsl(var(--text-muted));
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 1px dashed hsl(var(--line-strong));
border-radius: var(--radius-panel);
transition: border-color var(--motion-duration-fast)
var(--motion-ease-standard);
}
.skill-create-dialog__dropzone:hover,
.skill-create-dialog__dropzone:focus-visible {
border-color: hsl(var(--primary));
outline: none;
}
.skill-create-dialog__dropzone .el-icon {
font-size: 28px;
}
.skill-create-dialog__dropzone span {
color: hsl(var(--foreground));
font-weight: 600;
}
.skill-create-dialog__imports {
display: grid;
gap: var(--space-2);
max-height: 264px;
margin-top: var(--space-4);
overflow: auto;
}
.skill-create-dialog__imports article {
display: flex;
gap: var(--space-3);
align-items: center;
min-height: 56px;
padding: var(--space-3);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.skill-create-dialog__imports article > div {
display: grid;
flex: 1;
min-width: 0;
}
.skill-create-dialog__imports strong,
.skill-create-dialog__imports small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-create-dialog__imports small {
color: hsl(var(--text-muted));
}
.skill-create-dialog__strategy {
width: 112px;
}
.skill-create-dialog__rename {
width: 176px;
}
@media (max-width: 640px) {
.skill-create-dialog__fields {
grid-template-columns: 1fr;
gap: 0;
}
.skill-create-dialog__imports article {
flex-wrap: wrap;
}
}
</style>

View File

@@ -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();
});
});

View File

@@ -1,449 +1,40 @@
import type { Router } from 'vue-router';
import { describe, expect, it } from 'vitest';
/* eslint-disable vue/one-component-per-file -- Inline stubs keep this publish-flow test isolated. */
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import detailSource from './SkillDetail.vue?raw';
import { ElMessage, ElMessageBox } from 'element-plus';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import SkillDetail from './SkillDetail.vue';
const apiMocks = vi.hoisted(() => ({
getSkillCategories: vi.fn(),
getSkillDetail: vi.fn(),
saveSkill: vi.fn(),
submitSkillDeleteApproval: vi.fn(),
submitSkillOfflineApproval: vi.fn(),
submitSkillPublishApproval: vi.fn(),
updateSkill: vi.fn(),
}));
const childMocks = vi.hoisted(() => ({
capabilityHasDirty: vi.fn(() => false),
capabilitySave: vi.fn(),
resourceSave: vi.fn(async () => true),
resourceValidate: vi.fn(),
}));
const permissionMocks = vi.hoisted(() => ({
hasPermission: vi.fn((_permissions: string[]) => true),
}));
vi.mock('vue-router', async (importOriginal) => {
const original = await importOriginal<typeof import('vue-router')>();
return {
...original,
onBeforeRouteLeave: vi.fn(),
onBeforeRouteUpdate: vi.fn(),
};
});
vi.mock('./api', () => apiMocks);
vi.mock('#/api/common/hasPermission', () => ({
hasPermission: permissionMocks.hasPermission,
}));
vi.mock('./SkillResourceWorkbench.vue', async () => {
const { defineComponent, h } = await import('vue');
return {
default: defineComponent({
name: 'SkillResourceWorkbench',
props: {
activePanel: { default: 'resources', type: String },
capabilityAvailable: Boolean,
readonly: Boolean,
},
emits: [
'dirty',
'issues',
'locateCapability',
'newContent',
'requestSave',
'update:activePanel',
],
setup(props, { expose, slots }) {
expose({
getSkillContent: () => undefined,
saveAll: childMocks.resourceSave,
validateAll: childMocks.resourceValidate,
});
return () =>
h(
'div',
{
'data-readonly': String(props.readonly),
'data-testid': 'resource-workbench',
},
slots.capability?.(),
describe('skill studio contract', () => {
it('uses one unified file workbench and one publish modal', () => {
expect(detailSource).not.toContain('activeTab');
expect(detailSource).not.toContain('instructionsOnly');
expect(detailSource).not.toContain('skill-detail-page__tabs');
expect(detailSource.match(/<SkillResourceWorkbench/g)).toHaveLength(1);
expect(detailSource).toContain('title="发布技能"');
expect(detailSource).toContain('发布说明');
expect(detailSource).toContain('maxlength="500"');
expect(detailSource).toContain(
'submitSkillPublishApproval(skillId.value, reason)',
);
},
}),
};
});
vi.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,
});
expect(detailSource).not.toContain('能力绑定');
expect(detailSource).not.toContain('SkillCapabilityPanel');
});
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;
}),
it('keeps the detail shell compact and uses the neutral content surface', () => {
expect(detailSource).toContain('min-height: 48px');
expect(detailSource).toContain('background: hsl(var(--background))');
expect(detailSource).not.toContain(
'background: hsl(var(--surface-canvas))',
);
vi.spyOn(ElMessageBox, 'confirm').mockImplementation(async () => {
calls.push('confirm');
return { action: 'confirm' } as Awaited<
ReturnType<typeof ElMessageBox.confirm>
>;
});
apiMocks.submitSkillPublishApproval.mockImplementation(
() =>
new Promise((resolve) => {
calls.push('submit');
resolveSubmit = resolve;
}),
it('saves dirty files and validates the package before opening publish', () => {
const saveAt = detailSource.indexOf('await saveFiles(false)');
const validateAt = detailSource.indexOf(
'resourceWorkbenchRef.value?.validateAll()',
);
const wrapper = await mountDetail();
markResourceDirty(wrapper);
await flushPromises();
const dialogAt = detailSource.indexOf('publishDialogOpen.value = true');
await publishButton(wrapper).trigger('click');
await flushPromises();
expect(calls).toEqual(['save', 'validate']);
expect(
wrapper
.get('[data-testid="resource-workbench"]')
.attributes('data-readonly'),
).toBe('true');
expect(
wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'),
).toBe('true');
resolveValidation?.(true);
await flushPromises();
expect(calls).toEqual(['save', 'validate', 'confirm', 'submit']);
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
expect(childMocks.resourceValidate).toHaveBeenCalledWith();
resolveSubmit?.({ data: 9001, errorCode: 0, message: '已提交' });
await flushPromises();
expect(
wrapper
.get('[data-testid="resource-workbench"]')
.attributes('data-readonly'),
).toBe('false');
expect(
wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'),
).toBe('false');
wrapper.unmount();
});
it('does not confirm or submit when publish validation fails', async () => {
childMocks.resourceValidate.mockResolvedValue(false);
const confirm = vi.spyOn(ElMessageBox, 'confirm');
const wrapper = await mountDetail();
await publishButton(wrapper).trigger('click');
await flushPromises();
expect(childMocks.resourceValidate).toHaveBeenCalledWith();
expect(confirm).not.toHaveBeenCalled();
expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled();
wrapper.unmount();
});
it('stops before validation when saving dirty resources fails', async () => {
childMocks.resourceSave.mockResolvedValue(false);
const confirm = vi.spyOn(ElMessageBox, 'confirm');
const wrapper = await mountDetail();
markResourceDirty(wrapper);
await flushPromises();
await publishButton(wrapper).trigger('click');
await flushPromises();
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
expect(childMocks.resourceValidate).not.toHaveBeenCalled();
expect(confirm).not.toHaveBeenCalled();
expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled();
wrapper.unmount();
});
it('uses one resource and capability workspace and opens settings on demand', async () => {
const wrapper = await mountDetail();
const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' });
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0);
expect(workbench.props('activePanel')).toBe('resources');
expect(workbench.props('capabilityAvailable')).toBe(true);
expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe(
true,
);
expect(wrapper.text()).not.toContain('设置');
expect(wrapper.text()).not.toContain('校验');
const lifecycleDropdown = wrapper.findComponent({ name: 'ElDropdown' });
expect(lifecycleDropdown.props('trigger')).toBe('click');
lifecycleDropdown.vm.$emit('command', 'settings');
await flushPromises();
expect(wrapper.find('[data-testid="settings-dialog"]').exists()).toBe(true);
wrapper.unmount();
});
it('keeps the routed display title when an older detail response omits it', async () => {
apiMocks.getSkillDetail.mockResolvedValueOnce({
data: {
capabilityHash: 'a'.repeat(64),
displayName: '',
displayPublishStatus: 'DRAFT',
manageable: true,
name: '',
publishStatus: 'DRAFT',
skillContent:
'---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n',
},
errorCode: 0,
});
const wrapper = await mountDetail('101', '验收 Skill');
expect(wrapper.get('h1').text()).toBe('验收 Skill');
wrapper.unmount();
});
it('keeps one file save action and handles workbench and keyboard save requests', async () => {
const wrapper = await mountDetail();
const saveButtons = wrapper
.findAll('button')
.filter((button) => button.text().trim() === '保存');
expect(saveButtons).toHaveLength(1);
expect(saveButtons[0]?.attributes('disabled')).toBeDefined();
markResourceDirty(wrapper);
await flushPromises();
expect(saveButtons[0]?.attributes('disabled')).toBeUndefined();
wrapper
.getComponent({ name: 'SkillResourceWorkbench' })
.vm.$emit('requestSave');
await flushPromises();
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
markResourceDirty(wrapper);
await flushPromises();
const shortcut = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 's',
metaKey: true,
});
window.dispatchEvent(shortcut);
await flushPromises();
expect(shortcut.defaultPrevented).toBe(true);
expect(childMocks.resourceSave).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
it('redirects the retired direct-new route back to the Skill list', async () => {
const wrapper = await mountDetail('new');
expect(router.currentRoute.value.path).toBe('/ai/skill');
wrapper.unmount();
});
it('keeps validation navigation on resources without capability permission', async () => {
permissionMocks.hasPermission.mockImplementation(
(permissions: string[]) =>
!permissions.includes('/api/v1/skill/capability'),
);
const warning = vi.spyOn(ElMessage, 'warning');
const wrapper = await mountDetail();
const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' });
workbench.vm.$emit('locateCapability');
await flushPromises();
expect(workbench.props('activePanel')).toBe('resources');
expect(workbench.props('capabilityAvailable')).toBe(false);
expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe(
false,
);
expect(warning).toHaveBeenCalledWith('当前没有查看能力绑定的权限');
wrapper.unmount();
});
it('distinguishes a category request failure from an unavailable category and supports retry', async () => {
apiMocks.getSkillCategories.mockRejectedValueOnce(new Error('network'));
apiMocks.getSkillDetail.mockResolvedValueOnce({
data: {
categoryId: 77,
categoryName: '研发工具',
description: 'Demonstration skill',
displayName: '演示 Skill',
displayPublishStatus: 'DRAFT',
enabled: true,
id: 101,
manageable: true,
name: 'demo-skill',
publishStatus: 'DRAFT',
skillContent:
'---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n',
visibilityScope: 'PRIVATE',
},
errorCode: 0,
});
const wrapper = await mountDetail();
wrapper
.findComponent({ name: 'ElDropdown' })
.vm.$emit('command', 'settings');
await flushPromises();
expect(wrapper.get('[data-testid="settings-dialog"]').text()).toContain(
'分类加载失败,请重试',
);
apiMocks.getSkillCategories.mockResolvedValueOnce({
data: [{ categoryName: '研发工具', id: 77, status: 1 }],
errorCode: 0,
});
await wrapper
.get('[data-testid="settings-dialog"] button')
.trigger('click');
await flushPromises();
expect(apiMocks.getSkillCategories).toHaveBeenCalledTimes(2);
expect(wrapper.text()).not.toContain('分类加载失败,请重试');
wrapper.unmount();
expect(saveAt).toBeGreaterThan(0);
expect(validateAt).toBeGreaterThan(saveAt);
expect(dialogAt).toBeGreaterThan(validateAt);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,216 +1,26 @@
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import listSource from './SkillList.vue?raw';
import SkillList from './SkillList.vue';
import skillListSource from './SkillList.vue?raw';
/* eslint-disable vue/one-component-per-file -- Inline stubs keep this layout test isolated. */
const apiMocks = vi.hoisted(() => ({
deleteSkillCategory: vi.fn(),
getSkillCategories: vi.fn(),
}));
const permissionMocks = vi.hoisted(() => ({
hasPermission: vi.fn((_permissions: string[]) => true),
}));
const pageDataMocks = vi.hoisted(() => ({
reload: vi.fn(),
setQuery: vi.fn(),
}));
vi.mock('#/api/common/hasPermission', () => permissionMocks);
vi.mock('#/locales', () => ({
$t: (key: string) => (key === 'common.allCategories' ? '全部' : key),
}));
vi.mock('./api', () => ({
cancelSkillImport: vi.fn(),
copySkill: vi.fn(),
deleteSkillCategory: apiMocks.deleteSkillCategory,
exportSkills: vi.fn(),
getSkillCapabilityCandidates: vi.fn(),
getSkillCategories: apiMocks.getSkillCategories,
importSkillConfirm: vi.fn(),
importSkillPreview: vi.fn(),
submitSkillDeleteApproval: vi.fn(),
submitSkillOfflineApproval: vi.fn(),
submitSkillPublishApproval: vi.fn(),
}));
vi.mock('#/components/page/PageData.vue', async () => {
const { defineComponent, h } = await import('vue');
return {
default: defineComponent({
name: 'PageData',
setup(_, { expose }) {
expose(pageDataMocks);
return () => h('div', { class: 'page-data-container' });
},
}),
};
});
vi.mock('./SkillCategoryFormDialog.vue', async () => {
const { defineComponent, h } = await import('vue');
return {
default: defineComponent({
name: 'SkillCategoryFormDialog',
setup: () => () => h('div'),
}),
};
});
vi.mock('./SkillCreateDialog.vue', async () => {
const { defineComponent, h } = await import('vue');
return {
default: defineComponent({
name: 'SkillCreateDialog',
props: { modelValue: Boolean },
emits: ['created', 'update:modelValue'],
setup(props) {
return () =>
h('div', {
'data-open': String(props.modelValue),
'data-testid': 'skill-create-dialog',
});
},
}),
};
});
async function mountSkillList() {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ component: { template: '<div />' }, path: '/ai/skill' },
{
component: { template: '<div />' },
path: '/ai/skill/detail/:id?',
},
],
});
await router.push('/ai/skill');
await router.isReady();
const wrapper = mount(SkillList, {
global: {
directives: { access: {}, loading: {} },
plugins: [router],
stubs: { ElDialog: true },
},
});
await flushPromises();
return wrapper;
}
describe('skill list layout', () => {
beforeEach(() => {
vi.clearAllMocks();
permissionMocks.hasPermission.mockReturnValue(true);
apiMocks.getSkillCategories.mockResolvedValue({
data: [
{
categoryName: '研发',
id: 1,
status: 1,
children: [
{
categoryName: '平台',
id: 2,
parentId: 1,
status: 1,
},
],
},
],
errorCode: 0,
});
describe('skill list contract', () => {
it('uses the shared list layout with classification, fuzzy search, and compact filters', () => {
expect(listSource).toContain('<PageSide');
expect(listSource).toContain('<HeaderSearch');
expect(listSource).toContain('搜索技能名称、用途或创建人');
expect(listSource).toContain('placeholder="范围:全部"');
expect(listSource).toContain('placeholder="状态:全部"');
expect(listSource).toContain('placeholder="分类:全部"');
expect(listSource).toContain('skill-list-page__mobile-category');
expect(listSource).toContain("text: '批量导入'");
expect(listSource).toContain("text: '新建技能'");
});
it('places the toolbar above a full-height category and list workspace', async () => {
const wrapper = await mountSkillList();
const header = wrapper.get('.skill-list-page__header');
const toolbar = header.get('.custom-header');
expect(toolbar.text()).toContain('导入');
expect(toolbar.text()).toContain('导出');
expect(toolbar.text()).toContain('新建 Skill');
expect(toolbar.get('.search-group').findAll('button')).toHaveLength(2);
expect(toolbar.find('input').attributes()).toMatchObject({
placeholder: '请输入 Skill 名称或描述',
});
const workspace = wrapper.get('.skill-list-page__workspace');
expect(workspace.find('.skill-list-page__header').exists()).toBe(false);
expect(workspace.element.children[0]?.classList).toContain('page-side');
expect(workspace.element.children[1]?.classList).toContain(
'skill-list-page__content',
);
expect(workspace.get('.skill-list-page__content').classes()).toContain(
'skill-list-page__content',
);
const sidebar = workspace.get('#skill-category-sidebar');
expect(sidebar.text()).toContain('全部');
expect(sidebar.text()).toContain('研发');
expect(sidebar.text()).toContain('平台');
expect(sidebar.text()).not.toContain('未分类');
expect(sidebar.get('.page-side__footer').text()).toContain('添加');
const developmentNode = sidebar
.findAll('.el-tree-node__content')
.find((node) => node.text().includes('研发'));
expect(developmentNode).toBeTruthy();
await developmentNode?.trigger('click');
expect(pageDataMocks.setQuery).toHaveBeenLastCalledWith({
categoryId: 1,
displayName: undefined,
});
wrapper.unmount();
});
it('keeps the complete flex height chain for the list area', () => {
expect(skillListSource).toMatch(
/\.skill-list-page\s*\{[^}]*display:\s*flex;[^}]*height:\s*100%;[^}]*min-height:\s*0;/,
);
expect(skillListSource).toMatch(
/\.skill-list-page__workspace\s*\{[^}]*display:\s*grid;[^}]*flex:\s*1;[^}]*min-height:\s*0;/,
);
expect(skillListSource).toMatch(
/\.skill-list-page__content\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1;[^}]*height:\s*100%;[^}]*min-height:\s*0;/,
);
expect(skillListSource).toMatch(
/\.skill-list-page\s+:deep\(\.page-data-container\)\s*\{[^}]*height:\s*100%;/,
);
});
it('opens the compact creation dialog without navigating to a fake detail', async () => {
const wrapper = await mountSkillList();
const createButton = wrapper
.findAll('button')
.find((button) => button.text().includes('新建 Skill'));
await createButton?.trigger('click');
await flushPromises();
expect(
wrapper
.get('[data-testid="skill-create-dialog"]')
.attributes('data-open'),
).toBe('true');
wrapper.unmount();
});
it('hides category write actions without category management permission', async () => {
permissionMocks.hasPermission.mockImplementation(
(permissions: string[]) =>
!permissions.includes('/api/v1/skill/category'),
);
const wrapper = await mountSkillList();
const sidebar = wrapper.get('#skill-category-sidebar');
expect(sidebar.find('.page-side__footer').exists()).toBe(false);
expect(sidebar.findAll('.page-side__more')).toHaveLength(0);
wrapper.unmount();
it('shows only the agreed business columns and omits technical source data', () => {
for (const label of ['技能', '用途', '创建人', '范围', '状态', '操作']) {
expect(listSource).toContain(`label="${label}"`);
}
expect(listSource).not.toContain('label="来源"');
expect(listSource).not.toContain('label="能力"');
expect(listSource).not.toContain('<h1>技能库</h1>');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
import workbenchSource from './SkillResourceWorkbench.vue?raw';
const apiMocks = vi.hoisted(() => ({
createSkillFile: vi.fn(),
@@ -58,6 +59,17 @@ const resources = [
size: 12,
type: 'REFERENCE',
},
{
content: 'const value: string = "ok";\n',
isText: true,
key: 'scripts/check.ts',
language: 'TYPESCRIPT',
mediaType: 'application/octet-stream',
name: 'check.ts',
path: 'scripts/check.ts',
size: 28,
type: 'SCRIPT',
},
];
let wrapper: ReturnType<typeof mount> | undefined;
@@ -147,6 +159,14 @@ describe('skill resource workbench binary resources', () => {
);
});
it('uses the shared TypeScript highlighter for script resources', async () => {
await mountAndSelect('scripts/check.ts');
expect(
wrapper?.find('[data-testid="code-editor"]').attributes('data-language'),
).toBe('typescript');
});
it('gives readonly generic text the shared copy and download viewer', async () => {
await mountAndSelect('references/data.json', true);
@@ -310,17 +330,23 @@ describe('skill resource workbench validation', () => {
});
describe('skill resource workbench navigation', () => {
it('shows the standard directories and opens a directory-aware create form', async () => {
await mountAndSelect('references');
it('keeps the current file open when a directory is expanded', async () => {
await mountAndSelect('references/data.json');
await wrapper?.get('[data-tree-path="references"]').trigger('click');
await flushPromises();
expect(wrapper?.find('[data-tree-path="scripts"]').exists()).toBe(true);
expect(wrapper?.find('[data-tree-path="assets"]').exists()).toBe(true);
expect(wrapper?.text()).toContain('此目录暂无内容');
expect(wrapper?.text()).toContain('references/data.json');
expect(wrapper?.find('[data-testid="code-editor"]').exists()).toBe(true);
expect(wrapper?.text()).not.toContain('此目录暂无内容');
const createButton = wrapper
?.findAll('.skill-resource-workbench__directory-state button')
.find((button) => button.text().trim() === '新建文件');
await createButton?.trigger('click');
const referenceRow = wrapper?.get('[data-tree-path="references"]').element
.parentElement;
referenceRow
?.querySelector<HTMLButtonElement>('[aria-label="在此目录新建文件"]')
?.click();
await flushPromises();
expect(
@@ -334,47 +360,13 @@ describe('skill resource workbench navigation', () => {
expect(fileNameInput?.props('modelValue')).toBe('');
});
it('keeps capability binding in the same left navigation and switches panels', async () => {
wrapper = mount(SkillResourceWorkbench, {
global: {
directives: { loading: {} },
stubs: {
MarkdownLiveEditor: {
template: '<div data-testid="markdown-live-editor"></div>',
},
},
},
props: {
activePanel: 'resources',
capabilityAvailable: true,
capabilityCount: 3,
skillId: 101,
},
slots: {
capability: '<div data-testid="capability-slot">能力配置</div>',
},
});
await flushPromises();
it('keeps resource navigation focused on the standard package tree', async () => {
await mountAndSelect('references');
const entry = wrapper.get('.skill-resource-workbench__capability-entry');
expect(entry.text()).toContain('能力绑定');
expect(entry.text()).toContain('3');
expect(
wrapper
.get('.skill-resource-workbench__capability-area')
.attributes('style'),
).toContain('display: none');
await entry.trigger('click');
expect(wrapper.emitted('update:activePanel')?.at(-1)).toEqual([
'capability',
]);
await wrapper.setProps({ activePanel: 'capability' });
expect(
wrapper
.get('.skill-resource-workbench__capability-area')
.attributes('style'),
).toBeUndefined();
expect(entry.attributes('aria-current')).toBe('page');
expect(wrapper?.text()).not.toContain('能力绑定');
expect(workbenchSource).not.toContain('instructionsOnly');
expect(workbenchSource).not.toContain('selectSkillFile');
expect(workbenchSource).toContain('allow-create');
expect(workbenchSource).toContain('placeholder="选择或输入目录"');
});
});

View File

@@ -11,11 +11,7 @@ import type {
EditorLanguage,
} from '@easyflow-core/editor-ui';
import type {
SkillFileContent,
SkillFileNode,
SkillValidationIssue,
} from './types';
import type { SkillFileNode, SkillValidationIssue } from './types';
import {
computed,
@@ -48,7 +44,6 @@ import {
} from '@easyflow-core/editor-ui';
import {
Connection,
CopyDocument,
Download,
Fold,
@@ -91,18 +86,10 @@ import { useSkillFileBuffers } from './use-skill-file-buffers';
const props = withDefaults(
defineProps<{
activePanel?: 'capability' | 'resources';
capabilityAvailable?: boolean;
capabilityCount?: number;
initialContent?: string;
readonly?: boolean;
skillId: number | string;
}>(),
{
activePanel: 'resources',
capabilityAvailable: false,
capabilityCount: 0,
initialContent: '',
readonly: false,
},
);
@@ -110,11 +97,8 @@ const props = withDefaults(
const emit = defineEmits<{
dirty: [dirty: boolean];
issues: [issues: SkillValidationIssue[]];
locateCapability: [];
newContent: [content: string];
requestSave: [];
saved: [];
'update:activePanel': [panel: 'capability' | 'resources'];
}>();
const MarkdownLiveEditor = defineAsyncComponent(
@@ -123,7 +107,6 @@ const MarkdownLiveEditor = defineAsyncComponent(
const MAX_TEXT_FILE_BYTES = 2 * 1024 * 1024;
const STANDARD_DIRECTORIES = ['references', 'scripts', 'assets'] as const;
const isNew = computed(() => String(props.skillId) === 'new');
const tree = ref<SkillFileNode[]>([]);
const selectedPath = ref('SKILL.md');
const loading = ref(false);
@@ -275,17 +258,22 @@ const wordCount = computed(
);
const editorLanguage = computed<EditorLanguage>(() => {
const language = String(currentFile.value?.language || '').toUpperCase();
if (language === 'PYTHON' || currentFile.value?.path.endsWith('.py'))
return 'python';
if (
language === 'JAVASCRIPT' ||
/\.(?:js|mjs)$/.test(currentFile.value?.path || '')
)
const path = (currentFile.value?.path || '').toLowerCase();
if (language === 'PYTHON' || path.endsWith('.py')) return 'python';
if (language === 'TYPESCRIPT' || /\.(?:ts|tsx)$/.test(path))
return 'typescript';
if (language === 'JAVASCRIPT' || /\.(?:js|mjs|cjs|jsx)$/.test(path))
return 'javascript';
if (language === 'SHELL' || currentFile.value?.path.endsWith('.sh'))
return 'shell';
if (language === 'SHELL' || /\.(?:sh|bash|zsh)$/.test(path)) return 'shell';
if (language === 'JSON' || path.endsWith('.json')) return 'json';
if (language === 'YAML' || /\.(?:yaml|yml)$/.test(path)) return 'yaml';
if (language === 'XML' || path.endsWith('.xml')) return 'xml';
if (language === 'HTML' || /\.(?:html|htm|vue|svelte)$/.test(path))
return 'html';
if (language === 'CSS' || /\.(?:css|scss|less)$/.test(path)) return 'css';
if (language === 'JAVA' || /\.(?:java|kt|kts)$/.test(path)) return 'java';
if (language === 'SQL' || path.endsWith('.sql')) return 'sql';
if (isMarkdown.value) return 'markdown';
if (currentFile.value?.mediaType === 'application/json') return 'json';
return 'text';
});
const lineCount = computed(() => contentMetrics.value.lines);
@@ -297,9 +285,6 @@ watch(
dirtyPaths,
(value) => {
emit('dirty', value.size > 0);
if (value.has('SKILL.md')) {
emit('newContent', buffers.get('SKILL.md')?.content || '');
}
},
{ deep: true },
);
@@ -325,16 +310,6 @@ watch([selectedPath, isLargeForLive, isMarkdown], ([, large, markdown]) => {
});
watch(() => props.skillId, init);
watch(
() => props.initialContent,
(content) => {
if (!isNew.value) return;
const buffer = buffers.get('SKILL.md');
if (buffer && content !== buffer.content) {
updateContent('SKILL.md', content || '');
}
},
);
watch([selectedPath, currentFile], loadAssetPreview);
onMounted(init);
onKeyStroke('Escape', () => {
@@ -365,22 +340,6 @@ async function init() {
selectedPath.value = 'SKILL.md';
settledPath = 'SKILL.md';
try {
if (isNew.value) {
const content =
props.initialContent ||
'---\nname: new-skill\ndescription: New skill\n---\n\n# Instructions\n';
const file: SkillFileContent = {
content,
path: 'SKILL.md',
type: 'SKILL',
};
if (!isCurrentRequest(requestedSkillId, request)) return;
ensureBuffer(file);
tree.value = ensureStandardTree([
{ key: 'SKILL.md', name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
]);
return;
}
if (!(await loadTree(request))) return;
await selectFile('SKILL.md', request);
} catch {
@@ -420,19 +379,13 @@ async function selectFile(
) {
const path = typeof pathOrNode === 'string' ? pathOrNode : pathOrNode.path;
if (typeof pathOrNode !== 'string' && pathOrNode.type === 'DIRECTORY') {
fileContentRequest++;
loadingRequest++;
loading.value = false;
loadError.value = '';
selectedPath.value = path;
settledPath = path;
return true;
return false;
}
const request = ++fileContentRequest;
const requestedSkillId = String(props.skillId);
const previousPath = settledPath;
selectedPath.value = path;
if (buffers.has(path) || isNew.value) {
if (buffers.has(path)) {
settledPath = path;
loadingRequest++;
loading.value = false;
@@ -490,22 +443,13 @@ async function selectFileFromTree(
node: { path: string; type?: string },
closeTree: () => void,
) {
emit('update:activePanel', 'resources');
await selectFile(node);
closeTree();
}
function selectCapabilityPanel(closeTree: () => void) {
if (!props.capabilityAvailable) return;
inspector.value = undefined;
fullscreen.value = false;
emit('update:activePanel', 'capability');
closeTree();
}
async function saveBuffer(path: string) {
const buffer = buffers.get(path);
if (!buffer || buffer.content === buffer.original || isNew.value) return true;
if (!buffer || buffer.content === buffer.original) return true;
if (buffer.saveState === 'saving') return false;
if (buffer.conflict) {
markError(path);
@@ -547,7 +491,7 @@ async function saveBuffer(path: string) {
async function reloadCurrent() {
const buffer = currentBuffer.value;
if (!buffer || isNew.value || fileMutation.value || savingAll.value) return;
if (!buffer || fileMutation.value || savingAll.value) return;
fileMutation.value = 'reload';
try {
if (buffer.content !== buffer.original) {
@@ -577,10 +521,6 @@ async function saveAll() {
if (savingAll.value || fileMutation.value) return false;
savingAll.value = true;
try {
if (isNew.value) {
emit('newContent', buffers.get('SKILL.md')?.content || '');
return true;
}
for (const path of dirtyPaths.value) {
if (!(await saveBuffer(path))) return false;
}
@@ -594,11 +534,6 @@ async function saveAll() {
async function validateAll() {
if (validating.value || savingAll.value || fileMutation.value) return false;
if (isNew.value) {
issues.value = [];
emit('issues', []);
return true;
}
validating.value = true;
try {
const res = await validateSkillForPublish(props.skillId);
@@ -616,11 +551,6 @@ async function validateAll() {
async function locateIssue(issue: DocumentValidationIssue) {
if (!issue.path) return;
if (issue.path.startsWith('capabilities')) {
inspector.value = undefined;
emit('locateCapability');
return;
}
if (!flattenFilePaths(tree.value).includes(issue.path)) {
ElMessage.warning(`无法定位校验项:${issue.path}`);
return;
@@ -638,18 +568,7 @@ async function locateIssue(issue: DocumentValidationIssue) {
function openCreateFile(node?: DocumentFileNode) {
if (props.readonly || fileMutation.value || savingAll.value) return;
if (isNew.value) {
ElMessage.info('请先保存 Skill再添加资源文件');
return;
}
const selectedDirectory =
node?.type === 'DIRECTORY'
? node.path
: currentNode.value?.type === 'DIRECTORY'
? currentNode.value.path
: selectedPath.value.includes('/')
? selectedPath.value.slice(0, selectedPath.value.lastIndexOf('/'))
: 'references';
const selectedDirectory = resolveSelectedDirectory(node, 'references');
createFileForm.directory = directoryOptions.value.some(
(option) => option.path === selectedDirectory,
)
@@ -699,19 +618,20 @@ async function submitCreateFile() {
function chooseUpload(node?: DocumentFileNode) {
if (props.readonly || fileMutation.value || savingAll.value) return;
if (isNew.value) {
ElMessage.info('请先保存 Skill再上传资源');
return;
}
uploadDirectory.value =
node?.type === 'DIRECTORY'
? node.path
: currentNode.value?.type === 'DIRECTORY'
? currentNode.value.path
: 'assets';
uploadDirectory.value = resolveSelectedDirectory(node, 'assets');
uploadInputRef.value?.click();
}
function resolveSelectedDirectory(
node: DocumentFileNode | undefined,
fallback: string,
) {
if (node?.type === 'DIRECTORY') return node.path;
if (currentNode.value?.type === 'DIRECTORY') return currentNode.value.path;
const separator = selectedPath.value.lastIndexOf('/');
return separator > 0 ? selectedPath.value.slice(0, separator) : fallback;
}
async function handleUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
@@ -802,7 +722,6 @@ async function renameCurrent() {
if (
!currentFile.value ||
currentFile.value.path === 'SKILL.md' ||
isNew.value ||
fileMutation.value ||
savingAll.value
)
@@ -880,7 +799,6 @@ async function removeCurrent() {
if (
!currentFile.value ||
currentFile.value.path === 'SKILL.md' ||
isNew.value ||
fileMutation.value ||
savingAll.value
)
@@ -940,7 +858,7 @@ async function downloadNode(node: DocumentFileNode) {
}
async function downloadCurrent() {
if (!currentFile.value || isNew.value) return;
if (!currentFile.value) return;
try {
const blob =
buildSkillTextDownload(currentFile.value, currentContent.value) ||
@@ -972,7 +890,6 @@ async function loadAssetPreview() {
assetPreviewError.value = false;
if (
!isBinaryResource.value ||
isNew.value ||
(!isImageResource.value && !isPdfResource.value)
)
return;
@@ -1038,7 +955,6 @@ async function resolveMarkdownImage(url: string) {
if (/^[a-z][a-z\d+.-]*:|^\/\//i.test(url)) {
return EMPTY_IMAGE_DATA_URL;
}
if (isNew.value) return EMPTY_IMAGE_DATA_URL;
const path = resolveSkillRelativePath(selectedPath.value, url);
const node = path ? findFileNode(tree.value, path) : undefined;
if (!path || !node || !isSafeImageNode(node)) return EMPTY_IMAGE_DATA_URL;
@@ -1269,7 +1185,7 @@ defineExpose({
:busy="Boolean(fileMutation) || savingAll"
:nodes="tree"
:readonly="readonly"
:current-path="activePanel === 'resources' ? selectedPath : ''"
:current-path="selectedPath"
:dirty-paths="dirtyPaths"
:error-counts="errorCounts"
:warning-counts="warningCounts"
@@ -1280,20 +1196,6 @@ defineExpose({
@rename="renameNode"
@upload="chooseUpload"
/>
<button
v-if="capabilityAvailable"
type="button"
class="skill-resource-workbench__capability-entry"
:class="{ 'is-active': activePanel === 'capability' }"
:aria-current="activePanel === 'capability' ? 'page' : undefined"
@click="selectCapabilityPanel(close)"
>
<span>
<Connection aria-hidden="true" />
能力绑定
</span>
<small>{{ capabilityCount }}</small>
</button>
</div>
<input
ref="uploadInputRef"
@@ -1303,7 +1205,7 @@ defineExpose({
/>
</template>
<template v-if="activePanel === 'resources'" #toolbar>
<template #toolbar>
<div class="skill-resource-workbench__toolbar">
<div class="skill-resource-workbench__file-identity">
<strong>{{
@@ -1409,17 +1311,7 @@ defineExpose({
</div>
</template>
<div
v-show="activePanel === 'capability'"
class="skill-resource-workbench__capability-area"
>
<slot name="capability"></slot>
</div>
<div
v-show="activePanel === 'resources'"
v-loading="loading"
class="skill-resource-workbench__editor-area"
>
<div v-loading="loading" class="skill-resource-workbench__editor-area">
<template v-if="currentFile">
<MarkdownLiveEditor
v-if="isMarkdown && markdownMode === 'live' && !isLargeForLive"
@@ -1427,7 +1319,7 @@ defineExpose({
class="skill-resource-workbench__editor"
:readonly="readonly"
:resolve-image-url="resolveMarkdownImage"
:upload-image="isNew || readonly ? undefined : uploadInlineImage"
:upload-image="readonly ? undefined : uploadInlineImage"
@error="handleEditorError"
@fidelity-loss="handleMarkdownFidelityLoss"
@frontmatter-activate="handleFrontmatterActivate"
@@ -1540,19 +1432,6 @@ defineExpose({
</ElButton>
</div>
</template>
<div
v-else-if="currentNode?.type === 'DIRECTORY'"
class="skill-resource-workbench__directory-state"
>
<strong>{{ currentNode.name }}</strong>
<span>此目录暂无内容</span>
<div v-if="!readonly">
<ElButton type="primary" @click="openCreateFile(currentNode)">
新建文件
</ElButton>
<ElButton @click="chooseUpload(currentNode)">上传资源</ElButton>
</div>
</div>
<ElEmpty v-else :description="loadError || '从左侧选择文件'">
<ElButton v-if="loadError" @click="init">重新加载</ElButton>
</ElEmpty>
@@ -1560,7 +1439,8 @@ defineExpose({
<template #status>
<EditorStatusBar
v-if="activePanel === 'resources' && currentFile && !isBinaryResource"
v-if="currentFile && !isBinaryResource"
class="skill-resource-workbench__status"
:column="cursor.column"
:language="editorLanguage"
:line="cursor.line"
@@ -1602,7 +1482,13 @@ defineExpose({
show-icon
/>
<ElFormItem label="所属目录" prop="directory">
<ElSelect v-model="createFileForm.directory">
<ElSelect
v-model="createFileForm.directory"
allow-create
default-first-option
filterable
placeholder="选择或输入目录"
>
<ElOption
v-for="directory in directoryOptions"
:key="directory.path"
@@ -1653,58 +1539,6 @@ defineExpose({
min-height: 0;
}
.skill-resource-workbench__capability-entry {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 var(--space-3);
margin: var(--space-2);
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.skill-resource-workbench__capability-entry > span {
display: flex;
gap: var(--space-2);
align-items: center;
min-width: 0;
font-weight: 600;
}
.skill-resource-workbench__capability-entry svg {
width: 16px;
height: 16px;
}
.skill-resource-workbench__capability-entry small {
display: grid;
min-width: 24px;
height: 24px;
place-items: center;
color: hsl(var(--text-muted));
background: hsl(var(--surface-contrast-soft));
border-radius: var(--radius-pill);
}
.skill-resource-workbench__capability-entry:hover,
.skill-resource-workbench__capability-entry.is-active {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.skill-resource-workbench__capability-entry:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 1px;
}
.skill-resource-workbench__toolbar {
display: flex;
gap: var(--space-3);
@@ -1734,7 +1568,6 @@ defineExpose({
}
.skill-resource-workbench__editor-area,
.skill-resource-workbench__capability-area,
.skill-resource-workbench__editor {
display: flex;
flex: 1;
@@ -1742,8 +1575,15 @@ defineExpose({
min-height: 0;
}
.skill-resource-workbench__capability-area {
overflow: hidden;
.skill-resource-workbench__status {
position: absolute;
right: var(--space-3);
bottom: var(--space-3);
z-index: 2;
min-height: 28px;
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-control);
box-shadow: var(--shadow-subtle);
}
.skill-resource-workbench__asset {
@@ -1841,27 +1681,6 @@ defineExpose({
color: hsl(var(--foreground));
}
.skill-resource-workbench__directory-state {
display: grid;
gap: var(--space-2);
place-content: center;
place-items: center;
width: 100%;
color: hsl(var(--text-muted));
text-align: center;
}
.skill-resource-workbench__directory-state strong {
color: hsl(var(--foreground));
font-size: 16px;
}
.skill-resource-workbench__directory-state > div {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
@media (max-width: 720px) {
.skill-resource-workbench__toolbar {
flex-wrap: wrap;

View File

@@ -59,7 +59,6 @@ describe('skill settings dialog', () => {
skill: {
categoryId: 8,
displayName: '原 Skill',
enabled: true,
id: 101,
visibilityScope: 'PRIVATE',
},
@@ -78,7 +77,6 @@ describe('skill settings dialog', () => {
expect.objectContaining({
categoryId: 8,
displayName: '更新后的 Skill',
enabled: true,
id: 101,
visibilityScope: 'DEPT',
}),

View File

@@ -16,7 +16,6 @@ import {
ElMessage,
ElOption,
ElSelect,
ElSwitch,
} from 'element-plus';
import { updateSkill } from './api';
@@ -79,7 +78,6 @@ function resetForm() {
categoryId: props.skill.categoryId || '',
categoryName: props.skill.categoryName,
displayName: props.skill.displayName || '',
enabled: props.skill.enabled !== false,
id: props.skill.id,
visibilityScope: props.skill.visibilityScope || 'PRIVATE',
});
@@ -202,13 +200,6 @@ watch(
<ElOption label="全员可见" value="PUBLIC" />
</ElSelect>
</ElFormItem>
<ElFormItem label="启用状态">
<div class="skill-settings-form__switch">
<ElSwitch v-model="form.enabled" />
<span>{{ form.enabled === false ? '停用' : '启用' }}</span>
</div>
</ElFormItem>
</ElForm>
</EasyFlowFormModal>
</template>
@@ -222,8 +213,7 @@ watch(
width: 100%;
}
.skill-settings-form__category-field,
.skill-settings-form__switch {
.skill-settings-form__category-field {
display: flex;
gap: var(--space-2);
align-items: center;
@@ -234,8 +224,4 @@ watch(
flex: 1;
min-width: 0;
}
.skill-settings-form__switch {
color: hsl(var(--text-muted));
}
</style>

View File

@@ -1,15 +1,11 @@
import type {
RequestResult,
SkillCapabilityBinding,
SkillCapabilityCandidate,
SkillCapabilityReplaceResult,
SkillCapabilityTools,
SkillCapabilityType,
SkillCategory,
SkillCategoryDraft,
SkillExportFormat,
SkillFileContent,
SkillFileNode,
SkillImportBatchResult,
SkillImportConfirmPayload,
SkillImportPreview,
SkillInfo,
SkillValidationResult,
@@ -17,7 +13,6 @@ import type {
import { api } from '#/api/request';
import { buildCapabilityBindingsPayload } from './skill-capability';
import { buildSkillCategoryPayload } from './skill-category';
import { buildSkillDraftPayload } from './skill-draft';
@@ -60,18 +55,14 @@ export function validateSkillForPublish(id: number | string) {
export function getSkillCategories() {
return api.get<RequestResult<SkillCategory[]>>(
'/api/v1/skill/category/visibleList',
{
params: { sortKey: 'sortNo', sortType: 'asc' },
},
{ params: { sortKey: 'sortNo', sortType: 'asc' } },
);
}
export function getSkillCategoryTree() {
return api.get<RequestResult<SkillCategory[]>>(
'/api/v1/skill/category/tree',
{
params: { sortKey: 'sortNo', sortType: 'asc' },
},
{ params: { sortKey: 'sortNo', sortType: 'asc' } },
);
}
@@ -93,10 +84,13 @@ export function deleteSkillCategory(id: number | string) {
return api.post<RequestResult<void>>('/api/v1/skill/category/remove', { id });
}
export function submitSkillPublishApproval(id: number | string) {
export function submitSkillPublishApproval(
id: number | string,
applicationReason: string,
) {
return api.post<RequestResult<null | number | string>>(
'/api/v1/skill/submitPublishApproval',
{ id },
{ applicationReason, id },
);
}
@@ -123,9 +117,7 @@ export function getSkillFileTree(skillId: number | string) {
export function getSkillFileContent(skillId: number | string, path: string) {
return api.get<RequestResult<SkillFileContent>>(
'/api/v1/skill/file/content',
{
params: { path, skillId },
},
{ params: { path, skillId } },
);
}
@@ -196,9 +188,8 @@ export function uploadSkillFile(
const formData = new FormData();
formData.append('skillId', String(skillId));
formData.append('path', path);
if (expectedContentHash) {
if (expectedContentHash)
formData.append('expectedContentHash', expectedContentHash);
}
formData.append('file', file);
return api.postFile<RequestResult<SkillFileContent>>(
'/api/v1/skill/file/upload',
@@ -218,71 +209,18 @@ export function previewSkillFile(skillId: number | string, path: string) {
});
}
export function getSkillCapabilityBindings(skillId: number | string) {
return api.get<RequestResult<SkillCapabilityBinding[]>>(
'/api/v1/skill/capability/list',
{
params: { skillId },
},
);
}
export function getSkillCapabilityCandidates(
type: SkillCapabilityType,
keyword = '',
) {
return api.get<RequestResult<SkillCapabilityCandidate[]>>(
'/api/v1/skill/capability/candidates',
{ params: { keyword, type } },
);
}
export function getSkillCapabilityTools(targetId: number | string) {
return api.get<RequestResult<SkillCapabilityTools>>(
'/api/v1/skill/capability/tools',
{
params: { targetId },
},
);
}
export function replaceSkillCapabilityBindings(
skillId: number | string,
bindings: SkillCapabilityBinding[],
expectedCapabilityHash: string,
) {
return api.post<RequestResult<SkillCapabilityReplaceResult>>(
'/api/v1/skill/capability/replace',
{
bindings: buildCapabilityBindingsPayload(bindings),
expectedCapabilityHash,
skillId,
},
);
}
export function importSkillPreview(file: File) {
export function importSkillPreviews(files: File[]) {
const formData = new FormData();
formData.append('file', file);
return api.postFile<RequestResult<SkillImportPreview>>(
files.forEach((file) => formData.append('files', file));
return api.postFile<RequestResult<SkillImportPreview[]>>(
'/api/v1/skill/import/preview',
formData,
);
}
export function importSkillConfirm(payload: {
capabilityMappings?: Array<{
bindingKey: string;
disabled?: boolean;
targetId?: number | string;
}>;
categoryId?: number | string;
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
importToken: string;
renames?: Record<string, string>;
}) {
return api.post<RequestResult<SkillInfo[]>>(
'/api/v1/skill/import/confirm',
export function importSkillConfirmBatch(payload: SkillImportConfirmPayload[]) {
return api.post<RequestResult<SkillImportBatchResult[]>>(
'/api/v1/skill/import/confirmBatch',
payload,
);
}
@@ -293,12 +231,9 @@ export function cancelSkillImport(importToken: string) {
});
}
export function exportSkills(
ids: Array<number | string>,
format: SkillExportFormat,
) {
export function exportSkills(ids: Array<number | string>) {
return api.download<Blob>('/api/v1/skill/export', {
data: { format, ids },
data: { ids },
method: 'POST',
});
}

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest';
import {
isSkillAccessDeniedError,
isSkillCapabilityConflictError,
isSkillFileConflictError,
resolveSkillApiErrorMessage,
} from './skill-api-error';
@@ -15,23 +14,12 @@ describe('skill API conflict errors', () => {
message: '文件已被其他操作更新',
}),
).toBe(true);
expect(
isSkillCapabilityConflictError({
errorCode: 4093,
message: '能力配置已被其他操作更新',
}),
).toBe(true);
});
it('also recognizes Axios-shaped conflicts and ignores ordinary errors', () => {
expect(
isSkillFileConflictError({ response: { data: {}, status: 409 } }),
).toBe(true);
expect(
isSkillCapabilityConflictError({
response: { data: { errorCode: 4093 }, status: 400 },
}),
).toBe(true);
expect(isSkillFileConflictError({ errorCode: 500 })).toBe(false);
});

View File

@@ -43,16 +43,6 @@ export function isSkillFileConflictError(error: unknown) {
);
}
/** 判断能力配置请求是否因版本冲突失败。 */
export function isSkillCapabilityConflictError(error: unknown) {
const snapshot = errorSnapshot(error);
return (
snapshot.status === 409 ||
snapshot.errorCode === 4093 ||
snapshot.message.includes('能力配置已被其他操作更新')
);
}
/** 判断 Skill 请求是否被服务端以未登录或无权限拒绝。 */
export function isSkillAccessDeniedError(error: unknown) {
const snapshot = errorSnapshot(error);

View File

@@ -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);
});
});

View File

@@ -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]);
}

View File

@@ -26,7 +26,6 @@ describe('skill creation draft', () => {
categoryId: 8,
description: '研究助手',
displayName: '研究助手',
enabled: true,
publishStatus: 'DRAFT',
visibilityScope: 'DEPT',
});

View File

@@ -1,9 +1,7 @@
import type { SkillInfo } from './types';
import type { SkillInfo, SkillVisibilityScope } from './types';
import { syncSkillMarkdownFrontmatter } from './skill-markdown';
export type SkillCreateIntent = 'DRAFT' | 'PUBLISH';
/** Build a hidden, standards-compliant canonical name for a new Skill. */
export function createSkillCanonicalName(
displayName: string,
@@ -24,7 +22,7 @@ export function createSkillCanonicalName(
export function buildInitialSkillDraft(input: {
categoryId?: number | string;
displayName: string;
visibilityScope: string;
visibilityScope: SkillVisibilityScope;
}): SkillInfo {
const displayName = input.displayName.trim();
const name = createSkillCanonicalName(displayName);
@@ -37,7 +35,6 @@ export function buildInitialSkillDraft(input: {
categoryId: input.categoryId || undefined,
description: displayName,
displayName,
enabled: true,
name,
publishStatus: 'DRAFT',
skillContent: content,

View File

@@ -10,11 +10,9 @@ describe('skill draft payload', () => {
categoryId: 3,
description: 'description',
displayName: 'Display name',
enabled: true,
id: 9,
name: 'standard-name',
packageHash: 'must-not-leak',
resourceCount: 7,
resources: [{ contentRef: 'must-not-leak', path: 'secret.md' }],
skillContent: 'content',
visibilityScope: 'PRIVATE',
@@ -25,7 +23,6 @@ describe('skill draft payload', () => {
expect(payload).toEqual({
categoryId: 3,
displayName: 'Display name',
enabled: true,
skillContent: 'content',
visibilityScope: 'PRIVATE',
});
@@ -37,7 +34,6 @@ describe('skill draft payload', () => {
).toEqual({
categoryId: undefined,
displayName: undefined,
enabled: undefined,
id: 9,
visibilityScope: undefined,
});

View File

@@ -3,7 +3,6 @@ import type { SkillInfo } from './types';
export interface SkillDraftPayload {
categoryId?: number | string;
displayName?: string;
enabled?: boolean;
id?: number | string;
skillContent?: string;
visibilityScope?: string;
@@ -25,7 +24,6 @@ export function buildSkillDraftPayload(
const payload: SkillDraftPayload = {
categoryId: skill.categoryId,
displayName: skill.displayName,
enabled: skill.enabled,
visibilityScope: skill.visibilityScope,
};
if (includeId) payload.id = skill.id;

View File

@@ -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);
});
});

View File

@@ -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);
}

View File

@@ -9,6 +9,8 @@ interface SkillListRouteState {
keyword: string;
pageNumber: number;
pageSize: number;
publishStatus: string;
visibilityScope: string;
}
const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
@@ -18,9 +20,11 @@ const skillListRouteSchema = defineListRouteStateSchema<SkillListRouteState>({
keyword: stringListRouteField(),
pageNumber: positiveIntegerListRouteField({ defaultValue: 1 }),
pageSize: positiveIntegerListRouteField({
allowedValues: [12, 24, 48],
defaultValue: 12,
allowedValues: [10, 20, 50],
defaultValue: 10,
}),
publishStatus: stringListRouteField(),
visibilityScope: stringListRouteField(),
},
});

View File

@@ -4,16 +4,11 @@ export interface RequestResult<T = unknown> {
message?: string;
}
export type SkillCapabilityType = 'MCP' | 'PLUGIN_ITEM' | 'WORKFLOW';
export type SkillExportFormat = 'EASYFLOW' | 'STANDARD';
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
export type SkillVisibilityScope = 'DEPT' | 'PRIVATE' | 'PUBLIC';
export interface SkillInfo {
approvalPending?: boolean;
assetCount?: number;
bindings?: SkillCapabilityBinding[];
capabilityCount?: number;
capabilityHash?: string;
categoryId?: number | string;
categoryName?: string;
created?: string;
@@ -23,22 +18,17 @@ export interface SkillInfo {
description?: string;
displayName?: string;
displayPublishStatus?: string;
enabled?: boolean;
id?: number | string;
manageable?: boolean;
metadataJson?: Record<string, unknown>;
modified?: string;
name?: string;
packageHash?: string;
publishStatus?: string;
referenceCount?: number;
resourceCount?: number;
readable?: boolean;
resources?: SkillResource[];
scriptCount?: number;
skillContent?: string;
sourceType?: string;
visibilityScope?: string;
[key: string]: unknown;
snapshotHash?: string;
visibilityScope?: SkillVisibilityScope;
}
export interface SkillResource {
@@ -48,9 +38,7 @@ export interface SkillResource {
id?: number | string;
isText?: boolean;
kind?: string;
language?: string;
mediaType?: string;
metadataJson?: Record<string, unknown>;
path: string;
size?: number;
}
@@ -94,67 +82,6 @@ export interface SkillValidationResult {
valid: boolean;
}
export interface SkillCapabilityBinding {
bindingKey?: string;
capabilityType: SkillCapabilityType;
enabled: boolean;
executionMode?: 'ASYNC' | 'SYNC';
hitlConfigJson?: Record<string, unknown>;
hitlEnabled?: boolean;
id?: number | string;
optionsJson?: Record<string, unknown>;
runtimeName?: string;
resolvedToolNames?: string[];
selectedToolNamesJson?: string[];
selectionMode?: 'ALL' | 'SELECTED';
sortNo?: number;
targetLogicalRef?: string;
targetStatus?: 'AVAILABLE' | 'NO_PERMISSION' | 'UNAVAILABLE' | 'UNRESOLVED';
targetId?: number | string;
targetName?: string;
targetSummary?: string;
}
export interface SkillCapabilityCandidate {
capabilityType: SkillCapabilityType;
description?: string;
logicalRef?: string;
name: string;
revision?: string;
status?: string;
targetId: number | string;
toolNames?: string[];
}
export interface SkillCapabilityTools {
status: string;
targetId: number | string;
toolNames: string[];
}
export interface SkillCapabilityReplaceResult {
bindings: SkillCapabilityBinding[];
capabilityHash: string;
}
export interface SkillImportPreviewItem {
assetCount?: number;
conflict: boolean;
conflictReason?: 'NAME_UNAVAILABLE' | 'NOT_DRAFT';
description?: string;
files?: SkillImportPreviewFile[];
name: string;
packageHash?: string;
packageId: string;
packageRoot?: string;
overwriteAllowed?: boolean;
referenceCount?: number;
resourceCount?: number;
scriptCount?: number;
suggestedName?: string;
validationIssues?: SkillValidationIssue[];
}
export interface SkillImportPreviewFile {
kind: 'ASSET' | 'EXAMPLE' | 'OTHER' | 'REFERENCE' | 'SCRIPT' | 'SKILL';
mediaType?: string;
@@ -163,27 +90,40 @@ export interface SkillImportPreviewFile {
text: boolean;
}
export interface SkillCapabilityMapping {
bindingKey: string;
capabilityType: SkillCapabilityType;
disabled?: boolean;
export interface SkillImportPreviewItem {
conflict: boolean;
conflictReason?: 'NAME_UNAVAILABLE' | 'NOT_DRAFT';
description?: string;
files?: SkillImportPreviewFile[];
name: string;
overwriteAllowed?: boolean;
packageHash?: string;
packageId: string;
packageRoot?: string;
runtimeName?: string;
status: 'RESOLVED' | 'UNRESOLVED';
targetId?: number | string;
targetLogicalRef?: string;
targetName?: string;
}
export interface SkillImportPreview {
capabilityMappings?: SkillCapabilityMapping[];
expiresAt?: string;
format: SkillExportFormat;
importToken: string;
importToken?: string;
issues?: SkillValidationIssue[];
skills: SkillImportPreviewItem[];
}
export interface SkillImportConfirmPayload {
categoryId?: number | string;
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
importToken: string;
renames?: Record<string, string>;
visibilityScope: SkillVisibilityScope;
}
export interface SkillImportBatchResult {
importToken?: string;
message?: string;
skills: SkillInfo[];
success: boolean;
}
export interface SkillFileBuffer {
content: string;
conflict?: boolean;

View 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`

View File

@@ -17,6 +17,7 @@ import {
foldGutter,
foldKeymap,
indentOnInput,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import {
@@ -77,25 +78,53 @@ let languageRequest = 0;
let syncingFromOutside = false;
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') {
const { python } = await import('@codemirror/lang-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');
return javascript({ jsx: language === 'javascript', typescript: true });
return javascript({
jsx: true,
typescript: language === 'typescript',
});
}
if (language === 'markdown') {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
if (language === 'shell') {
const [{ StreamLanguage }, { shell }] = await Promise.all([
import('@codemirror/language'),
import('@codemirror/legacy-modes/mode/shell'),
]);
const { shell } = await import('@codemirror/legacy-modes/mode/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 [];
}

View File

@@ -90,7 +90,7 @@ describe('markdown live editor DOM safety', () => {
'.milkdown-frontmatter',
);
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('Use this skill');
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');

View File

@@ -4,7 +4,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
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(() => ({
config: undefined as MockCrepeConfig | undefined,
dispatch: vi.fn(),
destroy: vi.fn(async () => undefined),
listSpreadNodes: [] as Array<{
@@ -14,16 +29,30 @@ const mockState = vi.hoisted(() => ({
type: { name: string };
}>,
createPromise: undefined as Promise<void> | undefined,
firstNode: undefined as
| undefined
| { nodeSize: number; type: { name: string } },
markdown: '# Initial',
markdownUpdated: undefined as
| ((ctx: unknown, value: string) => void)
| undefined,
replaceAll: vi.fn(),
resolve: vi.fn((position: number) => ({ position })),
selectionFrom: 2,
setSelection: vi.fn(),
setNodeMarkup: vi.fn(),
setReadonly: vi.fn(),
setTransactionMeta: vi.fn(),
}));
vi.mock('@milkdown/kit/prose/state', () => ({
TextSelection: {
near: (position: { position: number }) => ({
position: position.position,
}),
},
}));
vi.mock('@milkdown/kit/utils', () => ({
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
@@ -39,6 +68,7 @@ vi.mock('@milkdown/crepe', () => {
class MockCrepe {
static Feature = {
AI: 'ai',
BlockEdit: 'block-edit',
ImageBlock: 'image-block',
Latex: 'latex',
Placeholder: 'placeholder',
@@ -56,6 +86,8 @@ vi.mock('@milkdown/crepe', () => {
if (slice.name === 'editorState') {
return {
doc: {
firstChild: mockState.firstNode,
resolve: mockState.resolve,
descendants: (
callback: (
node: (typeof mockState.listSpreadNodes)[number],
@@ -66,9 +98,11 @@ vi.mock('@milkdown/crepe', () => {
callback(node, node.position),
),
},
selection: { from: mockState.selectionFrom },
tr: {
setMeta: mockState.setTransactionMeta,
setNodeMarkup: mockState.setNodeMarkup,
setSelection: mockState.setSelection,
},
};
}
@@ -82,7 +116,8 @@ vi.mock('@milkdown/crepe', () => {
use: () => undefined,
};
setReadonly = mockState.setReadonly;
constructor(config: { defaultValue?: string }) {
constructor(config: MockCrepeConfig) {
mockState.config = config;
mockState.markdown = config.defaultValue || '';
}
getMarkdown = () => mockState.markdown;
@@ -101,10 +136,15 @@ vi.mock('@milkdown/crepe', () => {
describe('markdownLiveEditor', () => {
beforeEach(() => {
mockState.config = undefined;
mockState.destroy.mockClear();
mockState.createPromise = undefined;
mockState.dispatch.mockClear();
mockState.firstNode = undefined;
mockState.replaceAll.mockClear();
mockState.resolve.mockClear();
mockState.selectionFrom = 2;
mockState.setSelection.mockClear();
mockState.setReadonly.mockClear();
mockState.markdown = '# Initial';
mockState.markdownUpdated = undefined;
@@ -115,6 +155,133 @@ describe('markdownLiveEditor', () => {
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 () => {
vi.useFakeTimers();
const wrapper = mount(MarkdownLiveEditor, {
@@ -129,7 +296,7 @@ describe('markdownLiveEditor', () => {
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
.trigger('keydown', { key: 'Enter' });
mockState.markdownUpdated?.({}, '# User edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# 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 () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },

View File

@@ -3,6 +3,7 @@ import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { Crepe } from '@milkdown/crepe';
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
import { TextSelection } from '@milkdown/kit/prose/state';
import { replaceAll } from '@milkdown/kit/utils';
import {
@@ -26,7 +27,7 @@ const props = withDefaults(
uploadImage?: (file: File) => Promise<string>;
}>(),
{
placeholder: '输入 Skill 指令…',
placeholder: '输入 Markdown 内容…',
readonly: false,
resolveImageUrl: undefined,
uploadImage: undefined,
@@ -64,12 +65,37 @@ async function createEditor() {
const crepe = new Crepe({
defaultValue: initialModelValue,
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]: {
onUpload: async (file) => {
if (props.uploadImage) return props.uploadImage(file);
const error = new Error(
'当前上下文不支持直接上传图片,请从资源文件树上传',
);
const error = new Error('当前编辑器未配置图片上传');
emit('error', error);
throw error;
},
@@ -199,32 +225,36 @@ watch(
onMounted(createEditor);
onMounted(() => {
hostRef.value?.addEventListener('click', handleBlockMenuClick);
hostRef.value?.addEventListener('click', handleLinkClick);
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
hostRef.value?.addEventListener('beforeinput', markUserEdited);
hostRef.value?.addEventListener('beforeinput', prepareUserEdit, true);
hostRef.value?.addEventListener('drop', markUserEdited);
hostRef.value?.addEventListener('keydown', handleKeydown);
hostRef.value?.addEventListener('paste', markUserEdited);
hostRef.value?.addEventListener('keydown', handleKeydown, true);
hostRef.value?.addEventListener('paste', prepareUserEdit, true);
hostRef.value?.addEventListener('pointerup', prepareBlockMenuEdit, true);
if (hostRef.value) {
domObserver = new MutationObserver(sanitizeRenderedLinks);
domObserver = new MutationObserver(refreshRenderedContent);
domObserver.observe(hostRef.value, {
attributeFilter: ['href'],
attributes: true,
childList: true,
subtree: true,
});
sanitizeRenderedLinks();
refreshRenderedContent();
}
});
onBeforeUnmount(async () => {
destroyed = true;
ready = false;
hostRef.value?.removeEventListener('click', handleBlockMenuClick);
hostRef.value?.removeEventListener('click', handleLinkClick);
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
hostRef.value?.removeEventListener('beforeinput', markUserEdited);
hostRef.value?.removeEventListener('beforeinput', prepareUserEdit, true);
hostRef.value?.removeEventListener('drop', markUserEdited);
hostRef.value?.removeEventListener('keydown', handleKeydown);
hostRef.value?.removeEventListener('paste', markUserEdited);
hostRef.value?.removeEventListener('keydown', handleKeydown, true);
hostRef.value?.removeEventListener('paste', prepareUserEdit, true);
hostRef.value?.removeEventListener('pointerup', prepareBlockMenuEdit, true);
domObserver?.disconnect();
domObserver = undefined;
await crepeRef.value?.destroy();
@@ -260,10 +290,67 @@ function handleFrontmatterActivate(event: Event) {
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() {
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() {
hostRef.value
?.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) {
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') {
event.preventDefault();
if (!props.readonly) emit('save');
return;
}
prepareUserEdit();
}
function reportFidelityLoss(crepe: Crepe) {
@@ -370,12 +499,14 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
background: hsl(var(--surface-panel));
}
.easyflow-markdown-live-editor__host {
flex: 1;
width: 100%;
min-width: 0;
min-height: 100%;
}
@@ -405,7 +536,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
--crepe-color-on-secondary: hsl(var(--nav-item-active-foreground));
--crepe-color-inverse: hsl(var(--foreground));
--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-hover: hsl(var(--nav-item-hover));
--crepe-color-selected: hsl(var(--nav-item-active));
@@ -431,62 +562,108 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
font-size: 15px;
line-height: 1.76;
color: hsl(var(--text-strong));
caret-color: hsl(var(--primary));
background: transparent;
box-shadow: none;
}
.easyflow-markdown-live-editor :deep(.ProseMirror:focus-visible) {
outline: none;
box-shadow: inset 3px 0 hsl(var(--primary) / 28%);
.easyflow-markdown-live-editor :deep(.ProseMirror::selection),
.easyflow-markdown-live-editor :deep(.ProseMirror *::selection) {
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) {
box-sizing: border-box;
margin-block: var(--space-4);
padding: var(--space-3) var(--space-4);
margin-block: var(--space-4);
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 0;
border-left: 3px solid hsl(var(--primary) / 36%);
border-radius: 0;
border: 1px solid hsl(var(--line-subtle));
border-left: 3px solid hsl(var(--primary) / 58%);
border-radius: var(--radius-control);
transition:
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:focus-visible) {
background: hsl(var(--surface-contrast-soft));
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) {
margin-bottom: var(--space-1);
font-size: 12px;
font-weight: 600;
color: hsl(var(--nav-item-active-foreground));
letter-spacing: 0.02em;
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
margin: 0;
padding: 0;
margin: 0;
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
color: hsl(var(--text-strong));
overflow-wrap: anywhere;
white-space: pre-wrap;
word-break: break-word;
background: transparent;
border: 0;
}
.easyflow-markdown-live-editor :deep(a) {
font-weight: 520;
color: hsl(var(--primary));
text-decoration-thickness: 1px;
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) {
max-width: 100%;
border-radius: var(--radius);
@@ -500,6 +677,20 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
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) {
padding-bottom: var(--space-3);
margin-top: 0;
@@ -528,24 +719,119 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
.easyflow-markdown-live-editor :deep(blockquote) {
padding: var(--space-2) var(--space-4);
color: hsl(var(--text-muted));
color: hsl(var(--foreground));
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);
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-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) {
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
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) {
width: 100%;
border-collapse: collapse;
@@ -563,6 +849,155 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
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)) {
.easyflow-markdown-live-editor :deep(.ProseMirror code),
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
@@ -599,7 +1034,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
:deep(.milkdown-image-block .image-edit .link-importer .placeholder),
.easyflow-markdown-live-editor
:deep(.milkdown-image-inline .link-importer .placeholder) {
color: var(--crepe-color-on-background);
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor
@@ -610,7 +1045,7 @@ function normalizeListSpreadAttributes(crepe: Crepe) {
}
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
background-color: var(--crepe-color-outline);
background-color: hsl(var(--primary));
}
}

View File

@@ -64,4 +64,38 @@ describe('live markdown normalization', () => {
const source = '# Title\n\n<br />\n\nText\n';
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'),
);
});
});

View File

@@ -4,6 +4,7 @@ import { unified } from 'unified';
const markdownParser = unified().use(remarkParse).use(remarkGfm);
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
@@ -25,11 +26,40 @@ export function hasEquivalentMarkdownSemantics(
* changing an explicit HTML break that already exists in the source.
*/
export function normalizeLiveMarkdownUpdate(source: string, markdown: string) {
if (STANDALONE_BREAK_LINE.test(source)) return markdown;
return markdown
.replace(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '\n\n')
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*$/gi, '\n');
const orderedMarkdown = keepFrontmatterFirst(source, markdown);
if (STANDALONE_BREAK_LINE.test(source)) return orderedMarkdown;
return orderedMarkdown
.replaceAll(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
.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) {

View File

@@ -59,7 +59,7 @@ export const remarkFrontmatterMilkdown = $remark(
/**
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
* 序列化时再写回 mdast `yaml` 节点。这样 SKILL.md 的 frontmatter 在实时
* 序列化时再写回 mdast `yaml` 节点。这样 Markdown 文档的 frontmatter 在实时
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
*/
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
@@ -91,9 +91,9 @@ export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
role: 'button',
tabindex: '0',
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],
],
parseMarkdown: {

View File

@@ -1,10 +1,17 @@
export type EditorLanguage =
| 'css'
| 'html'
| 'java'
| 'javascript'
| 'json'
| 'markdown'
| 'python'
| 'shell'
| 'text';
| 'sql'
| 'text'
| 'typescript'
| 'xml'
| 'yaml';
export interface EditorCursorPosition {
column: number;

View File

@@ -164,8 +164,7 @@ onBeforeUnmount(() =>
overflow: hidden;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-panel);
box-shadow: var(--shadow-subtle);
border-radius: var(--radius-toolbar);
}
.document-editor-workbench__tree {
@@ -177,6 +176,7 @@ onBeforeUnmount(() =>
}
.document-editor-workbench__main {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;

View File

@@ -5,13 +5,13 @@ import { computed, ref } from 'vue';
import {
ChevronRight,
Ellipsis,
File,
FileCode2,
FileImage,
FileText,
FolderClosed,
FolderOpen,
Ellipsis,
Plus,
} from '@easyflow/icons';
@@ -69,6 +69,7 @@ function activate() {
emit('focusPath', props.node.path);
if (isDirectory.value) {
expanded.value = !expanded.value;
return;
}
emit('select', props.node);
}

View File

@@ -138,7 +138,7 @@ describe('file tree panel keyboard navigation', () => {
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();
treeItem('references/guide.md')?.dispatchEvent(
@@ -153,10 +153,7 @@ describe('file tree panel keyboard navigation', () => {
);
await nextTick();
expect(treeItem('references')?.getAttribute('aria-expanded')).toBe('false');
expect(wrapper.emitted('select')).toHaveLength(2);
expect(wrapper.emitted('select')?.[1]?.[0]).toMatchObject({
path: 'references',
});
expect(wrapper.emitted('select')).toHaveLength(1);
});
it('emits contextual create and file operations from row actions', async () => {