feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互 - 提供 Markdown 实时编辑、源码与脚本资源工作台 - 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
@@ -13,6 +13,14 @@
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@easyflow-core/editor-ui": "workspace:*",
|
||||
"@easyflow-core/shadcn-ui": "workspace:*",
|
||||
"@easyflow/access": "workspace:*",
|
||||
"@easyflow/common-ui": "workspace:*",
|
||||
@@ -28,13 +36,6 @@
|
||||
"@easyflow/styles": "workspace:*",
|
||||
"@easyflow/types": "workspace:*",
|
||||
"@easyflow/utils": "workspace:*",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tinyflow-ai/vue": "workspace:*",
|
||||
"@ungap/structured-clone": "1.3.0",
|
||||
@@ -55,7 +56,8 @@
|
||||
"vue-element-plus-x": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vue3-json-viewer": "^2.4.1",
|
||||
"wicg-inert": "3.1.3"
|
||||
"wicg-inert": "3.1.3",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
|
||||
@@ -73,6 +73,10 @@ const handleReset = () => {
|
||||
emit('search', '');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
reset: handleReset,
|
||||
});
|
||||
|
||||
const handleButtonClick = (button) => {
|
||||
emitButtonEvent({
|
||||
type: 'button',
|
||||
@@ -133,6 +137,7 @@ const handleDropdownClick = (button) => {
|
||||
:type="button.type || 'default'"
|
||||
:icon="button.icon"
|
||||
:disabled="button.disabled"
|
||||
:loading="button.loading"
|
||||
v-access:code="button.permission"
|
||||
@click="handleButtonClick(button)"
|
||||
>
|
||||
|
||||
28
easyflow-ui-admin/app/src/components/page/PageData.test.ts
Normal file
28
easyflow-ui-admin/app/src/components/page/PageData.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageData from './PageData.vue';
|
||||
|
||||
describe('page data recovery', () => {
|
||||
it('shows a retry action after a page request fails', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce({ data: { records: [], totalRow: 0 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('数据加载失败,请重试');
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.text()).not.toContain('数据加载失败,请重试');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElEmpty, ElPagination } from 'element-plus';
|
||||
import { ElButton, ElEmpty, ElPagination } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
@@ -28,7 +28,9 @@ const props = withDefaults(defineProps<PageDataProps>(), {
|
||||
// 响应式数据
|
||||
const pageList = ref<PageDataRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<unknown>();
|
||||
const queryParams = ref<Record<string, any>>({});
|
||||
let pageRequest = 0;
|
||||
|
||||
const pageInfo = reactive({
|
||||
pageNumber: 1,
|
||||
@@ -38,8 +40,6 @@ const pageInfo = reactive({
|
||||
|
||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||
const doGet = async (params: Record<string, any>) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||
@@ -47,13 +47,13 @@ const doGet = async (params: Record<string, any>) => {
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取页面数据
|
||||
const getPageList = async () => {
|
||||
const request = ++pageRequest;
|
||||
loading.value = true;
|
||||
loadError.value = undefined;
|
||||
try {
|
||||
const res = await doGet({
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
@@ -61,13 +61,19 @@ const getPageList = async () => {
|
||||
...props.extraQueryParams,
|
||||
...queryParams.value,
|
||||
});
|
||||
if (request === pageRequest) {
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get data error:', error);
|
||||
if (request === pageRequest) {
|
||||
loadError.value = error;
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
}
|
||||
} finally {
|
||||
if (request === pageRequest) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 分页事件处理
|
||||
@@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
pageInfo.pageSize = props.pageSize;
|
||||
queryParams.value = newQueryParams;
|
||||
getPageList();
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
@@ -116,7 +121,7 @@ defineExpose({
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value],
|
||||
() => {
|
||||
getPageList();
|
||||
},
|
||||
@@ -150,6 +155,16 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<slot
|
||||
v-else-if="loadError"
|
||||
name="error"
|
||||
:error="loadError"
|
||||
:retry="getPageList"
|
||||
>
|
||||
<ElEmpty description="数据加载失败,请重试">
|
||||
<ElButton type="primary" @click="getPageList">重新加载</ElButton>
|
||||
</ElEmpty>
|
||||
</slot>
|
||||
<slot v-else name="empty">
|
||||
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
|
||||
</slot>
|
||||
|
||||
141
easyflow-ui-admin/app/src/components/page/PageSide.test.ts
Normal file
141
easyflow-ui-admin/app/src/components/page/PageSide.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageSide from './PageSide.vue';
|
||||
|
||||
/* eslint-disable vue/one-component-per-file -- Inline dropdown stubs keep this component test focused. */
|
||||
|
||||
const DropdownStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'dropdown-stub' }, [
|
||||
slots.default?.(),
|
||||
slots.dropdown?.(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const DropdownMenuStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'dropdown-menu-stub' }, slots.default?.());
|
||||
},
|
||||
});
|
||||
const DropdownItemStub = defineComponent({
|
||||
props: { disabled: Boolean },
|
||||
emits: ['click'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'dropdown-item-stub',
|
||||
'data-disabled': String(props.disabled),
|
||||
onClick: () => {
|
||||
if (!props.disabled) emit('click');
|
||||
},
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
describe('page side', () => {
|
||||
it('keeps the existing flat menu selection and footer behavior', async () => {
|
||||
const add = vi.fn();
|
||||
const wrapper = mount(PageSide, {
|
||||
props: {
|
||||
defaultSelected: '',
|
||||
footerButton: { label: '添加', onClick: add },
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{ id: 'docs', name: '文档' },
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('.page-side__item')).toHaveLength(2);
|
||||
expect(wrapper.findAll('.page-side__item')[0]?.classes()).toContain(
|
||||
'is-selected',
|
||||
);
|
||||
|
||||
await wrapper.findAll('.page-side__item')[1]?.trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toMatchObject({ id: 'docs' });
|
||||
|
||||
await wrapper.get('.page-side__footer').trigger('click');
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders nested menus and emits the selected child', async () => {
|
||||
const wrapper = mount(PageSide, {
|
||||
props: {
|
||||
childrenKey: 'children',
|
||||
defaultSelected: '',
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{
|
||||
children: [{ id: 2, name: '平台' }],
|
||||
id: 1,
|
||||
name: '研发',
|
||||
},
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.el-tree-node__content')).toHaveLength(3);
|
||||
const all = wrapper
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('全部'));
|
||||
expect(all?.element.parentElement?.classList).toContain('is-current');
|
||||
const child = wrapper
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('平台'));
|
||||
expect(child).toBeTruthy();
|
||||
await child?.trigger('click');
|
||||
expect(wrapper.emitted('change')?.at(-1)?.[0]).toMatchObject({ id: 2 });
|
||||
});
|
||||
|
||||
it('supports per-node disabled action states', async () => {
|
||||
const edit = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const wrapper = mount(PageSide, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDropdown: DropdownStub,
|
||||
ElDropdownItem: DropdownItemStub,
|
||||
ElDropdownMenu: DropdownMenuStub,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
controlBtns: [
|
||||
{
|
||||
disabled: () => true,
|
||||
label: '编辑',
|
||||
onClick: edit,
|
||||
},
|
||||
{ label: '删除', onClick: remove },
|
||||
],
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{ id: 1, name: '研发' },
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
|
||||
const actions = wrapper.findAll('.dropdown-item-stub');
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]?.attributes('data-disabled')).toBe('true');
|
||||
await actions[0]?.trigger('click');
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
|
||||
await actions[1]?.trigger('click');
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
@@ -14,119 +13,256 @@ import {
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElTree,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
menus: T[];
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
iconSize?: number;
|
||||
controlBtns?: {
|
||||
interface ControlButton<Item> {
|
||||
disabled?: ((item: Item) => boolean) | boolean;
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (_: T) => void;
|
||||
onClick: (item: Item) => void;
|
||||
type?: any;
|
||||
}[];
|
||||
visible?: (item: Item) => boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
childrenKey?: string;
|
||||
controlBtns?: ControlButton<T>[];
|
||||
defaultExpandAll?: boolean;
|
||||
defaultSelected?: number | string;
|
||||
footerButton?: {
|
||||
disabled?: boolean;
|
||||
icon?: any;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
defaultSelected?: string;
|
||||
iconSize?: number;
|
||||
labelKey: string;
|
||||
menus: T[];
|
||||
title?: string;
|
||||
valueKey: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
iconSize: 16,
|
||||
childrenKey: '',
|
||||
controlBtns: () => [],
|
||||
footerButton: undefined,
|
||||
defaultExpandAll: true,
|
||||
defaultSelected: '',
|
||||
footerButton: undefined,
|
||||
iconSize: 16,
|
||||
title: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', item: T): void;
|
||||
}>();
|
||||
const panelWidth = ref(225);
|
||||
const selected = ref<string>(props.defaultSelected ?? '');
|
||||
const hoverId = ref<string>();
|
||||
|
||||
const handleChange = (item: T) => {
|
||||
selected.value = item[props.valueKey];
|
||||
emits('change', item);
|
||||
};
|
||||
// 监听 defaultSelected 的变化
|
||||
watch(
|
||||
() => props.defaultSelected,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
selected.value = newVal;
|
||||
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
|
||||
if (item) {
|
||||
emits('change', item);
|
||||
const panelWidth = 225;
|
||||
const selected = ref<number | string>(props.defaultSelected ?? '');
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const lastEmittedDefault = ref('');
|
||||
const treeProps = computed(() => ({
|
||||
children: props.childrenKey,
|
||||
label: props.labelKey,
|
||||
}));
|
||||
|
||||
function menuValue(item: T) {
|
||||
return item[props.valueKey] as number | string;
|
||||
}
|
||||
|
||||
function normalizedValue(value: unknown) {
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
function findMenu(items: T[], value: number | string): T | undefined {
|
||||
for (const item of items) {
|
||||
if (normalizedValue(menuValue(item)) === normalizedValue(value)) {
|
||||
return item;
|
||||
}
|
||||
if (props.childrenKey) {
|
||||
const children = item[props.childrenKey] as T[] | undefined;
|
||||
const matched = children?.length ? findMenu(children, value) : undefined;
|
||||
if (matched) return matched;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function syncTreeSelection() {
|
||||
if (!props.childrenKey) return;
|
||||
void nextTick(() => treeRef.value?.setCurrentKey(selected.value));
|
||||
}
|
||||
|
||||
function handleChange(item: T) {
|
||||
selected.value = menuValue(item);
|
||||
lastEmittedDefault.value = normalizedValue(selected.value);
|
||||
emits('change', item);
|
||||
syncTreeSelection();
|
||||
}
|
||||
|
||||
function visibleActions(item: T) {
|
||||
return props.controlBtns.filter(
|
||||
(button) => !button.visible || button.visible(item),
|
||||
);
|
||||
}
|
||||
|
||||
function hasActions(item: T) {
|
||||
const value = normalizedValue(menuValue(item));
|
||||
return !['', '0'].includes(value) && visibleActions(item).length > 0;
|
||||
}
|
||||
|
||||
function actionDisabled(button: ControlButton<T>, item: T) {
|
||||
return typeof button.disabled === 'function'
|
||||
? button.disabled(item)
|
||||
: Boolean(button.disabled);
|
||||
}
|
||||
|
||||
function runAction(button: ControlButton<T>, item: T) {
|
||||
if (!actionDisabled(button, item)) button.onClick(item);
|
||||
}
|
||||
|
||||
function isComponent(icon: any) {
|
||||
return typeof icon !== 'string';
|
||||
}
|
||||
|
||||
function isSvgString(icon: any) {
|
||||
if (typeof icon !== 'string') return false;
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.defaultSelected, () => props.menus],
|
||||
([defaultSelected]) => {
|
||||
selected.value = defaultSelected ?? '';
|
||||
syncTreeSelection();
|
||||
if (defaultSelected === '' || defaultSelected === undefined) return;
|
||||
const matched = findMenu(props.menus, defaultSelected);
|
||||
const normalized = normalizedValue(defaultSelected);
|
||||
if (matched && lastEmittedDefault.value !== normalized) {
|
||||
lastEmittedDefault.value = normalized;
|
||||
emits('change', matched);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
const handleMouseEvent = (id?: string) => {
|
||||
if (id === undefined) {
|
||||
setTimeout(() => {
|
||||
hoverId.value = id;
|
||||
}, 200);
|
||||
} else {
|
||||
hoverId.value = id;
|
||||
}
|
||||
};
|
||||
const isComponent = (icon: any) => {
|
||||
return typeof icon !== 'string';
|
||||
};
|
||||
const isSvgString = (icon: any) => {
|
||||
if (typeof icon !== 'string') return false;
|
||||
// 简单判断:是否包含 SVG 根标签
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
|
||||
:style="{ width: `${panelWidth}px` }"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-5 overflow-hidden">
|
||||
<h3 v-if="title && title.length > 0" class="text-base font-medium">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="page-side" :style="{ width: `${panelWidth}px` }">
|
||||
<div class="page-side__main">
|
||||
<h3 v-if="title" class="page-side__title">{{ title }}</h3>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div class="page-side__scroll">
|
||||
<ElTree
|
||||
v-if="childrenKey"
|
||||
ref="treeRef"
|
||||
class="page-side__tree"
|
||||
:data="menus"
|
||||
:node-key="valueKey"
|
||||
:props="treeProps"
|
||||
:current-node-key="selected"
|
||||
:default-expand-all="defaultExpandAll"
|
||||
:expand-on-click-node="false"
|
||||
highlight-current
|
||||
@node-click="handleChange"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="page-side__tree-node">
|
||||
<div class="page-side__label">
|
||||
<div v-if="data.icon" class="page-side__icon">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(data.icon)"
|
||||
v-html="data.icon"
|
||||
class="page-side__svg"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
v-else-if="
|
||||
typeof data.icon === 'string' && !isComponent(data.icon)
|
||||
"
|
||||
:src="data.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="data.icon as Component" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<slot name="label" :item="data">
|
||||
<span class="page-side__label-text">
|
||||
{{ data[labelKey] }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<ElDropdown v-if="hasActions(data)" trigger="click" @click.stop>
|
||||
<ElButton
|
||||
class="page-side__more"
|
||||
:aria-label="`管理${data[labelKey]}`"
|
||||
:icon="MoreFilled"
|
||||
text
|
||||
@click.stop
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="button in visibleActions(data)"
|
||||
:key="button.label"
|
||||
:disabled="actionDisabled(button, data)"
|
||||
@click="runAction(button, data)"
|
||||
>
|
||||
<ElButton
|
||||
:type="button.type"
|
||||
:icon="button.icon"
|
||||
:disabled="actionDisabled(button, data)"
|
||||
link
|
||||
>
|
||||
{{ button.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="item[valueKey]"
|
||||
class="group list-item"
|
||||
:key="menuValue(item)"
|
||||
class="page-side__item"
|
||||
:class="{
|
||||
selected: selected === item[valueKey],
|
||||
'is-selected':
|
||||
normalizedValue(selected) === normalizedValue(menuValue(item)),
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleChange(item)"
|
||||
@keydown.enter.prevent="handleChange(item)"
|
||||
@keydown.space.prevent="handleChange(item)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="ml-[-3px] flex items-center justify-center"
|
||||
>
|
||||
<div class="page-side__label">
|
||||
<div v-if="item.icon" class="page-side__icon">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
class="page-side__svg"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
class="svg-container"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
@@ -137,55 +273,53 @@ const isSvgString = (icon: any) => {
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
objectFit: 'contain',
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" v-bind="$attrs" />
|
||||
<component :is="item.icon as Component" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
<slot name="label" :item="item">
|
||||
<span class="page-side__label-text">
|
||||
{{ item[labelKey] }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
|
||||
|
||||
<ElDropdown v-if="hasActions(item)" trigger="click" @click.stop>
|
||||
<ElButton
|
||||
class="page-side__more"
|
||||
:aria-label="`管理${item[labelKey]}`"
|
||||
:icon="MoreFilled"
|
||||
text
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group-hover:!inline-flex',
|
||||
(!hoverId || item.id !== hoverId) && '!hidden',
|
||||
)
|
||||
"
|
||||
>
|
||||
<ElIcon>
|
||||
<MoreFilled />
|
||||
</ElIcon>
|
||||
</div>
|
||||
/>
|
||||
<template #dropdown>
|
||||
<div
|
||||
@mouseenter="handleMouseEvent(item.id)"
|
||||
@mouseleave="handleMouseEvent()"
|
||||
>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="btn in controlBtns"
|
||||
:key="btn.label"
|
||||
@click="btn.onClick(item)"
|
||||
v-for="button in visibleActions(item)"
|
||||
:key="button.label"
|
||||
:disabled="actionDisabled(button, item)"
|
||||
@click="runAction(button, item)"
|
||||
>
|
||||
<ElButton :type="btn.type" :icon="btn.icon" link>
|
||||
{{ btn.label }}
|
||||
<ElButton
|
||||
:type="button.type"
|
||||
:icon="button.icon"
|
||||
:disabled="actionDisabled(button, item)"
|
||||
link
|
||||
>
|
||||
{{ button.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty
|
||||
v-if="menus.length <= 0"
|
||||
v-if="menus.length === 0"
|
||||
:image="getEmptyStateImageUrl(preferences.theme.mode)"
|
||||
/>
|
||||
</div>
|
||||
@@ -193,9 +327,12 @@ const isSvgString = (icon: any) => {
|
||||
|
||||
<ElButton
|
||||
v-if="footerButton"
|
||||
@click="footerButton.onClick"
|
||||
class="page-side__footer"
|
||||
:disabled="footerButton.disabled"
|
||||
:icon="footerButton.icon"
|
||||
:loading="footerButton.loading"
|
||||
plain
|
||||
@click="footerButton.onClick"
|
||||
>
|
||||
{{ footerButton.label }}
|
||||
</ElButton>
|
||||
@@ -203,34 +340,164 @@ const isSvgString = (icon: any) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
.page-side {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
|
||||
.page-side__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-side__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-side__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-side__item,
|
||||
.page-side__tree-node {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-side__item {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
border-radius: var(--radius-control);
|
||||
outline: none;
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
.page-side__item:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 15%);
|
||||
.page-side__item.is-selected {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.list-item.selected:where(.dark, .dark *) {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background-color: hsl(var(--accent));
|
||||
.page-side__item:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.svg-container :deep(svg) {
|
||||
.page-side__label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-side__label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-side__icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.page-side__icon img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.page-side__svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-side__more {
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.page-side__item:hover .page-side__more,
|
||||
.page-side__item:focus-within .page-side__more,
|
||||
.page-side__tree-node:hover .page-side__more,
|
||||
.page-side__tree-node:focus-within .page-side__more {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.page-side__footer {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.page-side__tree {
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node__content) {
|
||||
height: 40px;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
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);
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node__content:hover) {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.page-side__tree-node {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.page-side__svg :deep(svg) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
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('已保存');
|
||||
});
|
||||
});
|
||||
1359
easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue
Normal file
1359
easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillCategoryFormDialog from './SkillCategoryFormDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSkillCategoryTree: vi.fn(),
|
||||
saveSkillCategory: vi.fn(),
|
||||
updateSkillCategory: 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 () =>
|
||||
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',
|
||||
},
|
||||
'保存',
|
||||
),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('skill category form dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.getSkillCategoryTree.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
categoryName: '研发',
|
||||
id: 1,
|
||||
levelNo: 1,
|
||||
status: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
levelNo: 2,
|
||||
parentId: 1,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.saveSkillCategory.mockResolvedValue({ data: {}, errorCode: 0 });
|
||||
apiMocks.updateSkillCategory.mockResolvedValue({
|
||||
data: {},
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a root category with the compact shared form modal', async () => {
|
||||
const wrapper = mount(SkillCategoryFormDialog, {
|
||||
props: { modelValue: true },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('添加分类');
|
||||
await wrapper.get('input').setValue('内容审核');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.saveSkillCategory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryName: '内容审核',
|
||||
parentId: '',
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
}),
|
||||
);
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]);
|
||||
});
|
||||
|
||||
it('edits a category while retaining its legal parent', async () => {
|
||||
const wrapper = mount(SkillCategoryFormDialog, {
|
||||
props: {
|
||||
category: {
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
levelNo: 2,
|
||||
parentId: 1,
|
||||
sortNo: 5,
|
||||
status: 1,
|
||||
},
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('编辑分类');
|
||||
await wrapper.get('input').setValue('平台工具');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.updateSkillCategory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryName: '平台工具',
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
sortNo: 5,
|
||||
}),
|
||||
);
|
||||
expect(apiMocks.saveSkillCategory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCategory, SkillCategoryDraft } from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getSkillCategoryTree,
|
||||
saveSkillCategory,
|
||||
updateSkillCategory,
|
||||
} from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { getSkillCategoryParentOptions } from './skill-category';
|
||||
|
||||
const props = defineProps<{
|
||||
category?: SkillCategory;
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const categories = ref<SkillCategory[]>([]);
|
||||
const categoriesLoading = ref(false);
|
||||
const saveLoading = ref(false);
|
||||
const loadError = ref('');
|
||||
const actionError = ref('');
|
||||
const form = reactive<SkillCategoryDraft>({
|
||||
categoryName: '',
|
||||
parentId: '',
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saveLoading.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const parentOptions = computed(() =>
|
||||
getSkillCategoryParentOptions(categories.value, form.id),
|
||||
);
|
||||
const dialogTitle = computed(() => (form.id ? '编辑分类' : '添加分类'));
|
||||
const formRules: FormRules = {
|
||||
categoryName: [
|
||||
{ message: '请输入分类名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: '分类名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
categoryName: props.category?.categoryName || '',
|
||||
id: props.category?.id,
|
||||
parentId: props.category?.parentId ?? '',
|
||||
sortNo: props.category?.sortNo ?? 0,
|
||||
status: props.category?.status === 0 ? 0 : 1,
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
if (categoriesLoading.value) return;
|
||||
categoriesLoading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const res = await getSkillCategoryTree();
|
||||
if (res.errorCode !== 0) {
|
||||
loadError.value = res.message || '分类加载失败,请重试';
|
||||
return;
|
||||
}
|
||||
categories.value = res.data || [];
|
||||
} catch (error) {
|
||||
loadError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'分类加载失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
categoriesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCategory() {
|
||||
if (saveLoading.value || categoriesLoading.value) return;
|
||||
form.categoryName = form.categoryName.trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saveLoading.value = true;
|
||||
actionError.value = '';
|
||||
const editing = Boolean(form.id);
|
||||
try {
|
||||
const res = editing
|
||||
? await updateSkillCategory(form)
|
||||
: await saveSkillCategory(form);
|
||||
if (res.errorCode !== 0) {
|
||||
actionError.value = res.message || '分类保存失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success(editing ? '分类已更新' : '分类已创建');
|
||||
emit('update:modelValue', false);
|
||||
emit('saved');
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'分类保存失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saveLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (!visible) return;
|
||||
resetForm();
|
||||
void loadCategories();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.category,
|
||||
() => {
|
||||
if (props.modelValue) resetForm();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saveLoading"
|
||||
:confirm-loading="saveLoading"
|
||||
confirm-text="保存"
|
||||
:submitting="saveLoading || categoriesLoading"
|
||||
:title="dialogTitle"
|
||||
@confirm="submitCategory"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-category-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="loadError"
|
||||
class="skill-category-form__alert"
|
||||
:closable="false"
|
||||
:title="loadError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
class="skill-category-form__alert"
|
||||
:closable="false"
|
||||
:title="actionError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<ElFormItem label="分类名称" prop="categoryName">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.categoryName"
|
||||
maxlength="128"
|
||||
placeholder="请输入分类名称"
|
||||
show-word-limit
|
||||
@keyup.enter="submitCategory"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="上级分类">
|
||||
<ElSelect
|
||||
v-model="form.parentId"
|
||||
clearable
|
||||
:loading="categoriesLoading"
|
||||
placeholder="无(根分类)"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in parentOptions"
|
||||
:key="option.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(option.depth)}${option.categoryName}`"
|
||||
:value="option.id"
|
||||
:disabled="option.status === 0"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序">
|
||||
<ElInputNumber
|
||||
v-model="form.sortNo"
|
||||
:max="999_999"
|
||||
:min="-999_999"
|
||||
controls-position="right"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-category-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-category-form :deep(.el-select),
|
||||
.skill-category-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
235
easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue
Normal file
235
easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCreateIntent } from './skill-create';
|
||||
import type { SkillCategory, SkillInfo } from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import { saveSkill } from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
import { buildInitialSkillDraft } from './skill-create';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canPublish?: boolean;
|
||||
categories: SkillCategory[];
|
||||
categoriesLoading?: boolean;
|
||||
defaultCategoryId?: number | string;
|
||||
modelValue: boolean;
|
||||
}>(),
|
||||
{
|
||||
canPublish: false,
|
||||
categoriesLoading: false,
|
||||
defaultCategoryId: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [payload: { intent: SkillCreateIntent; skill: SkillInfo }];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const saving = ref(false);
|
||||
const actionError = ref('');
|
||||
const form = reactive({
|
||||
categoryId: '' as number | string,
|
||||
displayName: '',
|
||||
intent: 'DRAFT' as SkillCreateIntent,
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saving.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const categoryOptions = computed(() =>
|
||||
flattenSkillCategories(props.categories),
|
||||
);
|
||||
const rules: FormRules = {
|
||||
displayName: [
|
||||
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: 'Skill 名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
categoryId: props.defaultCategoryId || '',
|
||||
displayName: '',
|
||||
intent: 'DRAFT',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || props.categoriesLoading) return;
|
||||
form.displayName = form.displayName.trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saving.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await saveSkill(
|
||||
buildInitialSkillDraft({
|
||||
categoryId: form.categoryId || undefined,
|
||||
displayName: form.displayName,
|
||||
visibilityScope: form.visibilityScope,
|
||||
}),
|
||||
);
|
||||
if (response.errorCode !== 0 || !response.data?.id) {
|
||||
actionError.value = response.message || 'Skill 创建失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success('Skill 已创建');
|
||||
emit('update:modelValue', false);
|
||||
emit('created', { intent: form.intent, skill: response.data });
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'Skill 创建失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) resetForm();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saving"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="创建并进入详情"
|
||||
:submitting="saving || categoriesLoading"
|
||||
title="新建 Skill"
|
||||
@confirm="submit"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-create-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
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">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.displayName"
|
||||
maxlength="128"
|
||||
placeholder="请输入 Skill 名称"
|
||||
show-word-limit
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="分类">
|
||||
<ElSelect
|
||||
v-model="form.categoryId"
|
||||
clearable
|
||||
:loading="categoriesLoading"
|
||||
placeholder="未分类"
|
||||
>
|
||||
<ElOption
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 1"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="可见范围">
|
||||
<ElSelect v-model="form.visibilityScope">
|
||||
<ElOption label="仅自己" value="PRIVATE" />
|
||||
<ElOption label="本部门" value="DEPT" />
|
||||
<ElOption label="全员可见" value="PUBLIC" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="发布状态">
|
||||
<div class="skill-create-form__publish-field">
|
||||
<ElRadioGroup v-model="form.intent">
|
||||
<ElRadioButton value="DRAFT">草稿</ElRadioButton>
|
||||
<ElRadioButton value="PUBLISH" :disabled="!canPublish">
|
||||
创建后发布
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
<span v-if="form.intent === 'PUBLISH'">
|
||||
进入详情完善内容,通过校验后提交发布。
|
||||
</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-create-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-create-form__publish-field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.skill-create-form__publish-field > span {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
</style>
|
||||
449
easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts
Normal file
449
easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
/* 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 { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillDetail from './SkillDetail.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSkillCategories: vi.fn(),
|
||||
getSkillDetail: vi.fn(),
|
||||
saveSkill: vi.fn(),
|
||||
submitSkillDeleteApproval: vi.fn(),
|
||||
submitSkillOfflineApproval: vi.fn(),
|
||||
submitSkillPublishApproval: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
const childMocks = vi.hoisted(() => ({
|
||||
capabilityHasDirty: vi.fn(() => false),
|
||||
capabilitySave: vi.fn(),
|
||||
resourceSave: vi.fn(async () => true),
|
||||
resourceValidate: vi.fn(),
|
||||
}));
|
||||
const permissionMocks = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn((_permissions: string[]) => true),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('vue-router')>();
|
||||
return {
|
||||
...original,
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
onBeforeRouteUpdate: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('./api', () => apiMocks);
|
||||
vi.mock('#/api/common/hasPermission', () => ({
|
||||
hasPermission: permissionMocks.hasPermission,
|
||||
}));
|
||||
vi.mock('./SkillResourceWorkbench.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillResourceWorkbench',
|
||||
props: {
|
||||
activePanel: { default: 'resources', type: String },
|
||||
capabilityAvailable: Boolean,
|
||||
readonly: Boolean,
|
||||
},
|
||||
emits: [
|
||||
'dirty',
|
||||
'issues',
|
||||
'locateCapability',
|
||||
'newContent',
|
||||
'requestSave',
|
||||
'update:activePanel',
|
||||
],
|
||||
setup(props, { expose, slots }) {
|
||||
expose({
|
||||
getSkillContent: () => undefined,
|
||||
saveAll: childMocks.resourceSave,
|
||||
validateAll: childMocks.resourceValidate,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
'data-readonly': String(props.readonly),
|
||||
'data-testid': 'resource-workbench',
|
||||
},
|
||||
slots.capability?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillSettingsDialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillSettingsDialog',
|
||||
props: {
|
||||
categoryLoadError: { default: '', type: String },
|
||||
modelValue: Boolean,
|
||||
},
|
||||
emits: ['retryCategories', 'saved', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
props.modelValue
|
||||
? h('div', { 'data-testid': 'settings-dialog' }, [
|
||||
props.categoryLoadError,
|
||||
h('button', { onClick: () => emit('retryCategories') }, '重试'),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillCapabilityPanel.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillCapabilityPanel',
|
||||
props: { locked: Boolean, readonly: Boolean },
|
||||
setup(props, { expose }) {
|
||||
expose({
|
||||
flushPending: childMocks.capabilitySave,
|
||||
hasDirty: childMocks.capabilityHasDirty,
|
||||
});
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-locked': String(props.locked),
|
||||
'data-testid': 'capability-panel',
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let router: Router;
|
||||
|
||||
async function mountDetail(id = '101', navTitle?: string) {
|
||||
router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ component: { template: '<div />' }, path: '/ai/skill' },
|
||||
{
|
||||
component: { template: '<div />' },
|
||||
path: '/ai/skill/detail/:id',
|
||||
},
|
||||
],
|
||||
});
|
||||
await router.push({
|
||||
path: `/ai/skill/detail/${id}`,
|
||||
query: navTitle ? { navTitle } : undefined,
|
||||
});
|
||||
await router.isReady();
|
||||
const wrapper = mount(SkillDetail, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function publishButton(wrapper: ReturnType<typeof mount>) {
|
||||
const button = wrapper
|
||||
.findAll('button')
|
||||
.find((candidate) => candidate.text().trim() === '发布');
|
||||
if (!button) throw new Error('未找到发布按钮');
|
||||
return button;
|
||||
}
|
||||
|
||||
function markResourceDirty(wrapper: ReturnType<typeof mount>) {
|
||||
wrapper
|
||||
.getComponent({ name: 'SkillResourceWorkbench' })
|
||||
.vm.$emit('dirty', true);
|
||||
}
|
||||
|
||||
describe('skill detail publish transaction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
childMocks.capabilityHasDirty.mockReset().mockReturnValue(false);
|
||||
childMocks.capabilitySave.mockReset().mockResolvedValue(true);
|
||||
childMocks.resourceSave.mockReset().mockResolvedValue(true);
|
||||
childMocks.resourceValidate.mockReset().mockResolvedValue(true);
|
||||
permissionMocks.hasPermission.mockReset().mockReturnValue(true);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
apiMocks.getSkillCategories.mockResolvedValue({ data: [], errorCode: 0 });
|
||||
apiMocks.getSkillDetail.mockResolvedValue({
|
||||
data: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
description: 'Demonstration skill',
|
||||
displayName: '演示 Skill',
|
||||
displayPublishStatus: 'DRAFT',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
manageable: true,
|
||||
name: 'demo-skill',
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent:
|
||||
'---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n',
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('locks editors and follows validate, confirm, submit order', async () => {
|
||||
const calls: string[] = [];
|
||||
childMocks.resourceSave.mockImplementation(async () => {
|
||||
calls.push('save');
|
||||
return true;
|
||||
});
|
||||
let resolveValidation: ((value: boolean) => void) | undefined;
|
||||
let resolveSubmit:
|
||||
| ((value: { data: number; errorCode: number; message: string }) => void)
|
||||
| undefined;
|
||||
childMocks.resourceValidate.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
calls.push('validate');
|
||||
resolveValidation = resolve;
|
||||
}),
|
||||
);
|
||||
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;
|
||||
}),
|
||||
);
|
||||
const wrapper = await mountDetail();
|
||||
markResourceDirty(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
await publishButton(wrapper).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toEqual(['save', 'validate']);
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="resource-workbench"]')
|
||||
.attributes('data-readonly'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'),
|
||||
).toBe('true');
|
||||
|
||||
resolveValidation?.(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toEqual(['save', 'validate', 'confirm', 'submit']);
|
||||
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
|
||||
expect(childMocks.resourceValidate).toHaveBeenCalledWith();
|
||||
|
||||
resolveSubmit?.({ data: 9001, errorCode: 0, message: '已提交' });
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="resource-workbench"]')
|
||||
.attributes('data-readonly'),
|
||||
).toBe('false');
|
||||
expect(
|
||||
wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'),
|
||||
).toBe('false');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not confirm or submit when publish validation fails', async () => {
|
||||
childMocks.resourceValidate.mockResolvedValue(false);
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm');
|
||||
const wrapper = await mountDetail();
|
||||
|
||||
await publishButton(wrapper).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(childMocks.resourceValidate).toHaveBeenCalledWith();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('stops before validation when saving dirty resources fails', async () => {
|
||||
childMocks.resourceSave.mockResolvedValue(false);
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm');
|
||||
const wrapper = await mountDetail();
|
||||
markResourceDirty(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
await publishButton(wrapper).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
|
||||
expect(childMocks.resourceValidate).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('uses one resource and capability workspace and opens settings on demand', async () => {
|
||||
const wrapper = await mountDetail();
|
||||
const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' });
|
||||
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0);
|
||||
expect(workbench.props('activePanel')).toBe('resources');
|
||||
expect(workbench.props('capabilityAvailable')).toBe(true);
|
||||
expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
216
easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
Normal file
216
easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createSkillFile: vi.fn(),
|
||||
deleteSkillFile: vi.fn(),
|
||||
downloadSkillFile: vi.fn(),
|
||||
getSkillFileContent: vi.fn(),
|
||||
getSkillFileTree: vi.fn(),
|
||||
previewSkillFile: vi.fn(),
|
||||
renameSkillFile: vi.fn(),
|
||||
saveSkillFile: vi.fn(),
|
||||
uploadSkillFile: vi.fn(),
|
||||
validateSkillForPublish: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
|
||||
const resources = [
|
||||
{
|
||||
isText: false,
|
||||
key: 'examples/archive.bin',
|
||||
mediaType: 'application/octet-stream',
|
||||
name: 'archive.bin',
|
||||
path: 'examples/archive.bin',
|
||||
size: 2048,
|
||||
type: 'EXAMPLE',
|
||||
},
|
||||
{
|
||||
isText: false,
|
||||
key: 'resources/diagram.png',
|
||||
mediaType: 'image/png',
|
||||
name: 'diagram.png',
|
||||
path: 'resources/diagram.png',
|
||||
size: 4096,
|
||||
type: 'OTHER',
|
||||
},
|
||||
{
|
||||
isText: false,
|
||||
key: 'references/manual.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
name: 'manual.pdf',
|
||||
path: 'references/manual.pdf',
|
||||
size: 8192,
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
{
|
||||
content: '{"ok":true}\n',
|
||||
isText: true,
|
||||
key: 'references/data.json',
|
||||
language: undefined,
|
||||
mediaType: 'application/json',
|
||||
name: 'data.json',
|
||||
path: 'references/data.json',
|
||||
size: 12,
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
];
|
||||
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
async function mountAndSelect(path: string, readonly = false) {
|
||||
wrapper = mount(SkillResourceWorkbench, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: {
|
||||
CodeEditor: {
|
||||
props: ['language'],
|
||||
template:
|
||||
'<div data-testid="code-editor" :data-language="language"></div>',
|
||||
},
|
||||
CodeViewer: {
|
||||
props: ['filename', 'language'],
|
||||
template:
|
||||
'<div data-testid="code-viewer" :data-filename="filename" :data-language="language"><button>复制</button><button>下载</button></div>',
|
||||
},
|
||||
MarkdownLiveEditor: {
|
||||
template: '<div data-testid="markdown-live-editor"></div>',
|
||||
},
|
||||
MarkdownSourceEditor: {
|
||||
template: '<div data-testid="markdown-source-editor"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: { readonly, skillId: 101 },
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.get(`[data-tree-path="${path}"]`).trigger('click');
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function toolbarButton(label: string) {
|
||||
return wrapper?.find(`button[aria-label="${label}"]`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:skill-preview'),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
apiMocks.getSkillFileTree.mockResolvedValue({
|
||||
data: [
|
||||
{ key: 'SKILL.md', name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
|
||||
...resources,
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.getSkillFileContent.mockImplementation(
|
||||
(_skillId: number, path: string) => {
|
||||
if (path === 'SKILL.md') {
|
||||
return Promise.resolve({ data: undefined, errorCode: 1 });
|
||||
}
|
||||
const file = resources.find((item) => item.path === path);
|
||||
return Promise.resolve({
|
||||
data: { ...file, content: '', contentHash: 'a'.repeat(64) },
|
||||
errorCode: file ? 0 : 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('skill resource workbench binary resources', () => {
|
||||
it('routes a JSON reference to the code editor instead of Markdown live mode', async () => {
|
||||
await mountAndSelect('references/data.json');
|
||||
|
||||
expect(wrapper?.find('[data-testid="code-editor"]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper?.find('[data-testid="code-editor"]').attributes('data-language'),
|
||||
).toBe('json');
|
||||
expect(wrapper?.find('[data-testid="markdown-live-editor"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('gives readonly generic text the shared copy and download viewer', async () => {
|
||||
await mountAndSelect('references/data.json', true);
|
||||
|
||||
expect(wrapper?.find('[data-testid="code-editor"]').exists()).toBe(false);
|
||||
const viewer = wrapper?.get('[data-testid="code-viewer"]');
|
||||
expect(viewer?.attributes('data-filename')).toBe('references/data.json');
|
||||
expect(viewer?.attributes('data-language')).toBe('json');
|
||||
expect(viewer?.text()).toContain('复制');
|
||||
expect(viewer?.text()).toContain('下载');
|
||||
});
|
||||
|
||||
it('renders generic EXAMPLE binaries with metadata and only meaningful actions', async () => {
|
||||
await mountAndSelect('examples/archive.bin');
|
||||
|
||||
expect(wrapper?.text()).toContain('application/octet-stream');
|
||||
expect(wrapper?.text()).toContain('2.0 KB');
|
||||
expect(wrapper?.text()).toContain('此格式不支持在线预览');
|
||||
expect(toolbarButton('搜索')?.exists()).toBe(false);
|
||||
expect(toolbarButton('切换换行')?.exists()).toBe(false);
|
||||
expect(
|
||||
wrapper?.findAll('button').some((button) => button.text() === '保存'),
|
||||
).toBe(false);
|
||||
expect(toolbarButton('进入全屏')?.exists()).toBe(true);
|
||||
expect(toolbarButton('重命名文件')?.exists()).toBe(false);
|
||||
expect(toolbarButton('删除文件')?.exists()).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
?.get('[data-tree-path="examples/archive.bin"]')
|
||||
.element.parentElement?.querySelector('[aria-label="更多操作"]'),
|
||||
).not.toBeNull();
|
||||
expect(toolbarButton('下载资源')?.exists()).toBe(true);
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset button').text(),
|
||||
).toBe('下载');
|
||||
expect(apiMocks.previewSkillFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('previews an OTHER image only after the response MIME type is verified', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['image'], { type: 'image/png' }),
|
||||
);
|
||||
|
||||
await mountAndSelect('resources/diagram.png');
|
||||
|
||||
expect(apiMocks.previewSkillFile).toHaveBeenCalledWith(
|
||||
101,
|
||||
'resources/diagram.png',
|
||||
);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-preview').exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-image').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a sandboxed PDF preview for any binary resource kind', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['pdf'], { type: 'application/pdf' }),
|
||||
);
|
||||
|
||||
await mountAndSelect('references/manual.pdf');
|
||||
|
||||
const iframe = wrapper?.get('iframe');
|
||||
expect(iframe?.attributes('src')).toBe('blob:skill-preview');
|
||||
expect(iframe?.attributes('sandbox')).toBe('');
|
||||
expect(iframe?.attributes('referrerpolicy')).toBe('no-referrer');
|
||||
expect(iframe?.attributes('title')).toContain('PDF 预览');
|
||||
});
|
||||
|
||||
it('rejects a preview response whose MIME type does not match the safe renderer', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['<html></html>'], { type: 'text/html' }),
|
||||
);
|
||||
|
||||
await mountAndSelect('resources/diagram.png');
|
||||
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-image').exists(),
|
||||
).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
expect(wrapper?.text()).toContain('可下载后使用本地应用打开');
|
||||
});
|
||||
|
||||
it('falls back to download when an image renderer rejects valid-MIME content', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['broken'], { type: 'image/png' }),
|
||||
);
|
||||
await mountAndSelect('resources/diagram.png');
|
||||
|
||||
await wrapper
|
||||
?.get('.skill-resource-workbench__asset-image')
|
||||
.trigger('error');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-image').exists(),
|
||||
).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
expect(wrapper?.text()).toContain('可下载后使用本地应用打开');
|
||||
});
|
||||
|
||||
it('falls back to download when the PDF iframe reports a render error', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['broken'], { type: 'application/pdf' }),
|
||||
);
|
||||
await mountAndSelect('references/manual.pdf');
|
||||
|
||||
await wrapper?.get('iframe').trigger('error');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper?.find('iframe').exists()).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
});
|
||||
|
||||
it('falls back when the PDF renderer never reports readiness', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['pdf'], { type: 'application/pdf' }),
|
||||
);
|
||||
await mountAndSelect('references/manual.pdf');
|
||||
|
||||
vi.advanceTimersByTime(8001);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper?.find('iframe').exists()).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill resource workbench validation', () => {
|
||||
it('uses the publish preflight endpoint for publish validation', async () => {
|
||||
apiMocks.validateSkillForPublish.mockResolvedValue({
|
||||
data: { issues: [], valid: true },
|
||||
errorCode: 0,
|
||||
});
|
||||
wrapper = mount(SkillResourceWorkbench, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: {
|
||||
MarkdownLiveEditor: {
|
||||
template: '<div data-testid="markdown-live-editor"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: { skillId: 101 },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const valid = await (
|
||||
wrapper.vm as unknown as {
|
||||
validateAll: () => Promise<boolean>;
|
||||
}
|
||||
).validateAll();
|
||||
|
||||
expect(valid).toBe(true);
|
||||
expect(apiMocks.validateSkillForPublish).toHaveBeenCalledWith(101);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill resource workbench navigation', () => {
|
||||
it('shows the standard directories and opens a directory-aware create form', async () => {
|
||||
await mountAndSelect('references');
|
||||
|
||||
expect(wrapper?.find('[data-tree-path="scripts"]').exists()).toBe(true);
|
||||
expect(wrapper?.find('[data-tree-path="assets"]').exists()).toBe(true);
|
||||
expect(wrapper?.text()).toContain('此目录暂无内容');
|
||||
|
||||
const createButton = wrapper
|
||||
?.findAll('.skill-resource-workbench__directory-state button')
|
||||
.find((button) => button.text().trim() === '新建文件');
|
||||
await createButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
?.findAllComponents({ name: 'ElSelect' })
|
||||
.some((select) => select.props('modelValue') === 'references'),
|
||||
).toBe(true);
|
||||
const fileNameInput = wrapper
|
||||
?.findAllComponents({ name: 'ElInput' })
|
||||
.find((input) => input.props('placeholder') === '例如 guide.md');
|
||||
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();
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
1881
easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
Normal file
1881
easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillSettingsDialog from './SkillSettingsDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
updateSkill: 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', [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'form-modal-confirm',
|
||||
disabled: props.submitting,
|
||||
onClick: () => emit('confirm'),
|
||||
type: 'button',
|
||||
},
|
||||
'保存',
|
||||
),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('skill settings dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.updateSkill.mockResolvedValue({
|
||||
data: { displayName: '更新后的 Skill', id: 101 },
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only management settings through the shared compact modal', async () => {
|
||||
const wrapper = mount(SkillSettingsDialog, {
|
||||
props: {
|
||||
categories: [{ categoryName: '研发', id: 8, status: 1 }],
|
||||
modelValue: true,
|
||||
skill: {
|
||||
categoryId: 8,
|
||||
displayName: '原 Skill',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('编辑基本信息');
|
||||
await wrapper.get('input').setValue('更新后的 Skill');
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' });
|
||||
selects[1]?.vm.$emit('update:modelValue', 'DEPT');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.updateSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 8,
|
||||
displayName: '更新后的 Skill',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
visibilityScope: 'DEPT',
|
||||
}),
|
||||
);
|
||||
expect(wrapper.emitted('saved')?.[0]?.[0]).toMatchObject({ id: 101 });
|
||||
});
|
||||
|
||||
it('keeps category loading failure visible and retryable', async () => {
|
||||
const wrapper = mount(SkillSettingsDialog, {
|
||||
props: {
|
||||
categories: [],
|
||||
categoryLoadError: '分类加载失败,请重试',
|
||||
modelValue: true,
|
||||
skill: { displayName: '原 Skill', id: 101 },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('重新加载');
|
||||
const retry = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '重新加载');
|
||||
await retry?.trigger('click');
|
||||
expect(wrapper.emitted('retryCategories')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
241
easyflow-ui-admin/app/src/views/ai/skill/SkillSettingsDialog.vue
Normal file
241
easyflow-ui-admin/app/src/views/ai/skill/SkillSettingsDialog.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCategory, SkillInfo } from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import { updateSkill } from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: SkillCategory[];
|
||||
categoriesLoading?: boolean;
|
||||
categoryLoadError?: string;
|
||||
modelValue: boolean;
|
||||
skill: SkillInfo;
|
||||
}>(),
|
||||
{ categoriesLoading: false, categoryLoadError: '' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
retryCategories: [];
|
||||
saved: [skill: SkillInfo];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const saving = ref(false);
|
||||
const actionError = ref('');
|
||||
const form = reactive<SkillInfo>({});
|
||||
const categoryOptions = computed(() =>
|
||||
flattenSkillCategories(props.categories),
|
||||
);
|
||||
const currentCategoryAvailable = computed(() =>
|
||||
categoryOptions.value.some(
|
||||
(category) => String(category.id) === String(form.categoryId),
|
||||
),
|
||||
);
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saving.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const rules: FormRules = {
|
||||
displayName: [
|
||||
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: 'Skill 名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
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',
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || props.categoriesLoading) return;
|
||||
form.displayName = String(form.displayName || '').trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saving.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await updateSkill(form);
|
||||
if (response.errorCode !== 0) {
|
||||
actionError.value = response.message || '设置保存失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success('基本信息已保存');
|
||||
emit('update:modelValue', false);
|
||||
emit('saved', response.data);
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'设置保存失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) resetForm();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saving"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="保存"
|
||||
:submitting="saving || categoriesLoading"
|
||||
title="编辑基本信息"
|
||||
@confirm="submit"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-settings-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
class="skill-settings-form__alert"
|
||||
:closable="false"
|
||||
:title="actionError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<ElFormItem label="Skill 名称" prop="displayName">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.displayName"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="分类">
|
||||
<div class="skill-settings-form__category-field">
|
||||
<ElSelect
|
||||
v-model="form.categoryId"
|
||||
clearable
|
||||
:disabled="Boolean(categoryLoadError)"
|
||||
:loading="categoriesLoading"
|
||||
:placeholder="categoryLoadError ? '分类加载失败' : '未分类'"
|
||||
>
|
||||
<ElOption
|
||||
v-if="form.categoryId && !currentCategoryAvailable"
|
||||
:label="form.categoryName || '当前分类不可用'"
|
||||
:value="form.categoryId"
|
||||
disabled
|
||||
/>
|
||||
<ElOption
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 1"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
v-if="categoryLoadError"
|
||||
link
|
||||
type="primary"
|
||||
:loading="categoriesLoading"
|
||||
@click="emit('retryCategories')"
|
||||
>
|
||||
重新加载
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="可见范围">
|
||||
<ElSelect v-model="form.visibilityScope">
|
||||
<ElOption label="仅自己" value="PRIVATE" />
|
||||
<ElOption label="本部门" value="DEPT" />
|
||||
<ElOption label="全员可见" value="PUBLIC" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="启用状态">
|
||||
<div class="skill-settings-form__switch">
|
||||
<ElSwitch v-model="form.enabled" />
|
||||
<span>{{ form.enabled === false ? '停用' : '启用' }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-settings-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-settings-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-settings-form__category-field,
|
||||
.skill-settings-form__switch {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-settings-form__category-field :deep(.el-select) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-settings-form__switch {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
</style>
|
||||
@@ -1,49 +1,114 @@
|
||||
import type {
|
||||
RequestResult,
|
||||
SkillCapabilityBinding,
|
||||
SkillCapabilityCandidate,
|
||||
SkillCapabilityReplaceResult,
|
||||
SkillCapabilityTools,
|
||||
SkillCapabilityType,
|
||||
SkillCategory,
|
||||
SkillCategoryDraft,
|
||||
SkillExportFormat,
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillValidationResult,
|
||||
} from './types';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
import { buildCapabilityBindingsPayload } from './skill-capability';
|
||||
import { buildSkillCategoryPayload } from './skill-category';
|
||||
import { buildSkillDraftPayload } from './skill-draft';
|
||||
|
||||
export function getSkillDetail(id: number | string) {
|
||||
return api.get<RequestResult<SkillInfo>>('/api/v1/skill/getDetail', {
|
||||
return api.get<RequestResult<SkillInfo>>('/api/v1/skill/detail', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/save', skill);
|
||||
return api.post<RequestResult<SkillInfo>>(
|
||||
'/api/v1/skill/save',
|
||||
buildSkillDraftPayload(skill, false),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/update', skill);
|
||||
return api.post<RequestResult<SkillInfo>>(
|
||||
'/api/v1/skill/update',
|
||||
buildSkillDraftPayload(skill, true),
|
||||
);
|
||||
}
|
||||
|
||||
export function copySkill(payload: {
|
||||
categoryId?: number | string;
|
||||
displayName: string;
|
||||
name: string;
|
||||
sourceId: number | string;
|
||||
}) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/copy', payload);
|
||||
}
|
||||
|
||||
export function validateSkillForPublish(id: number | string) {
|
||||
return api.post<RequestResult<SkillValidationResult>>(
|
||||
'/api/v1/skill/validatePublish',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCategories() {
|
||||
return api.get<RequestResult<any[]>>('/api/v1/skillCategory/visibleList', {
|
||||
return api.get<RequestResult<SkillCategory[]>>(
|
||||
'/api/v1/skill/category/visibleList',
|
||||
{
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCategoryTree() {
|
||||
return api.get<RequestResult<SkillCategory[]>>(
|
||||
'/api/v1/skill/category/tree',
|
||||
{
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveSkillCategory(category: SkillCategoryDraft) {
|
||||
return api.post<RequestResult<SkillCategory>>(
|
||||
'/api/v1/skill/category/save',
|
||||
buildSkillCategoryPayload(category, false),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSkillCategory(category: SkillCategoryDraft) {
|
||||
return api.post<RequestResult<SkillCategory>>(
|
||||
'/api/v1/skill/category/update',
|
||||
buildSkillCategoryPayload(category, true),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSkillCategory(id: number | string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/category/remove', { id });
|
||||
}
|
||||
|
||||
export function submitSkillPublishApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitPublishApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function submitSkillOfflineApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitOfflineApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function submitSkillDeleteApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitDeleteApproval',
|
||||
{ id },
|
||||
);
|
||||
@@ -56,23 +121,146 @@ 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: { skillId, path },
|
||||
});
|
||||
return api.get<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/content',
|
||||
{
|
||||
params: { path, skillId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
content: string,
|
||||
expectedContentHash?: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>('/api/v1/skill/file/save', {
|
||||
skillId,
|
||||
path,
|
||||
content,
|
||||
expectedContentHash,
|
||||
path,
|
||||
skillId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
content = '',
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/create',
|
||||
{
|
||||
content,
|
||||
path,
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function renameSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
newPath: string,
|
||||
expectedContentHash: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/rename',
|
||||
{
|
||||
expectedContentHash,
|
||||
newPath,
|
||||
path,
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
expectedContentHash: string,
|
||||
) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/file/delete', {
|
||||
expectedContentHash,
|
||||
path,
|
||||
skillId,
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
file: File,
|
||||
expectedContentHash?: string,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('skillId', String(skillId));
|
||||
formData.append('path', path);
|
||||
if (expectedContentHash) {
|
||||
formData.append('expectedContentHash', expectedContentHash);
|
||||
}
|
||||
formData.append('file', file);
|
||||
return api.postFile<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/upload',
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadSkillFile(skillId: number | string, path: string) {
|
||||
return api.download<Blob>('/api/v1/skill/file/download', {
|
||||
params: { path, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export function previewSkillFile(skillId: number | string, path: string) {
|
||||
return api.download<Blob>('/api/v1/skill/file/preview', {
|
||||
params: { path, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -82,25 +270,35 @@ export function importSkillPreview(file: File) {
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillConfirm(
|
||||
file: File,
|
||||
categoryId?: number | string,
|
||||
overwriteDraft = false,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (categoryId) {
|
||||
formData.append('categoryId', String(categoryId));
|
||||
}
|
||||
formData.append('overwriteDraft', String(overwriteDraft));
|
||||
return api.postFile<RequestResult<SkillInfo[]>>(
|
||||
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',
|
||||
formData,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSkillAssetUrl(skillId: number | string, path: string) {
|
||||
return `/api/v1/skill/file/asset?skillId=${encodeURIComponent(
|
||||
String(skillId),
|
||||
)}&path=${encodeURIComponent(path)}`;
|
||||
export function cancelSkillImport(importToken: string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/import/cancel', {
|
||||
importToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function exportSkills(
|
||||
ids: Array<number | string>,
|
||||
format: SkillExportFormat,
|
||||
) {
|
||||
return api.download<Blob>('/api/v1/skill/export', {
|
||||
data: { format, ids },
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isSkillAccessDeniedError,
|
||||
isSkillCapabilityConflictError,
|
||||
isSkillFileConflictError,
|
||||
resolveSkillApiErrorMessage,
|
||||
} from './skill-api-error';
|
||||
|
||||
describe('skill API conflict errors', () => {
|
||||
it('recognizes response bodies thrown directly by the request client', () => {
|
||||
expect(
|
||||
isSkillFileConflictError({
|
||||
errorCode: 4091,
|
||||
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);
|
||||
});
|
||||
|
||||
it('recognizes direct and Axios-shaped access denials', () => {
|
||||
expect(isSkillAccessDeniedError({ errorCode: 403 })).toBe(true);
|
||||
expect(
|
||||
isSkillAccessDeniedError({ response: { data: {}, status: 401 } }),
|
||||
).toBe(true);
|
||||
expect(isSkillAccessDeniedError({ errorCode: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps backend business messages for recoverable category actions', () => {
|
||||
expect(
|
||||
resolveSkillApiErrorMessage(
|
||||
{ response: { data: { message: '请先迁移该分类下的 Skill' } } },
|
||||
'删除失败',
|
||||
),
|
||||
).toBe('请先迁移该分类下的 Skill');
|
||||
expect(resolveSkillApiErrorMessage(undefined, '删除失败')).toBe('删除失败');
|
||||
});
|
||||
});
|
||||
65
easyflow-ui-admin/app/src/views/ai/skill/skill-api-error.ts
Normal file
65
easyflow-ui-admin/app/src/views/ai/skill/skill-api-error.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
interface SkillApiErrorShape {
|
||||
code?: number | string;
|
||||
error?: string;
|
||||
errorCode?: number | string;
|
||||
httpStatus?: number | string;
|
||||
message?: string;
|
||||
response?: {
|
||||
data?: unknown;
|
||||
status?: number | string;
|
||||
};
|
||||
status?: number | string;
|
||||
}
|
||||
|
||||
function errorSnapshot(error: unknown) {
|
||||
const source = (error || {}) as SkillApiErrorShape;
|
||||
const body =
|
||||
source.response?.data && typeof source.response.data === 'object'
|
||||
? (source.response.data as SkillApiErrorShape)
|
||||
: source;
|
||||
return {
|
||||
code: body.code,
|
||||
errorCode: Number(body.errorCode),
|
||||
message: String(body.message || body.error || ''),
|
||||
status: Number(
|
||||
source.response?.status ?? body.httpStatus ?? body.status ?? 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回 Skill 请求中可直接展示的错误信息。 */
|
||||
export function resolveSkillApiErrorMessage(error: unknown, fallback: string) {
|
||||
return errorSnapshot(error).message || fallback;
|
||||
}
|
||||
|
||||
/** 判断文件请求是否因乐观锁冲突失败。 */
|
||||
export function isSkillFileConflictError(error: unknown) {
|
||||
const snapshot = errorSnapshot(error);
|
||||
return (
|
||||
snapshot.status === 409 ||
|
||||
snapshot.errorCode === 4091 ||
|
||||
snapshot.code === 'SKILL_FILE_CONFLICT' ||
|
||||
snapshot.message.includes('已被其他操作更新')
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断能力配置请求是否因版本冲突失败。 */
|
||||
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);
|
||||
return (
|
||||
snapshot.status === 401 ||
|
||||
snapshot.status === 403 ||
|
||||
snapshot.errorCode === 401 ||
|
||||
snapshot.errorCode === 403
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
113
easyflow-ui-admin/app/src/views/ai/skill/skill-capability.ts
Normal file
113
easyflow-ui-admin/app/src/views/ai/skill/skill-capability.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
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]);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SkillCategory } from './types';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildSkillCategoryPayload,
|
||||
canCreateSkillSubcategory,
|
||||
flattenSkillCategories,
|
||||
getSkillCategoryParentOptions,
|
||||
} from './skill-category';
|
||||
|
||||
const categories: SkillCategory[] = [
|
||||
{
|
||||
categoryName: '研发',
|
||||
id: 1,
|
||||
levelNo: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
levelNo: 2,
|
||||
parentId: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '运行时',
|
||||
id: 3,
|
||||
levelNo: 3,
|
||||
parentId: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ categoryName: '运营', id: 4, levelNo: 1 },
|
||||
];
|
||||
|
||||
describe('skill category helpers', () => {
|
||||
it('flattens categories with stable display depth', () => {
|
||||
expect(
|
||||
flattenSkillCategories(categories).map(({ depth, id }) => ({
|
||||
depth,
|
||||
id,
|
||||
})),
|
||||
).toEqual([
|
||||
{ depth: 0, id: 1 },
|
||||
{ depth: 1, id: 2 },
|
||||
{ depth: 2, id: 3 },
|
||||
{ depth: 0, id: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('only sends writable category fields', () => {
|
||||
expect(
|
||||
buildSkillCategoryPayload(
|
||||
{
|
||||
categoryName: ' 产品 ',
|
||||
id: 9,
|
||||
parentId: '',
|
||||
sortNo: 3.8,
|
||||
status: 0,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
categoryName: '产品',
|
||||
id: 9,
|
||||
parentId: undefined,
|
||||
sortNo: 3,
|
||||
status: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents cycles and moves that would exceed three levels', () => {
|
||||
expect(
|
||||
getSkillCategoryParentOptions(categories, 1).map(({ id }) => id),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
getSkillCategoryParentOptions(categories, 2).map(({ id }) => id),
|
||||
).toEqual([1, 4]);
|
||||
expect(
|
||||
getSkillCategoryParentOptions(categories).map(({ id }) => id),
|
||||
).toEqual([1, 2, 4]);
|
||||
});
|
||||
|
||||
it('allows adding children only below level three', () => {
|
||||
const flattened = flattenSkillCategories(categories);
|
||||
expect(flattened[1] && canCreateSkillSubcategory(flattened[1])).toBe(true);
|
||||
expect(flattened[2] && canCreateSkillSubcategory(flattened[2])).toBe(false);
|
||||
});
|
||||
});
|
||||
98
easyflow-ui-admin/app/src/views/ai/skill/skill-category.ts
Normal file
98
easyflow-ui-admin/app/src/views/ai/skill/skill-category.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { SkillCategory, SkillCategoryDraft } from './types';
|
||||
|
||||
export interface FlatSkillCategory extends SkillCategory {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a category tree while retaining its display depth.
|
||||
*/
|
||||
export function flattenSkillCategories(
|
||||
categories: SkillCategory[],
|
||||
depth = 0,
|
||||
): FlatSkillCategory[] {
|
||||
return categories.flatMap((category) => [
|
||||
{ ...category, depth },
|
||||
...flattenSkillCategories(category.children || [], depth + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the writable category DTO without leaking server-managed fields.
|
||||
*/
|
||||
export function buildSkillCategoryPayload(
|
||||
category: SkillCategoryDraft,
|
||||
includeId: boolean,
|
||||
): SkillCategoryDraft {
|
||||
const parentId =
|
||||
category.parentId === '' || category.parentId === null
|
||||
? undefined
|
||||
: category.parentId;
|
||||
const payload: SkillCategoryDraft = {
|
||||
categoryName: category.categoryName.trim(),
|
||||
parentId,
|
||||
sortNo:
|
||||
typeof category.sortNo === 'number' && Number.isFinite(category.sortNo)
|
||||
? Math.trunc(category.sortNo)
|
||||
: 0,
|
||||
status: category.status === 0 ? 0 : 1,
|
||||
};
|
||||
if (includeId) payload.id = category.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a category can accept another level within the three-level limit.
|
||||
*/
|
||||
export function canCreateSkillSubcategory(category: FlatSkillCategory) {
|
||||
return (category.levelNo ?? category.depth + 1) < 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns legal parent choices, excluding cycles and moves that exceed three levels.
|
||||
*/
|
||||
export function getSkillCategoryParentOptions(
|
||||
categories: SkillCategory[],
|
||||
editingId?: number | string,
|
||||
): FlatSkillCategory[] {
|
||||
const editing = editingId
|
||||
? findSkillCategory(categories, editingId)
|
||||
: undefined;
|
||||
const excludedIds = editing ? collectCategoryIds(editing) : new Set<string>();
|
||||
const subtreeHeight = editing ? getSubtreeHeight(editing) : 1;
|
||||
|
||||
return flattenSkillCategories(categories).filter((category) => {
|
||||
if (excludedIds.has(String(category.id))) return false;
|
||||
const parentLevel = category.levelNo ?? category.depth + 1;
|
||||
return parentLevel + subtreeHeight <= 3;
|
||||
});
|
||||
}
|
||||
|
||||
function findSkillCategory(
|
||||
categories: SkillCategory[],
|
||||
id: number | string,
|
||||
): SkillCategory | undefined {
|
||||
for (const category of categories) {
|
||||
if (String(category.id) === String(id)) return category;
|
||||
const child = findSkillCategory(category.children || [], id);
|
||||
if (child) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectCategoryIds(
|
||||
category: SkillCategory,
|
||||
result = new Set<string>(),
|
||||
) {
|
||||
result.add(String(category.id));
|
||||
for (const child of category.children || [])
|
||||
collectCategoryIds(child, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSubtreeHeight(category: SkillCategory): number {
|
||||
if (!category.children?.length) return 1;
|
||||
return (
|
||||
1 + Math.max(...category.children.map((child) => getSubtreeHeight(child)))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildInitialSkillDraft,
|
||||
createSkillCanonicalName,
|
||||
} from './skill-create';
|
||||
import { readFrontmatterScalar, splitSkillMarkdown } from './skill-markdown';
|
||||
|
||||
describe('skill creation draft', () => {
|
||||
it('creates a stable canonical name without exposing it to the form', () => {
|
||||
expect(createSkillCanonicalName('Research Assistant', 'abc123')).toBe(
|
||||
'research-assistant-abc123',
|
||||
);
|
||||
expect(createSkillCanonicalName('研究助手', 'abc123')).toBe('skill-abc123');
|
||||
});
|
||||
|
||||
it('builds a valid initial SKILL.md package', () => {
|
||||
const skill = buildInitialSkillDraft({
|
||||
categoryId: 8,
|
||||
displayName: '研究助手',
|
||||
visibilityScope: 'DEPT',
|
||||
});
|
||||
const parts = splitSkillMarkdown(String(skill.skillContent));
|
||||
|
||||
expect(skill).toMatchObject({
|
||||
categoryId: 8,
|
||||
description: '研究助手',
|
||||
displayName: '研究助手',
|
||||
enabled: true,
|
||||
publishStatus: 'DRAFT',
|
||||
visibilityScope: 'DEPT',
|
||||
});
|
||||
expect(skill.name).toMatch(/^skill-[a-z0-9]{10}$/);
|
||||
expect(readFrontmatterScalar(parts.frontmatter, 'name')).toBe(skill.name);
|
||||
expect(readFrontmatterScalar(parts.frontmatter, 'description')).toBe(
|
||||
'研究助手',
|
||||
);
|
||||
expect(parts.body).toContain('# Instructions');
|
||||
});
|
||||
});
|
||||
52
easyflow-ui-admin/app/src/views/ai/skill/skill-create.ts
Normal file
52
easyflow-ui-admin/app/src/views/ai/skill/skill-create.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { SkillInfo } 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,
|
||||
suffix = createRandomSuffix(),
|
||||
): string {
|
||||
const slug = displayName
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
.replaceAll(/-+$/g, '');
|
||||
const prefix = slug || 'skill';
|
||||
return `${prefix}-${suffix}`.slice(0, 64).replaceAll(/-+$/g, '');
|
||||
}
|
||||
|
||||
/** Create the minimal valid package accepted by the Skill draft endpoint. */
|
||||
export function buildInitialSkillDraft(input: {
|
||||
categoryId?: number | string;
|
||||
displayName: string;
|
||||
visibilityScope: string;
|
||||
}): SkillInfo {
|
||||
const displayName = input.displayName.trim();
|
||||
const name = createSkillCanonicalName(displayName);
|
||||
const content = syncSkillMarkdownFrontmatter(
|
||||
'# Instructions\n\n',
|
||||
name,
|
||||
displayName,
|
||||
);
|
||||
return {
|
||||
categoryId: input.categoryId || undefined,
|
||||
description: displayName,
|
||||
displayName,
|
||||
enabled: true,
|
||||
name,
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent: content,
|
||||
visibilityScope: input.visibilityScope,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomSuffix() {
|
||||
const uuid = globalThis.crypto?.randomUUID?.().replaceAll('-', '');
|
||||
if (uuid) return uuid.slice(0, 10);
|
||||
return Math.random().toString(36).slice(2, 12).padEnd(10, '0');
|
||||
}
|
||||
96
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.test.ts
Normal file
96
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSkillDraftPayload, validateSkillBasic } from './skill-draft';
|
||||
|
||||
describe('skill draft payload', () => {
|
||||
it('only sends editable draft fields and omits the id for create', () => {
|
||||
const payload = buildSkillDraftPayload(
|
||||
{
|
||||
approvalPending: true,
|
||||
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',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
categoryId: 3,
|
||||
displayName: 'Display name',
|
||||
enabled: true,
|
||||
skillContent: 'content',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the id and excludes stale content for update', () => {
|
||||
expect(
|
||||
buildSkillDraftPayload({ id: 9, skillContent: 'stale content' }, true),
|
||||
).toEqual({
|
||||
categoryId: undefined,
|
||||
displayName: undefined,
|
||||
enabled: undefined,
|
||||
id: 9,
|
||||
visibilityScope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill basic validation', () => {
|
||||
it('requires canonical standard metadata when creating a Skill', () => {
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{ description: '', displayName: '', name: 'Invalid_Name' },
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
description: '请输入标准描述',
|
||||
displayName: '请输入显示名称',
|
||||
name: '仅支持小写字母、数字和连字符',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not revalidate frontmatter-only fields for an existing Skill', () => {
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{ description: '', displayName: 'Display', name: '' },
|
||||
false,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('uses the same basic-field limits as the backend Skill contract', () => {
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{
|
||||
description: 'd'.repeat(1025),
|
||||
displayName: 'x'.repeat(129),
|
||||
name: 'a'.repeat(65),
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
description: '标准描述不能超过 1024 个字符',
|
||||
displayName: '显示名称不能超过 128 个字符',
|
||||
name: '标准名称不能超过 64 个字符',
|
||||
});
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{
|
||||
description: 'd'.repeat(1024),
|
||||
displayName: 'x'.repeat(128),
|
||||
name: 'a'.repeat(64),
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
65
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.ts
Normal file
65
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SkillInfo } from './types';
|
||||
|
||||
export interface SkillDraftPayload {
|
||||
categoryId?: number | string;
|
||||
displayName?: string;
|
||||
enabled?: boolean;
|
||||
id?: number | string;
|
||||
skillContent?: string;
|
||||
visibilityScope?: string;
|
||||
}
|
||||
|
||||
export interface SkillBasicValidationErrors {
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const CANONICAL_SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/** Build the narrow DTO accepted by the Skill draft endpoints. */
|
||||
export function buildSkillDraftPayload(
|
||||
skill: SkillInfo,
|
||||
includeId: boolean,
|
||||
): SkillDraftPayload {
|
||||
const payload: SkillDraftPayload = {
|
||||
categoryId: skill.categoryId,
|
||||
displayName: skill.displayName,
|
||||
enabled: skill.enabled,
|
||||
visibilityScope: skill.visibilityScope,
|
||||
};
|
||||
if (includeId) payload.id = skill.id;
|
||||
else payload.skillContent = skill.skillContent;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Validate fields that are editable in the basic-information section. */
|
||||
export function validateSkillBasic(
|
||||
skill: SkillInfo,
|
||||
isNew: boolean,
|
||||
): SkillBasicValidationErrors {
|
||||
const errors: SkillBasicValidationErrors = {};
|
||||
const displayName = String(skill.displayName || '').trim();
|
||||
if (!displayName) {
|
||||
errors.displayName = '请输入显示名称';
|
||||
} else if (displayName.length > 128) {
|
||||
errors.displayName = '显示名称不能超过 128 个字符';
|
||||
}
|
||||
if (!isNew) return errors;
|
||||
|
||||
const name = String(skill.name || '').trim();
|
||||
if (!name) {
|
||||
errors.name = '请输入标准名称';
|
||||
} else if (name.length > 64) {
|
||||
errors.name = '标准名称不能超过 64 个字符';
|
||||
} else if (!CANONICAL_SKILL_NAME.test(name)) {
|
||||
errors.name = '仅支持小写字母、数字和连字符';
|
||||
}
|
||||
const description = String(skill.description || '').trim();
|
||||
if (!description) {
|
||||
errors.description = '请输入标准描述';
|
||||
} else if (description.length > 1024) {
|
||||
errors.description = '标准描述不能超过 1024 个字符';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSkillTextDownload } from './skill-file-download';
|
||||
|
||||
describe('skill file download', () => {
|
||||
it('downloads the current text buffer including unsaved edits', async () => {
|
||||
const blob = buildSkillTextDownload(
|
||||
{
|
||||
content: 'saved',
|
||||
isText: true,
|
||||
path: 'scripts/run.py',
|
||||
type: 'SCRIPT',
|
||||
},
|
||||
'unsaved edit',
|
||||
);
|
||||
|
||||
expect(await blob?.text()).toBe('unsaved edit');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ASSET', 'assets/archive.bin'],
|
||||
['EXAMPLE', 'examples/archive.bin'],
|
||||
['OTHER', 'resources/archive.bin'],
|
||||
['REFERENCE', 'references/manual.pdf'],
|
||||
['SCRIPT', 'scripts/tool.bin'],
|
||||
])(
|
||||
'keeps binary %s resources on the authenticated download path',
|
||||
(type, path) => {
|
||||
expect(
|
||||
buildSkillTextDownload({ isText: false, path, type }, ''),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SkillFileContent } from './types';
|
||||
|
||||
/** Build a download from the active text buffer so unsaved edits are included. */
|
||||
export function buildSkillTextDownload(
|
||||
file: SkillFileContent,
|
||||
content: string,
|
||||
): Blob | undefined {
|
||||
if (file.isText === false) return undefined;
|
||||
const textFile =
|
||||
file.isText === true ||
|
||||
['REFERENCE', 'SCRIPT', 'SKILL'].includes(file.type);
|
||||
if (!textFile) return undefined;
|
||||
return new Blob([content], {
|
||||
type: file.mediaType || 'text/plain;charset=utf-8',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSkillPathText } from './skill-file-representation';
|
||||
|
||||
describe('skill file representation', () => {
|
||||
it('keeps scripts textual and assets binary regardless of extension', () => {
|
||||
expect(isSkillPathText('scripts/run.bin')).toBe(true);
|
||||
expect(isSkillPathText('assets/readme.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies other resources by their known text extension', () => {
|
||||
expect(isSkillPathText('references/data.json')).toBe(true);
|
||||
expect(isSkillPathText('references/page.htm')).toBe(true);
|
||||
expect(isSkillPathText('examples/archive.bin')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
const TEXT_EXTENSIONS = [
|
||||
'.bash',
|
||||
'.c',
|
||||
'.cjs',
|
||||
'.cpp',
|
||||
'.css',
|
||||
'.csv',
|
||||
'.go',
|
||||
'.gradle',
|
||||
'.groovy',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.htm',
|
||||
'.html',
|
||||
'.ini',
|
||||
'.java',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.kt',
|
||||
'.kts',
|
||||
'.markdown',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.properties',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.rs',
|
||||
'.sh',
|
||||
'.sql',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsv',
|
||||
'.tsx',
|
||||
'.txt',
|
||||
'.xml',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.zsh',
|
||||
] as const;
|
||||
|
||||
/** 按 M18 规范判断目标路径的持久化表示。 */
|
||||
export function isSkillPathText(path: string) {
|
||||
const normalized = path.toLowerCase();
|
||||
const topDirectory = normalized.split('/', 1)[0];
|
||||
if (topDirectory === 'scripts') return true;
|
||||
if (topDirectory === 'assets') return false;
|
||||
return TEXT_EXTENSIONS.some((extension) => normalized.endsWith(extension));
|
||||
}
|
||||
116
easyflow-ui-admin/app/src/views/ai/skill/skill-import.test.ts
Normal file
116
easyflow-ui-admin/app/src/views/ai/skill/skill-import.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
59
easyflow-ui-admin/app/src/views/ai/skill/skill-import.ts
Normal file
59
easyflow-ui-admin/app/src/views/ai/skill/skill-import.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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);
|
||||
}
|
||||
162
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.test.ts
Normal file
162
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
joinSkillMarkdown,
|
||||
readFrontmatterScalar,
|
||||
resolveSkillRelativePath,
|
||||
splitSkillMarkdown,
|
||||
syncSkillMarkdownFrontmatter,
|
||||
updateFrontmatterScalar,
|
||||
} from './skill-markdown';
|
||||
|
||||
describe('skill markdown round-trip', () => {
|
||||
it('preserves nested and unknown frontmatter while editing the body', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'name: research-skill',
|
||||
'description: Research helper',
|
||||
'metadata:',
|
||||
' owner: team-a',
|
||||
' flags:',
|
||||
' - safe',
|
||||
'x-extra: keep-me',
|
||||
'---',
|
||||
'',
|
||||
'# Instructions',
|
||||
'',
|
||||
'- Read `references/policy.md`.',
|
||||
].join('\n');
|
||||
const parts = splitSkillMarkdown(source);
|
||||
parts.body = `${parts.body}\n- Return a concise answer.`;
|
||||
const next = joinSkillMarkdown(parts);
|
||||
|
||||
expect(next).toContain('metadata:\n owner: team-a\n flags:\n - safe');
|
||||
expect(next).toContain('x-extra: keep-me');
|
||||
expect(next).toContain('Return a concise answer.');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'---\nname: exact\n---',
|
||||
'---\nname: exact\n---\n',
|
||||
'---\nname: exact\n---\n\n# Title\n',
|
||||
'---\r\nname: exact\r\n---\r\n\r\n# Title',
|
||||
])('round-trips delimiters and whitespace exactly', (source) => {
|
||||
expect(joinSkillMarkdown(splitSkillMarkdown(source))).toBe(source);
|
||||
});
|
||||
|
||||
it('does not treat a non-delimiter line as closing frontmatter', () => {
|
||||
const source = '---\nname: exact\n---foo\n# title';
|
||||
expect(splitSkillMarkdown(source).hasFrontmatter).toBe(false);
|
||||
});
|
||||
|
||||
it('only changes the requested top-level scalar', () => {
|
||||
const frontmatter =
|
||||
'name: old-name\ncustom:\n name: nested-name\nx-extra: true';
|
||||
const updated = updateFrontmatterScalar(frontmatter, 'name', 'new-name');
|
||||
expect(readFrontmatterScalar(updated, 'name')).toBe('new-name');
|
||||
expect(updated).toContain(' name: nested-name');
|
||||
expect(updated).toContain('x-extra: true');
|
||||
});
|
||||
|
||||
it('syncs new Skill metadata without losing the body or unknown frontmatter', () => {
|
||||
const source =
|
||||
'---\nname: old-name\ndescription: old description\nx-owner: team-a\n---\n\n# Instructions\n\nKeep this body.';
|
||||
const updated = syncSkillMarkdownFrontmatter(
|
||||
source,
|
||||
'new-name',
|
||||
'New description',
|
||||
);
|
||||
|
||||
expect(updated).toContain('name: "new-name"');
|
||||
expect(updated).toContain('description: "New description"');
|
||||
expect(updated).toContain('x-owner: team-a');
|
||||
expect(updated).toContain('# Instructions\n\nKeep this body.');
|
||||
});
|
||||
|
||||
it('adds required frontmatter to a Markdown document that has none', () => {
|
||||
const updated = syncSkillMarkdownFrontmatter(
|
||||
'# Instructions\n\nKeep this body.',
|
||||
'new-skill',
|
||||
'New description',
|
||||
);
|
||||
|
||||
expect(updated).toContain('name: "new-skill"');
|
||||
expect(updated).toContain('description: "New description"');
|
||||
expect(updated).toContain('# Instructions\n\nKeep this body.');
|
||||
expect(splitSkillMarkdown(updated).hasFrontmatter).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses structured edits for multiline YAML scalars', () => {
|
||||
const frontmatter =
|
||||
'name: exact\ndescription: |\n line one\n line two\nx-extra: true';
|
||||
expect(() =>
|
||||
updateFrontmatterScalar(frontmatter, 'description', 'next'),
|
||||
).toThrow('源码模式');
|
||||
expect(frontmatter).toContain('line two');
|
||||
});
|
||||
|
||||
it('reads block scalars and preserves them when metadata is unchanged', () => {
|
||||
const source =
|
||||
'---\nname: exact\ndescription: |\n line one\n line two\nx-extra: true\n---\n\n# Instructions\n';
|
||||
const parts = splitSkillMarkdown(source);
|
||||
|
||||
expect(readFrontmatterScalar(parts.frontmatter, 'description')).toBe(
|
||||
'line one\nline two\n',
|
||||
);
|
||||
expect(
|
||||
syncSkillMarkdownFrontmatter(source, 'exact', 'line one\nline two'),
|
||||
).toBe(source);
|
||||
});
|
||||
|
||||
it('returns an empty scalar for malformed or non-string frontmatter', () => {
|
||||
expect(readFrontmatterScalar('name: [broken', 'name')).toBe('');
|
||||
expect(readFrontmatterScalar('name:\n nested: value', 'name')).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'description: |2\n text',
|
||||
'description: >-\n text',
|
||||
'description: |2- # comment\n text',
|
||||
'description: first line\n continuation',
|
||||
'description: plain # keep this comment',
|
||||
])('routes unsafe scalar edits to source mode', (frontmatter) => {
|
||||
expect(() =>
|
||||
updateFrontmatterScalar(frontmatter, 'description', 'next'),
|
||||
).toThrow('源码模式');
|
||||
});
|
||||
|
||||
it('resolves links from the current markdown file directory', () => {
|
||||
expect(
|
||||
resolveSkillRelativePath(
|
||||
'references/guides/start.md',
|
||||
'../policy.md#scope',
|
||||
),
|
||||
).toBe('references/policy.md');
|
||||
expect(resolveSkillRelativePath('SKILL.md', 'examples/sample.md')).toBe(
|
||||
'examples/sample.md',
|
||||
);
|
||||
expect(
|
||||
resolveSkillRelativePath('SKILL.md', '../outside.md'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'file:///etc/passwd',
|
||||
'blob:https://example.com/foreign',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'//example.com/path',
|
||||
'references/a.md\u0000.png',
|
||||
])(
|
||||
'rejects URI schemes and control characters from relative links',
|
||||
(href) => {
|
||||
expect(resolveSkillRelativePath('SKILL.md', href)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves raw HTML as source text for the text-only Milkdown HTML node', () => {
|
||||
const source =
|
||||
'---\nname: safe\ndescription: safe\n---\n\n<img src=x onerror="window.pwned=true">';
|
||||
expect(joinSkillMarkdown(splitSkillMarkdown(source))).toBe(source);
|
||||
});
|
||||
});
|
||||
165
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.ts
Normal file
165
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { parseDocument } from 'yaml';
|
||||
|
||||
export interface SkillMarkdownParts {
|
||||
body: string;
|
||||
bodyPrefix: string;
|
||||
frontmatter: string;
|
||||
hasFrontmatter: boolean;
|
||||
lineEnding: '\n' | '\r\n';
|
||||
}
|
||||
|
||||
// The closing marker is accepted only when `---` occupies the complete line.
|
||||
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
|
||||
|
||||
export function splitSkillMarkdown(source: string): SkillMarkdownParts {
|
||||
const lineEnding = source.includes('\r\n') ? '\r\n' : '\n';
|
||||
const match = FRONTMATTER_PATTERN.exec(source);
|
||||
if (!match) {
|
||||
return {
|
||||
body: source,
|
||||
bodyPrefix: '',
|
||||
frontmatter: '',
|
||||
hasFrontmatter: false,
|
||||
lineEnding,
|
||||
};
|
||||
}
|
||||
return {
|
||||
body: source.slice(match[0].length),
|
||||
bodyPrefix: match[2] || '',
|
||||
frontmatter: match[1] || '',
|
||||
hasFrontmatter: true,
|
||||
lineEnding,
|
||||
};
|
||||
}
|
||||
|
||||
export function joinSkillMarkdown(parts: SkillMarkdownParts): string {
|
||||
if (!parts.hasFrontmatter) return parts.body;
|
||||
return `---${parts.lineEnding}${parts.frontmatter}${parts.lineEnding}---${parts.bodyPrefix}${parts.body}`;
|
||||
}
|
||||
|
||||
export function updateFrontmatterScalar(
|
||||
frontmatter: string,
|
||||
key: 'description' | 'name',
|
||||
value: string,
|
||||
lineEnding: '\n' | '\r\n' = '\n',
|
||||
): string {
|
||||
const serialized = JSON.stringify(value.trim());
|
||||
const fieldPattern = new RegExp(`^${key}\\s*:[^\\r\\n]*$`, 'm');
|
||||
const match = fieldPattern.exec(frontmatter);
|
||||
const current = match?.[0];
|
||||
if (current) {
|
||||
const raw = current.slice(current.indexOf(':') + 1).trim();
|
||||
const following = frontmatter.slice((match?.index || 0) + current.length);
|
||||
const hasIndentedContinuation = /^\r?\n[ \t]+\S/.test(following);
|
||||
const unsafeScalar =
|
||||
!raw ||
|
||||
/^[>|]/.test(raw) ||
|
||||
/\s+#/.test(raw) ||
|
||||
/^[{[&*!]/.test(raw) ||
|
||||
/^(?:null|true|false|[-+]?\d+(?:\.\d+)?)$/i.test(raw) ||
|
||||
hasIndentedContinuation;
|
||||
if (unsafeScalar) {
|
||||
throw new Error(`${key} 使用复杂 YAML,请在源码模式中编辑`);
|
||||
}
|
||||
return frontmatter.replace(fieldPattern, `${key}: ${serialized}`);
|
||||
}
|
||||
const suffix =
|
||||
frontmatter && !frontmatter.endsWith(lineEnding) ? lineEnding : '';
|
||||
return `${frontmatter}${suffix}${key}: ${serialized}`;
|
||||
}
|
||||
|
||||
export function syncSkillMarkdownFrontmatter(
|
||||
source: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): string {
|
||||
const parts = splitSkillMarkdown(source);
|
||||
if (!parts.hasFrontmatter) {
|
||||
parts.hasFrontmatter = true;
|
||||
parts.bodyPrefix = `${parts.lineEnding}${parts.lineEnding}`;
|
||||
}
|
||||
if (readFrontmatterScalar(parts.frontmatter, 'name').trim() !== name.trim()) {
|
||||
parts.frontmatter = updateFrontmatterScalar(
|
||||
parts.frontmatter,
|
||||
'name',
|
||||
name,
|
||||
parts.lineEnding,
|
||||
);
|
||||
}
|
||||
if (
|
||||
readFrontmatterScalar(parts.frontmatter, 'description').trim() !==
|
||||
description.trim()
|
||||
) {
|
||||
parts.frontmatter = updateFrontmatterScalar(
|
||||
parts.frontmatter,
|
||||
'description',
|
||||
description,
|
||||
parts.lineEnding,
|
||||
);
|
||||
}
|
||||
return joinSkillMarkdown(parts);
|
||||
}
|
||||
|
||||
export function readFrontmatterScalar(
|
||||
frontmatter: string,
|
||||
key: 'description' | 'name',
|
||||
): string {
|
||||
if (!frontmatter.trim()) return '';
|
||||
try {
|
||||
const document = parseDocument(frontmatter, {
|
||||
prettyErrors: false,
|
||||
schema: 'failsafe',
|
||||
strict: true,
|
||||
uniqueKeys: true,
|
||||
});
|
||||
if (document.errors.length > 0) return '';
|
||||
const value = document.get(key);
|
||||
return typeof value === 'string' ? value : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSkillRelativeLink(href: string): string {
|
||||
if (hasUnsafeUriForm(href)) return '';
|
||||
return href.split('#', 1)[0]?.replace(/^\.\//, '') || '';
|
||||
}
|
||||
|
||||
export function resolveSkillRelativePath(
|
||||
currentPath: string,
|
||||
href: string,
|
||||
): string | undefined {
|
||||
const cleanHref = href.split(/[?#]/, 1)[0] || '';
|
||||
if (
|
||||
!cleanHref ||
|
||||
hasUnsafeUriForm(href) ||
|
||||
cleanHref.startsWith('/') ||
|
||||
cleanHref.includes('\\')
|
||||
)
|
||||
return undefined;
|
||||
const segments = currentPath.split('/');
|
||||
segments.pop();
|
||||
for (const segment of cleanHref.split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
if (segments.length === 0) return undefined;
|
||||
segments.pop();
|
||||
} else {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function hasUnsafeUriForm(value: string) {
|
||||
const href = value.trim();
|
||||
return (
|
||||
!href ||
|
||||
/^[a-z][a-z\d+.-]*:/i.test(href) ||
|
||||
href.startsWith('//') ||
|
||||
[...href].some((character) => {
|
||||
const code = character.codePointAt(0) || 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,99 +1,213 @@
|
||||
export interface RequestResult<T = any> {
|
||||
export interface RequestResult<T = unknown> {
|
||||
data: T;
|
||||
errorCode: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type SkillCapabilityType = 'MCP' | 'PLUGIN_ITEM' | 'WORKFLOW';
|
||||
export type SkillExportFormat = 'EASYFLOW' | 'STANDARD';
|
||||
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
||||
|
||||
export interface SkillInfo {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
categoryId?: number | string;
|
||||
metadataJson?: Record<string, any>;
|
||||
skillContent?: string;
|
||||
enabled?: boolean;
|
||||
visibilityScope?: string;
|
||||
sourceType?: string;
|
||||
referenceCount?: number;
|
||||
scriptCount?: number;
|
||||
assetCount?: number;
|
||||
publishStatus?: string;
|
||||
displayPublishStatus?: string;
|
||||
approvalPending?: boolean;
|
||||
currentApprovalActionType?: string;
|
||||
currentApprovalInstanceId?: number | string;
|
||||
assetCount?: number;
|
||||
bindings?: SkillCapabilityBinding[];
|
||||
capabilityCount?: number;
|
||||
capabilityHash?: string;
|
||||
categoryId?: number | string;
|
||||
categoryName?: string;
|
||||
created?: string;
|
||||
createdByName?: string;
|
||||
references?: SkillReference[];
|
||||
scripts?: SkillScript[];
|
||||
assets?: SkillAsset[];
|
||||
[key: string]: any;
|
||||
currentApprovalActionType?: string;
|
||||
currentApprovalInstanceId?: number | string;
|
||||
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;
|
||||
resources?: SkillResource[];
|
||||
scriptCount?: number;
|
||||
skillContent?: string;
|
||||
sourceType?: string;
|
||||
visibilityScope?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SkillReference {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
name?: string;
|
||||
export interface SkillResource {
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillScript {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
language?: string;
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillAsset {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
name?: string;
|
||||
mediaType?: string;
|
||||
contentRef?: string;
|
||||
contentHash?: string;
|
||||
id?: number | string;
|
||||
isText?: boolean;
|
||||
kind?: string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
metadataJson?: Record<string, unknown>;
|
||||
path: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
children?: SkillFileNode[];
|
||||
contentHash?: string;
|
||||
isText?: boolean;
|
||||
key: string;
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'ASSET' | 'DIRECTORY' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
children?: SkillFileNode[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SkillFileContent {
|
||||
path: string;
|
||||
type: 'ASSET' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
isText?: boolean;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
downloadUrl?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SkillValidationIssue {
|
||||
code?: string;
|
||||
column?: number;
|
||||
line?: number;
|
||||
message: string;
|
||||
path?: string;
|
||||
severity: SkillIssueSeverity;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface SkillValidationResult {
|
||||
issues: SkillValidationIssue[];
|
||||
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 {
|
||||
packageId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
referenceCount: number;
|
||||
scriptCount: number;
|
||||
assetCount: number;
|
||||
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;
|
||||
path: string;
|
||||
size: number;
|
||||
text: boolean;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityMapping {
|
||||
bindingKey: string;
|
||||
capabilityType: SkillCapabilityType;
|
||||
disabled?: boolean;
|
||||
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;
|
||||
issues?: SkillValidationIssue[];
|
||||
skills: SkillImportPreviewItem[];
|
||||
}
|
||||
|
||||
export interface SkillFileBuffer {
|
||||
content: string;
|
||||
conflict?: boolean;
|
||||
file: SkillFileContent;
|
||||
original: string;
|
||||
pendingContent?: string;
|
||||
revision: number;
|
||||
saveState: 'dirty' | 'error' | 'saved' | 'saving';
|
||||
}
|
||||
|
||||
export interface SkillCategory {
|
||||
categoryName: string;
|
||||
children?: SkillCategory[];
|
||||
id: number | string;
|
||||
levelNo?: number;
|
||||
parentId?: number | string;
|
||||
sortNo?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface SkillCategoryDraft {
|
||||
categoryName: string;
|
||||
id?: number | string;
|
||||
parentId?: '' | null | number | string;
|
||||
sortNo?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useSkillFileBuffers } from './use-skill-file-buffers';
|
||||
|
||||
describe('skill file buffers', () => {
|
||||
it('keeps independent unsaved content when switching files', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({ content: '# Skill', path: 'SKILL.md', type: 'SKILL' });
|
||||
store.ensureBuffer({
|
||||
content: '# Policy',
|
||||
path: 'references/policy.md',
|
||||
type: 'REFERENCE',
|
||||
});
|
||||
|
||||
store.updateContent('SKILL.md', '# Updated Skill');
|
||||
store.updateContent('references/policy.md', '# Updated Policy');
|
||||
|
||||
expect(store.buffers.get('SKILL.md')?.content).toBe('# Updated Skill');
|
||||
expect(store.buffers.get('references/policy.md')?.content).toBe(
|
||||
'# Updated Policy',
|
||||
);
|
||||
expect([...store.dirtyPaths.value]).toEqual([
|
||||
'SKILL.md',
|
||||
'references/policy.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('only clears dirty state after the matching file is saved', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({
|
||||
content: 'one',
|
||||
path: 'scripts/run.py',
|
||||
type: 'SCRIPT',
|
||||
});
|
||||
store.updateContent('scripts/run.py', 'two');
|
||||
expect(store.dirtyPaths.value.size).toBe(1);
|
||||
|
||||
store.markSaved('scripts/run.py');
|
||||
expect(store.dirtyPaths.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps edits made while a save request is in flight dirty', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({
|
||||
content: 'one',
|
||||
path: 'scripts/run.py',
|
||||
type: 'SCRIPT',
|
||||
});
|
||||
store.updateContent('scripts/run.py', 'two');
|
||||
const sentContent = store.markSaving('scripts/run.py');
|
||||
store.updateContent('scripts/run.py', 'three');
|
||||
|
||||
store.markSaved(
|
||||
'scripts/run.py',
|
||||
{ content: 'two', path: 'scripts/run.py', type: 'SCRIPT' },
|
||||
sentContent,
|
||||
);
|
||||
|
||||
expect(store.buffers.get('scripts/run.py')?.original).toBe('two');
|
||||
expect(store.buffers.get('scripts/run.py')?.content).toBe('three');
|
||||
expect(store.buffers.get('scripts/run.py')?.saveState).toBe('dirty');
|
||||
});
|
||||
|
||||
it('preserves dirty content and marks a conflict when the server refresh changes', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({
|
||||
content: 'one',
|
||||
contentHash: 'old-hash',
|
||||
path: 'SKILL.md',
|
||||
type: 'SKILL',
|
||||
});
|
||||
store.updateContent('SKILL.md', 'local');
|
||||
store.ensureBuffer({
|
||||
content: 'remote',
|
||||
contentHash: 'remote-hash',
|
||||
path: 'SKILL.md',
|
||||
type: 'SKILL',
|
||||
});
|
||||
|
||||
expect(store.buffers.get('SKILL.md')?.content).toBe('local');
|
||||
expect(store.buffers.get('SKILL.md')?.conflict).toBe(true);
|
||||
expect(store.buffers.get('SKILL.md')?.file.contentHash).toBe('old-hash');
|
||||
});
|
||||
|
||||
it('rejects renaming over an existing buffer', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({ content: 'one', path: 'a.md', type: 'REFERENCE' });
|
||||
store.ensureBuffer({ content: 'two', path: 'b.md', type: 'REFERENCE' });
|
||||
expect(() => store.renameBuffer('a.md', 'b.md')).toThrow('目标路径已存在');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { SkillFileBuffer, SkillFileContent } from './types';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
export function useSkillFileBuffers() {
|
||||
const buffers = reactive(new Map<string, SkillFileBuffer>());
|
||||
|
||||
const dirtyPaths = computed(
|
||||
() =>
|
||||
new Set(
|
||||
[...buffers.values()]
|
||||
.filter((buffer) => buffer.content !== buffer.original)
|
||||
.map((buffer) => buffer.file.path),
|
||||
),
|
||||
);
|
||||
|
||||
function ensureBuffer(
|
||||
file: SkillFileContent,
|
||||
refreshStrategy: 'preserve-dirty' | 'replace' = 'preserve-dirty',
|
||||
) {
|
||||
const existing = buffers.get(file.path);
|
||||
if (existing) {
|
||||
const serverContent = file.content || '';
|
||||
const dirty = existing.content !== existing.original;
|
||||
if (refreshStrategy === 'replace' || !dirty) {
|
||||
Object.assign(existing, {
|
||||
conflict: false,
|
||||
content: serverContent,
|
||||
file,
|
||||
original: serverContent,
|
||||
pendingContent: undefined,
|
||||
saveState: 'saved',
|
||||
});
|
||||
} else {
|
||||
existing.conflict = serverContent !== existing.original;
|
||||
if (!existing.conflict) existing.file = file;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const buffer: SkillFileBuffer = {
|
||||
content: file.content || '',
|
||||
file,
|
||||
original: file.content || '',
|
||||
revision: 0,
|
||||
saveState: 'saved',
|
||||
};
|
||||
buffers.set(file.path, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function updateContent(path: string, content: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer || buffer.content === content) return;
|
||||
buffer.content = content;
|
||||
buffer.revision += 1;
|
||||
buffer.saveState = content === buffer.original ? 'saved' : 'dirty';
|
||||
}
|
||||
|
||||
function markSaving(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return undefined;
|
||||
buffer.pendingContent = buffer.content;
|
||||
buffer.saveState = 'saving';
|
||||
return buffer.pendingContent;
|
||||
}
|
||||
|
||||
function markSaved(
|
||||
path: string,
|
||||
file?: SkillFileContent,
|
||||
sentContent?: string,
|
||||
) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return;
|
||||
const sent = sentContent ?? buffer.pendingContent ?? buffer.content;
|
||||
const serverContent = file?.content ?? sent;
|
||||
if (file) buffer.file = file;
|
||||
buffer.original = serverContent;
|
||||
buffer.pendingContent = undefined;
|
||||
buffer.conflict = false;
|
||||
if (buffer.content === sent) {
|
||||
buffer.content = serverContent;
|
||||
buffer.saveState = 'saved';
|
||||
} else {
|
||||
buffer.saveState = 'dirty';
|
||||
}
|
||||
}
|
||||
|
||||
function markError(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (buffer) buffer.saveState = 'error';
|
||||
}
|
||||
|
||||
function markConflict(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return;
|
||||
buffer.conflict = true;
|
||||
buffer.pendingContent = undefined;
|
||||
buffer.saveState = 'error';
|
||||
}
|
||||
|
||||
function removeBuffer(path: string) {
|
||||
buffers.delete(path);
|
||||
}
|
||||
|
||||
function renameBuffer(path: string, nextPath: string) {
|
||||
if (path !== nextPath && buffers.has(nextPath)) {
|
||||
throw new Error(`目标路径已存在:${nextPath}`);
|
||||
}
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return;
|
||||
buffers.delete(path);
|
||||
buffer.file = { ...buffer.file, path: nextPath };
|
||||
buffers.set(nextPath, buffer);
|
||||
}
|
||||
|
||||
function clearBuffers() {
|
||||
buffers.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
buffers,
|
||||
clearBuffers,
|
||||
dirtyPaths,
|
||||
ensureBuffer,
|
||||
markConflict,
|
||||
markError,
|
||||
markSaved,
|
||||
markSaving,
|
||||
removeBuffer,
|
||||
renameBuffer,
|
||||
updateContent,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import BotApprovalSnapshotPreview from '#/views/system/approval/components/BotAp
|
||||
import KnowledgeApprovalSnapshotPreview from '#/views/system/approval/components/KnowledgeApprovalSnapshotPreview.vue';
|
||||
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
@@ -171,23 +173,6 @@ function formatPayload(payload: Record<string, any>) {
|
||||
return JSON.stringify(payload || {}, null, 2);
|
||||
}
|
||||
|
||||
function formatAccountDisplay(
|
||||
name?: string,
|
||||
account?: null | string,
|
||||
fallbackId?: null | number | string,
|
||||
) {
|
||||
if (name && account) {
|
||||
return `${name}(${account})`;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (account) {
|
||||
return account;
|
||||
}
|
||||
return fallbackId || '-';
|
||||
}
|
||||
|
||||
function formatOperatorId(
|
||||
account?: null | string,
|
||||
fallbackId?: null | number | string,
|
||||
@@ -334,7 +319,7 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
|
||||
{{
|
||||
formatAccountDisplay(
|
||||
formatApprovalAccount(
|
||||
detail.applicantName,
|
||||
detail.applicantAccount,
|
||||
detail.applicantId,
|
||||
@@ -390,7 +375,7 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('approval.fields.actedBy')" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatAccountDisplay(row.actedByName, row.actedBy) }}
|
||||
{{ formatApprovalAccount(row.actedByName, row.actedBy) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
|
||||
@@ -29,6 +29,7 @@ import PageData from '#/components/page/PageData.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
import ApprovalFlowModal from './ApprovalFlowModal.vue';
|
||||
|
||||
type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed';
|
||||
@@ -785,6 +786,20 @@ function formatApplicationReason(value?: null | string) {
|
||||
{{ formatApplicationReason(row.applicationReason) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.applicant')"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
formatApprovalAccount(
|
||||
row.applicantName,
|
||||
row.applicantAccount,
|
||||
row.applicantId,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.resourceType')"
|
||||
width="120"
|
||||
@@ -969,6 +984,20 @@ function formatApplicationReason(value?: null | string) {
|
||||
{{ formatApplicationReason(row.applicationReason) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.applicant')"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
formatApprovalAccount(
|
||||
row.applicantName,
|
||||
row.applicantAccount,
|
||||
row.applicantId,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.resourceType')"
|
||||
width="120"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
|
||||
describe('formatApprovalAccount', () => {
|
||||
it('formats the applicant consistently with approval detail', () => {
|
||||
expect(formatApprovalAccount('陈子默', 'czm', '7')).toBe('陈子默(czm)');
|
||||
});
|
||||
|
||||
it('falls back when applicant account information is unavailable', () => {
|
||||
expect(formatApprovalAccount(null, null, '7')).toBe('7');
|
||||
expect(formatApprovalAccount()).toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 格式化审批账号信息,与审批详情保持“姓名(账号)”展示规则。
|
||||
*
|
||||
* @param name 展示名称
|
||||
* @param account 登录账号
|
||||
* @param fallbackId 无账号信息时使用的 ID
|
||||
* @returns 可直接展示的账号文本
|
||||
*/
|
||||
export function formatApprovalAccount(
|
||||
name?: null | string,
|
||||
account?: null | string,
|
||||
fallbackId?: null | number | string,
|
||||
) {
|
||||
if (name && account) {
|
||||
return `${name}(${account})`;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (account) {
|
||||
return account;
|
||||
}
|
||||
return fallbackId || '-';
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"esno",
|
||||
"etag",
|
||||
"execa",
|
||||
"hitl",
|
||||
"iconify",
|
||||
"iconoir",
|
||||
"intlify",
|
||||
|
||||
@@ -85,6 +85,27 @@
|
||||
/* Border radius for card, input and buttons */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Shared workspace and editor rhythm */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--radius-control: 10px;
|
||||
--radius-toolbar: 12px;
|
||||
--radius-panel: 16px;
|
||||
--radius-float: 20px;
|
||||
--radius-pill: 999px;
|
||||
--motion-duration-fast: 120ms;
|
||||
--motion-duration-base: 160ms;
|
||||
--motion-duration-medium: 220ms;
|
||||
--motion-duration-slow: 280ms;
|
||||
--motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
|
||||
--motion-ease-decelerate: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
|
||||
|
||||
/* ============= custom ============= */
|
||||
|
||||
/* 遮罩颜色 */
|
||||
|
||||
@@ -28,6 +28,12 @@ export {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
File,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
FoldHorizontal,
|
||||
Fullscreen,
|
||||
Github,
|
||||
@@ -43,10 +49,10 @@ export {
|
||||
LogOut,
|
||||
MailCheck,
|
||||
Maximize,
|
||||
MessageSquare,
|
||||
ArrowRightFromLine as MdiMenuClose,
|
||||
ArrowLeftFromLine as MdiMenuOpen,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
Minimize,
|
||||
Minimize2,
|
||||
MoonStar,
|
||||
@@ -67,6 +73,7 @@ export {
|
||||
Sun,
|
||||
SunMoon,
|
||||
SwatchBook,
|
||||
Upload,
|
||||
UserRoundPen,
|
||||
X,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src',
|
||||
loaders: ['vue'],
|
||||
pattern: ['**/*.vue'],
|
||||
},
|
||||
{
|
||||
builder: 'mkdist',
|
||||
format: 'esm',
|
||||
input: './src',
|
||||
loaders: ['js'],
|
||||
pattern: ['**/*.ts', '!**/*.test.ts', '!**/*.spec.ts'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@easyflow-core/editor-ui",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css",
|
||||
"**/*.vue"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./markdown-live-editor": {
|
||||
"types": "./src/MarkdownLiveEditor.vue",
|
||||
"development": "./src/MarkdownLiveEditor.vue",
|
||||
"default": "./dist/MarkdownLiveEditor.vue"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./markdown-live-editor": {
|
||||
"default": "./dist/MarkdownLiveEditor.vue"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/search": "^6.7.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@milkdown/crepe": "^7.21.2",
|
||||
"@milkdown/kit": "^7.21.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"unified": "^11.0.5",
|
||||
"vue": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import CodeEditor from './CodeEditor.vue';
|
||||
|
||||
const languageMocks = vi.hoisted(() => {
|
||||
let releasePython = () => undefined;
|
||||
const pythonReady = new Promise<void>((resolve) => {
|
||||
releasePython = resolve;
|
||||
});
|
||||
return {
|
||||
javascript: vi.fn(() => []),
|
||||
python: vi.fn(() => []),
|
||||
pythonReady,
|
||||
releasePython: () => releasePython(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@codemirror/lang-python', async () => {
|
||||
await languageMocks.pythonReady;
|
||||
return { python: languageMocks.python };
|
||||
});
|
||||
|
||||
vi.mock('@codemirror/lang-javascript', () => ({
|
||||
javascript: languageMocks.javascript,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
languageMocks.javascript.mockReset().mockReturnValue([]);
|
||||
languageMocks.python.mockReset().mockReturnValue([]);
|
||||
});
|
||||
|
||||
it('mounts the latest language when it changes during initial loading', async () => {
|
||||
const wrapper = mount(CodeEditor, {
|
||||
attachTo: document.body,
|
||||
props: { language: 'python', modelValue: 'const value = 1;' },
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(wrapper.attributes('aria-busy')).toBe('true');
|
||||
expect(wrapper.text()).toContain('正在加载编辑器');
|
||||
|
||||
await wrapper.setProps({ language: 'javascript' });
|
||||
await flushPromises();
|
||||
|
||||
expect(languageMocks.javascript).toHaveBeenCalledOnce();
|
||||
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
|
||||
|
||||
languageMocks.releasePython();
|
||||
await flushPromises();
|
||||
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('shows a recoverable state when a language extension fails', async () => {
|
||||
let releaseRetry = () => undefined;
|
||||
const retryReady = new Promise<never[]>((resolve) => {
|
||||
releaseRetry = () => resolve([]);
|
||||
});
|
||||
languageMocks.javascript.mockImplementationOnce(() => {
|
||||
throw new Error('load failed');
|
||||
});
|
||||
languageMocks.javascript.mockImplementationOnce(() => retryReady as never);
|
||||
const wrapper = mount(CodeEditor, {
|
||||
attachTo: document.body,
|
||||
props: { language: 'javascript', modelValue: 'const value = 1;' },
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('编辑器加载失败');
|
||||
expect(wrapper.find('.cm-editor').exists()).toBe(false);
|
||||
|
||||
await wrapper.get('[role="alert"] button').trigger('click');
|
||||
expect(wrapper.attributes('aria-busy')).toBe('true');
|
||||
expect(wrapper.text()).toContain('正在加载编辑器');
|
||||
|
||||
releaseRetry();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
|
||||
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import type { Extension } from '@codemirror/state';
|
||||
|
||||
import type { EditorLanguage } from './types';
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import {
|
||||
defaultKeymap,
|
||||
history,
|
||||
historyKeymap,
|
||||
indentWithTab,
|
||||
} from '@codemirror/commands';
|
||||
import {
|
||||
bracketMatching,
|
||||
defaultHighlightStyle,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import {
|
||||
closeSearchPanel,
|
||||
highlightSelectionMatches,
|
||||
openSearchPanel,
|
||||
searchKeymap,
|
||||
} from '@codemirror/search';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
placeholder as editorPlaceholder,
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
autofocus?: boolean;
|
||||
disabled?: boolean;
|
||||
language?: EditorLanguage;
|
||||
lineWrapping?: boolean;
|
||||
loading?: boolean;
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
autofocus: false,
|
||||
disabled: false,
|
||||
language: 'text',
|
||||
lineWrapping: true,
|
||||
loading: false,
|
||||
placeholder: '',
|
||||
readonly: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
cursorChange: [{ column: number; line: number }];
|
||||
save: [];
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const hostRef = ref<HTMLElement>();
|
||||
const viewRef = shallowRef<EditorView>();
|
||||
const languageError = ref('');
|
||||
const languageLoading = ref(false);
|
||||
const languageSlot = new Compartment();
|
||||
const readonlySlot = new Compartment();
|
||||
const wrappingSlot = new Compartment();
|
||||
let languageRequest = 0;
|
||||
let syncingFromOutside = false;
|
||||
|
||||
async function loadLanguage(language: EditorLanguage): Promise<Extension> {
|
||||
if (language === 'python') {
|
||||
const { python } = await import('@codemirror/lang-python');
|
||||
return python();
|
||||
}
|
||||
if (language === 'javascript' || language === 'json') {
|
||||
const { javascript } = await import('@codemirror/lang-javascript');
|
||||
return javascript({ jsx: language === 'javascript', typescript: true });
|
||||
}
|
||||
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'),
|
||||
]);
|
||||
return StreamLanguage.define(shell);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function readonlyExtensions(readonly: boolean): Extension[] {
|
||||
return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
|
||||
}
|
||||
|
||||
function isReadonly() {
|
||||
return props.readonly || props.disabled || props.loading;
|
||||
}
|
||||
|
||||
function wrappingExtension(enabled: boolean): Extension {
|
||||
return enabled ? EditorView.lineWrapping : [];
|
||||
}
|
||||
|
||||
function createState(doc: string, language: Extension) {
|
||||
return EditorState.create({
|
||||
doc,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
foldGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
highlightActiveLine(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSelectionMatches(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
editorPlaceholder(props.placeholder),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
if (isReadonly()) return true;
|
||||
emit('save');
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: (view) => {
|
||||
if (closeSearchPanel(view)) return true;
|
||||
view.contentDOM.blur();
|
||||
hostRef.value?.focus();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
indentWithTab,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap,
|
||||
...foldKeymap,
|
||||
]),
|
||||
languageSlot.of(language),
|
||||
readonlySlot.of(readonlyExtensions(isReadonly())),
|
||||
wrappingSlot.of(wrappingExtension(props.lineWrapping)),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !syncingFromOutside) {
|
||||
emit('update:modelValue', update.state.doc.toString());
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
const head = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
emit('cursorChange', {
|
||||
column: head - line.from + 1,
|
||||
line: line.number,
|
||||
});
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
backgroundColor: 'hsl(var(--surface-panel))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
fontSize: '13px',
|
||||
height: '100%',
|
||||
},
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-content': { caretColor: 'hsl(var(--primary))', padding: '16px 0' },
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'hsl(var(--primary))',
|
||||
},
|
||||
'.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'hsl(var(--primary) / 0.16)',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'hsl(var(--surface-subtle))',
|
||||
borderRight: '1px solid hsl(var(--line-subtle))',
|
||||
color: 'hsl(var(--text-muted))',
|
||||
},
|
||||
'.cm-activeLine, .cm-activeLineGutter': {
|
||||
backgroundColor: 'hsl(var(--nav-item-hover) / 0.62)',
|
||||
},
|
||||
'.cm-foldPlaceholder': {
|
||||
backgroundColor: 'hsl(var(--surface-contrast-soft))',
|
||||
border: '0',
|
||||
color: 'hsl(var(--text-muted))',
|
||||
},
|
||||
'.cm-search': {
|
||||
backgroundColor: 'hsl(var(--surface-elevated))',
|
||||
borderBottom: '1px solid hsl(var(--line-subtle))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
padding: '8px 12px',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function mountEditor() {
|
||||
if (!hostRef.value) return;
|
||||
const request = ++languageRequest;
|
||||
languageError.value = '';
|
||||
languageLoading.value = true;
|
||||
try {
|
||||
const language = await loadLanguage(props.language);
|
||||
if (!hostRef.value || request !== languageRequest) return;
|
||||
viewRef.value?.destroy();
|
||||
viewRef.value = new EditorView({
|
||||
parent: hostRef.value,
|
||||
state: createState(props.modelValue || '', language),
|
||||
});
|
||||
if (props.autofocus) viewRef.value.focus();
|
||||
} catch {
|
||||
if (request === languageRequest) languageError.value = '编辑器加载失败';
|
||||
} finally {
|
||||
if (request === languageRequest) languageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateLanguage(language: EditorLanguage) {
|
||||
const view = viewRef.value;
|
||||
if (!view) return;
|
||||
const request = ++languageRequest;
|
||||
languageError.value = '';
|
||||
languageLoading.value = true;
|
||||
try {
|
||||
const extension = await loadLanguage(language);
|
||||
if (!viewRef.value || request !== languageRequest) return;
|
||||
viewRef.value.dispatch({ effects: languageSlot.reconfigure(extension) });
|
||||
} catch {
|
||||
if (request === languageRequest) languageError.value = '语法高亮加载失败';
|
||||
} finally {
|
||||
if (request === languageRequest) languageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLanguageChange(language: EditorLanguage) {
|
||||
if (viewRef.value) {
|
||||
void updateLanguage(language);
|
||||
return;
|
||||
}
|
||||
// 首次语言扩展仍在加载时重新发起挂载;generation 会使旧请求失效。
|
||||
void mountEditor();
|
||||
}
|
||||
|
||||
function retryLanguage() {
|
||||
if (viewRef.value) void updateLanguage(props.language);
|
||||
else void mountEditor();
|
||||
}
|
||||
|
||||
function focus() {
|
||||
viewRef.value?.focus();
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
if (viewRef.value) openSearchPanel(viewRef.value);
|
||||
}
|
||||
|
||||
function scrollToLine(lineNumber: number, column = 1) {
|
||||
const view = viewRef.value;
|
||||
if (!view) return;
|
||||
const safeLine = Math.min(Math.max(lineNumber, 1), view.state.doc.lines);
|
||||
const line = view.state.doc.line(safeLine);
|
||||
const position = Math.min(line.from + Math.max(column - 1, 0), line.to);
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(position, { y: 'center' }),
|
||||
selection: { anchor: position },
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value = '') => {
|
||||
const view = viewRef.value;
|
||||
if (!view) return;
|
||||
const nextValue = value;
|
||||
const currentValue = view.state.doc.toString();
|
||||
if (nextValue === currentValue) return;
|
||||
syncingFromOutside = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, insert: nextValue, to: currentValue.length },
|
||||
});
|
||||
syncingFromOutside = false;
|
||||
},
|
||||
);
|
||||
|
||||
watch(() => props.language, handleLanguageChange);
|
||||
|
||||
watch(
|
||||
() => [props.readonly, props.disabled, props.loading],
|
||||
() => {
|
||||
viewRef.value?.dispatch({
|
||||
effects: readonlySlot.reconfigure(readonlyExtensions(isReadonly())),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.lineWrapping,
|
||||
(value) => {
|
||||
viewRef.value?.dispatch({
|
||||
effects: wrappingSlot.reconfigure(wrappingExtension(value)),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(mountEditor);
|
||||
onBeforeUnmount(() => {
|
||||
languageRequest++;
|
||||
viewRef.value?.destroy();
|
||||
});
|
||||
|
||||
defineExpose({ focus, openSearch, scrollToLine });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="hostRef"
|
||||
class="easyflow-code-editor"
|
||||
:class="{
|
||||
'is-disabled': disabled,
|
||||
'is-loading': loading || languageLoading,
|
||||
}"
|
||||
:aria-busy="loading || languageLoading"
|
||||
:aria-disabled="disabled || loading"
|
||||
role="region"
|
||||
tabindex="-1"
|
||||
aria-label="代码编辑器,按 Esc 退出编辑区域"
|
||||
>
|
||||
<div
|
||||
v-if="loading || languageLoading"
|
||||
class="easyflow-code-editor__loading"
|
||||
aria-live="polite"
|
||||
>
|
||||
正在加载编辑器…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="languageError"
|
||||
class="easyflow-code-editor__error"
|
||||
role="alert"
|
||||
>
|
||||
<span>{{ languageError }}</span>
|
||||
<button type="button" @click="retryLanguage">重试</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.easyflow-code-editor {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-control);
|
||||
transition:
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.easyflow-code-editor:focus-within {
|
||||
border-color: hsl(var(--primary) / 72%);
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 12%);
|
||||
}
|
||||
|
||||
.easyflow-code-editor.is-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.easyflow-code-editor__loading,
|
||||
.easyflow-code-editor__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: hsl(var(--text-muted));
|
||||
pointer-events: auto;
|
||||
background: hsl(var(--surface-panel) / 90%);
|
||||
}
|
||||
|
||||
.easyflow-code-editor__error {
|
||||
gap: var(--space-2);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.easyflow-code-editor__error button {
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--nav-item-active));
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.easyflow-code-editor__error button:hover {
|
||||
background: hsl(var(--nav-tool-hover));
|
||||
}
|
||||
|
||||
.easyflow-code-editor__error button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import CodeViewer from './CodeViewer.vue';
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: originalCreateObjectURL,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: originalRevokeObjectURL,
|
||||
});
|
||||
});
|
||||
|
||||
describe('code viewer', () => {
|
||||
it('copies visible content and emits the shared download action', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const wrapper = mount(CodeViewer, {
|
||||
global: { stubs: { CodeEditor: true } },
|
||||
props: {
|
||||
content: '{"ready":true}',
|
||||
downloadMode: 'emit',
|
||||
filename: 'references/data.json',
|
||||
language: 'json',
|
||||
},
|
||||
});
|
||||
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === '复制');
|
||||
const downloadButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === '下载');
|
||||
if (!copyButton || !downloadButton) throw new Error('缺少查看器操作按钮');
|
||||
|
||||
await copyButton.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('{"ready":true}');
|
||||
expect(copyButton.text()).toBe('已复制');
|
||||
|
||||
await downloadButton.trigger('click');
|
||||
expect(wrapper.emitted('download')).toHaveLength(1);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('downloads an empty local text file and always releases its object URL', async () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:empty-source');
|
||||
const revokeObjectURL = vi.fn();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: createObjectURL,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: revokeObjectURL,
|
||||
});
|
||||
const click = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
const wrapper = mount(CodeViewer, {
|
||||
global: { stubs: { CodeEditor: true } },
|
||||
props: { content: '', filename: 'empty.txt' },
|
||||
});
|
||||
const downloadButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === '下载');
|
||||
|
||||
await downloadButton?.trigger('click');
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(createObjectURL.mock.calls[0]?.[0]).toBeInstanceOf(Blob);
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:empty-source');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('leaves fullscreen mode with Escape', async () => {
|
||||
const wrapper = mount(CodeViewer, {
|
||||
global: { stubs: { CodeEditor: true } },
|
||||
props: { content: 'echo ready', filename: 'scripts/check.sh' },
|
||||
});
|
||||
const fullscreenButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === '全屏');
|
||||
|
||||
await fullscreenButton?.trigger('click');
|
||||
expect(wrapper.classes()).toContain('is-fullscreen');
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === '退出全屏')
|
||||
?.attributes('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.classes()).not.toContain('is-fullscreen');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorLanguage } from './types';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import CodeEditor from './CodeEditor.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content: string;
|
||||
downloadMode?: 'emit' | 'local';
|
||||
filename?: string;
|
||||
hash?: string;
|
||||
highlight?: boolean;
|
||||
language?: EditorLanguage;
|
||||
size?: number;
|
||||
}>(),
|
||||
{
|
||||
filename: 'source.txt',
|
||||
downloadMode: 'local',
|
||||
hash: '',
|
||||
highlight: true,
|
||||
language: 'text',
|
||||
size: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
download: [];
|
||||
error: [error: Error];
|
||||
}>();
|
||||
|
||||
const editorRef = ref<InstanceType<typeof CodeEditor>>();
|
||||
const fullscreen = ref(false);
|
||||
const copied = ref(false);
|
||||
let copiedTimer: number | undefined;
|
||||
const sizeLabel = computed(() => {
|
||||
const bytes = props.size ?? new TextEncoder().encode(props.content).length;
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
});
|
||||
|
||||
async function copyContent() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.content);
|
||||
copied.value = true;
|
||||
window.clearTimeout(copiedTimer);
|
||||
copiedTimer = window.setTimeout(() => (copied.value = false), 1600);
|
||||
} catch (error_) {
|
||||
copied.value = false;
|
||||
emit('error', error_ instanceof Error ? error_ : new Error('复制失败'));
|
||||
}
|
||||
}
|
||||
|
||||
function downloadContent() {
|
||||
if (props.downloadMode === 'emit') {
|
||||
emit('download');
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([props.content], { type: 'text/plain;charset=utf-8' }),
|
||||
);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = props.filename;
|
||||
try {
|
||||
anchor.click();
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && fullscreen.value) {
|
||||
event.preventDefault();
|
||||
fullscreen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', handleKeydown));
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(copiedTimer);
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="easyflow-code-viewer"
|
||||
:class="{ 'is-fullscreen': fullscreen }"
|
||||
:aria-label="`${filename} 只读内容`"
|
||||
>
|
||||
<header class="easyflow-code-viewer__toolbar">
|
||||
<div class="easyflow-code-viewer__identity">
|
||||
<strong>{{ filename }}</strong>
|
||||
<span>{{ language }}</span>
|
||||
<span>{{ sizeLabel }}</span>
|
||||
<span v-if="hash" :title="hash">{{ hash.slice(0, 10) }}</span>
|
||||
</div>
|
||||
<div class="easyflow-code-viewer__actions">
|
||||
<button type="button" @click="editorRef?.openSearch()">搜索</button>
|
||||
<button type="button" @click="copyContent">
|
||||
<span aria-live="polite">{{ copied ? '已复制' : '复制' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-pressed="fullscreen"
|
||||
@click="fullscreen = !fullscreen"
|
||||
>
|
||||
{{ fullscreen ? '退出全屏' : '全屏' }}
|
||||
</button>
|
||||
<button type="button" @click="downloadContent">下载</button>
|
||||
</div>
|
||||
</header>
|
||||
<CodeEditor
|
||||
ref="editorRef"
|
||||
class="easyflow-code-viewer__editor"
|
||||
:language="highlight ? language : 'text'"
|
||||
:model-value="content"
|
||||
readonly
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.easyflow-code-viewer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.easyflow-code-viewer.is-fullscreen {
|
||||
position: fixed;
|
||||
inset: var(--space-4);
|
||||
z-index: var(--popup-z-index);
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__identity,
|
||||
.easyflow-code-viewer__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__identity span {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__actions button {
|
||||
padding: 5px 9px;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__actions button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__actions button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.easyflow-code-viewer__editor {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
|
||||
|
||||
describe('markdown live editor DOM safety', () => {
|
||||
it('renders raw HTML as text and removes unsafe link navigation', async () => {
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
modelValue: [
|
||||
'<img src="x" onerror="window.__skillXss = true">',
|
||||
'',
|
||||
'[unsafe](javascript:alert(1))',
|
||||
].join('\n'),
|
||||
},
|
||||
});
|
||||
|
||||
for (let index = 0; index < 6; index++) await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.element.querySelector('img[onerror], script, iframe'),
|
||||
).toBeNull();
|
||||
expect(wrapper.text()).toContain('<img src="x"');
|
||||
expect(wrapper.element.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps an ordinary tight list in the live editor without fidelity loss', async () => {
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: '- first\n- second' },
|
||||
});
|
||||
|
||||
for (let index = 0; index < 6; index++) await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain('first');
|
||||
expect(wrapper.text()).toContain('second');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not rewrite a loaded Skill instruction document', async () => {
|
||||
const source = [
|
||||
'# Instructions',
|
||||
'',
|
||||
'Use this skill to verify the complete Skill management workflow.',
|
||||
'',
|
||||
'## Expected result',
|
||||
'',
|
||||
'- Markdown renders while editing.',
|
||||
'- Scripts are displayed and edited without execution.',
|
||||
'- Assets can be previewed safely.',
|
||||
].join('\n');
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: source },
|
||||
});
|
||||
|
||||
for (let index = 0; index < 8; index++) await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders a YAML frontmatter block as a read-only atom and preserves it on round-trip', async () => {
|
||||
const source = [
|
||||
'---',
|
||||
'name: research-skill',
|
||||
'description: Research helper for the team.',
|
||||
'---',
|
||||
'',
|
||||
'# Instructions',
|
||||
'',
|
||||
'Use this skill to look up references.',
|
||||
].join('\n');
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: source },
|
||||
});
|
||||
|
||||
for (let index = 0; index < 8; index++) await flushPromises();
|
||||
|
||||
const frontmatterNode = wrapper.element.querySelector(
|
||||
'.milkdown-frontmatter',
|
||||
);
|
||||
expect(frontmatterNode).not.toBeNull();
|
||||
expect(wrapper.text()).toContain('Skill 元数据');
|
||||
expect(wrapper.text()).toContain('name: research-skill');
|
||||
expect(wrapper.text()).toContain('Use this skill');
|
||||
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');
|
||||
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('emits frontmatterActivate when the frontmatter block is clicked', async () => {
|
||||
const source = ['---', 'name: research-skill', '---', '', '# Body'].join(
|
||||
'\n',
|
||||
);
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
attachTo: document.body,
|
||||
props: { modelValue: source },
|
||||
});
|
||||
|
||||
for (let index = 0; index < 8; index++) await flushPromises();
|
||||
|
||||
const frontmatterNode = wrapper.element.querySelector<HTMLElement>(
|
||||
'.milkdown-frontmatter',
|
||||
);
|
||||
expect(frontmatterNode).not.toBeNull();
|
||||
frontmatterNode?.click();
|
||||
|
||||
expect(wrapper.emitted('frontmatterActivate')).toHaveLength(1);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
destroy: vi.fn(async () => undefined),
|
||||
listSpreadNodes: [] as Array<{
|
||||
attrs: Record<string, unknown>;
|
||||
marks: unknown[];
|
||||
position: number;
|
||||
type: { name: string };
|
||||
}>,
|
||||
createPromise: undefined as Promise<void> | undefined,
|
||||
markdown: '# Initial',
|
||||
markdownUpdated: undefined as
|
||||
| ((ctx: unknown, value: string) => void)
|
||||
| undefined,
|
||||
replaceAll: vi.fn(),
|
||||
setNodeMarkup: vi.fn(),
|
||||
setReadonly: vi.fn(),
|
||||
setTransactionMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@milkdown/kit/utils', () => ({
|
||||
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
|
||||
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
|
||||
replaceAll: (value: string, flush: boolean) => {
|
||||
mockState.replaceAll(value, flush);
|
||||
return () => {
|
||||
mockState.markdown = value;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@milkdown/crepe', () => {
|
||||
class MockCrepe {
|
||||
static Feature = {
|
||||
AI: 'ai',
|
||||
ImageBlock: 'image-block',
|
||||
Latex: 'latex',
|
||||
Placeholder: 'placeholder',
|
||||
TopBar: 'top-bar',
|
||||
};
|
||||
|
||||
create = vi.fn(async () => mockState.createPromise);
|
||||
|
||||
destroy = mockState.destroy;
|
||||
|
||||
editor = {
|
||||
action: (action: (ctx: unknown) => void) =>
|
||||
action({
|
||||
get: (slice: { name?: string }) => {
|
||||
if (slice.name === 'editorState') {
|
||||
return {
|
||||
doc: {
|
||||
descendants: (
|
||||
callback: (
|
||||
node: (typeof mockState.listSpreadNodes)[number],
|
||||
position: number,
|
||||
) => void,
|
||||
) =>
|
||||
mockState.listSpreadNodes.forEach((node) =>
|
||||
callback(node, node.position),
|
||||
),
|
||||
},
|
||||
tr: {
|
||||
setMeta: mockState.setTransactionMeta,
|
||||
setNodeMarkup: mockState.setNodeMarkup,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (slice.name === 'editorView') {
|
||||
return { dispatch: mockState.dispatch };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
config: () => undefined,
|
||||
use: () => undefined,
|
||||
};
|
||||
setReadonly = mockState.setReadonly;
|
||||
constructor(config: { defaultValue?: string }) {
|
||||
mockState.markdown = config.defaultValue || '';
|
||||
}
|
||||
getMarkdown = () => mockState.markdown;
|
||||
on = (
|
||||
register: (listener: {
|
||||
markdownUpdated: (callback: typeof mockState.markdownUpdated) => void;
|
||||
}) => void,
|
||||
) => {
|
||||
register({
|
||||
markdownUpdated: (callback) => (mockState.markdownUpdated = callback),
|
||||
});
|
||||
};
|
||||
}
|
||||
return { Crepe: MockCrepe };
|
||||
});
|
||||
|
||||
describe('markdownLiveEditor', () => {
|
||||
beforeEach(() => {
|
||||
mockState.destroy.mockClear();
|
||||
mockState.createPromise = undefined;
|
||||
mockState.dispatch.mockClear();
|
||||
mockState.replaceAll.mockClear();
|
||||
mockState.setReadonly.mockClear();
|
||||
mockState.markdown = '# Initial';
|
||||
mockState.markdownUpdated = undefined;
|
||||
mockState.listSpreadNodes.length = 0;
|
||||
mockState.setNodeMarkup.mockClear();
|
||||
mockState.setTransactionMeta.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('uses a flush replace for external values without emitting a delayed model loop', async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '# Initial' },
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.setProps({ modelValue: '# External' });
|
||||
expect(mockState.replaceAll).toHaveBeenCalledWith('# External', true);
|
||||
|
||||
vi.advanceTimersByTime(250);
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
|
||||
await wrapper
|
||||
.get('.easyflow-markdown-live-editor__host')
|
||||
.trigger('beforeinput');
|
||||
mockState.markdownUpdated?.({}, '# User edit');
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
||||
'# User edit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not mark the document changed while Crepe normalizes its initial value', async () => {
|
||||
let resolveCreate: (() => void) | undefined;
|
||||
mockState.createPromise = new Promise<void>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '# Initial' },
|
||||
});
|
||||
mockState.markdownUpdated?.({}, '# Normalized during create');
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
|
||||
resolveCreate?.();
|
||||
await flushPromises();
|
||||
await wrapper
|
||||
.get('.easyflow-markdown-live-editor__host')
|
||||
.trigger('beforeinput');
|
||||
mockState.markdownUpdated?.({}, '# User edit');
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
|
||||
'# User edit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores lossless normalization emitted after the editor is ready', async () => {
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '- first\n- second' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
mockState.markdownUpdated?.({}, '* first\n* second\n');
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('locks the live editor when initial markdown cannot be preserved', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = '* normalized';
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '- original' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
|
||||
mockState.markdownUpdated?.({}, '* normalized');
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
|
||||
mockState.markdownUpdated?.({}, '* user edit');
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts line-ending and final-newline normalization as lossless', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = '# Heading\n';
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '# Heading\r\n' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('accepts common Crepe list and delimiter normalization as lossless', async () => {
|
||||
const source = [
|
||||
'# Review',
|
||||
'',
|
||||
'- first',
|
||||
'- second',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'| A | B |',
|
||||
'| --- | :---: |',
|
||||
'| 1 | 2 |',
|
||||
].join('\n');
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = [
|
||||
'# Review',
|
||||
'',
|
||||
'* first',
|
||||
'* second',
|
||||
'',
|
||||
'***',
|
||||
'',
|
||||
'| A | B |',
|
||||
'| - | :-: |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
].join('\n');
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: source },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('keeps internal list spread repairs out of undo history', async () => {
|
||||
mockState.listSpreadNodes.push({
|
||||
attrs: { spread: 'false' },
|
||||
marks: [],
|
||||
position: 4,
|
||||
type: { name: 'bullet_list' },
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '- first\n- second' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
|
||||
4,
|
||||
undefined,
|
||||
{ spread: false },
|
||||
[],
|
||||
);
|
||||
expect(mockState.setTransactionMeta).toHaveBeenCalledWith(
|
||||
'addToHistory',
|
||||
false,
|
||||
);
|
||||
expect(mockState.dispatch).toHaveBeenCalledOnce();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('repairs delayed list spread normalization before updating the model', async () => {
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '- first\n- second' },
|
||||
});
|
||||
await flushPromises();
|
||||
mockState.listSpreadNodes.push({
|
||||
attrs: { spread: 'false' },
|
||||
marks: [],
|
||||
position: 4,
|
||||
type: { name: 'bullet_list' },
|
||||
});
|
||||
mockState.markdown = '* first\n\n* second';
|
||||
mockState.dispatch.mockImplementationOnce(() => {
|
||||
mockState.markdown = '* first\n* second';
|
||||
});
|
||||
|
||||
mockState.markdownUpdated?.({}, '* first\n\n* second');
|
||||
|
||||
expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
|
||||
4,
|
||||
undefined,
|
||||
{ spread: false },
|
||||
[],
|
||||
);
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not mask changes inside fenced code blocks', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = ['```text', '* changed', '```'].join('\n');
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: {
|
||||
modelValue: ['```text', '- changed', '```'].join('\n'),
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not mask changes inside indented code blocks', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = ' * changed';
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: ' - changed' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not treat a setext heading underline as a thematic break', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = ['Heading', '***'].join('\n');
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: ['Heading', '---'].join('\n') },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not merge lists separated by different bullet markers', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = '* first\n* second';
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '- first\n* second' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not collapse a tight list into a loose list', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = '* first\n\n* second';
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '- first\n- second' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not normalize invalid table-like text as a GFM table', async () => {
|
||||
mockState.createPromise = Promise.resolve().then(() => {
|
||||
mockState.markdown = 'A | B | C\n- | -';
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: 'A | B | C\n--- | ---' },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('awaits editor destruction when unmounted', async () => {
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '# Initial' },
|
||||
});
|
||||
await flushPromises();
|
||||
wrapper.unmount();
|
||||
await flushPromises();
|
||||
expect(mockState.destroy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('applies the latest model value when it changes during creation', async () => {
|
||||
let resolveCreate: (() => void) | undefined;
|
||||
mockState.createPromise = new Promise<void>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '# Initial' },
|
||||
});
|
||||
await wrapper.setProps({ modelValue: '# Latest' });
|
||||
resolveCreate?.();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockState.replaceAll).toHaveBeenCalledWith('# Latest', true);
|
||||
});
|
||||
|
||||
it('applies the latest readonly state when it changes during creation', async () => {
|
||||
let resolveCreate: (() => void) | undefined;
|
||||
mockState.createPromise = new Promise<void>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
const wrapper = mount(MarkdownLiveEditor, {
|
||||
props: { modelValue: '# Initial', readonly: false },
|
||||
});
|
||||
await wrapper.setProps({ readonly: true });
|
||||
resolveCreate?.();
|
||||
await flushPromises();
|
||||
|
||||
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,630 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
|
||||
import { Crepe } from '@milkdown/crepe';
|
||||
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
|
||||
import { replaceAll } from '@milkdown/kit/utils';
|
||||
|
||||
import {
|
||||
hasEquivalentMarkdownSemantics,
|
||||
normalizeLiveMarkdownUpdate,
|
||||
} from './markdown-fidelity';
|
||||
import {
|
||||
isSafeMarkdownLinkHref,
|
||||
resolveSafeExternalMarkdownImageUrl,
|
||||
} from './markdown-security';
|
||||
import { installFrontmatter } from './milkdown-frontmatter';
|
||||
|
||||
import '@milkdown/crepe/theme/common/style.css';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
resolveImageUrl?: (url: string) => Promise<string> | string;
|
||||
uploadImage?: (file: File) => Promise<string>;
|
||||
}>(),
|
||||
{
|
||||
placeholder: '输入 Skill 指令…',
|
||||
readonly: false,
|
||||
resolveImageUrl: undefined,
|
||||
uploadImage: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: Error];
|
||||
fidelityLoss: [];
|
||||
frontmatterActivate: [];
|
||||
linkClick: [href: string];
|
||||
ready: [];
|
||||
save: [];
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const hostRef = ref<HTMLElement>();
|
||||
const crepeRef = shallowRef<Crepe>();
|
||||
const loading = ref(true);
|
||||
const failed = ref(false);
|
||||
const fidelityLoss = ref(false);
|
||||
let destroyed = false;
|
||||
let hasUserEdited = false;
|
||||
let ready = false;
|
||||
let syncingFromOutside = false;
|
||||
let synchronizedMarkdown = '';
|
||||
let domObserver: MutationObserver | undefined;
|
||||
|
||||
async function createEditor() {
|
||||
if (!hostRef.value) return;
|
||||
const initialModelValue = props.modelValue || '';
|
||||
loading.value = true;
|
||||
failed.value = false;
|
||||
fidelityLoss.value = false;
|
||||
const crepe = new Crepe({
|
||||
defaultValue: initialModelValue,
|
||||
featureConfigs: {
|
||||
[Crepe.Feature.ImageBlock]: {
|
||||
onUpload: async (file) => {
|
||||
if (props.uploadImage) return props.uploadImage(file);
|
||||
const error = new Error(
|
||||
'当前上下文不支持直接上传图片,请从资源文件树上传',
|
||||
);
|
||||
emit('error', error);
|
||||
throw error;
|
||||
},
|
||||
proxyDomURL: (url) =>
|
||||
props.resolveImageUrl
|
||||
? props.resolveImageUrl(url)
|
||||
: resolveSafeExternalMarkdownImageUrl(url),
|
||||
},
|
||||
[Crepe.Feature.Placeholder]: {
|
||||
mode: 'block',
|
||||
text: props.placeholder,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
[Crepe.Feature.AI]: false,
|
||||
[Crepe.Feature.ImageBlock]: true,
|
||||
[Crepe.Feature.Latex]: false,
|
||||
[Crepe.Feature.TopBar]: false,
|
||||
},
|
||||
root: hostRef.value,
|
||||
});
|
||||
installFrontmatter(crepe.editor);
|
||||
crepe.setReadonly(props.readonly);
|
||||
crepe.on((listener) => {
|
||||
listener.markdownUpdated((_ctx, markdown) => {
|
||||
const repairedListSpread = normalizeListSpreadAttributes(crepe);
|
||||
const editorMarkdown = repairedListSpread
|
||||
? crepe.getMarkdown()
|
||||
: markdown;
|
||||
const nextMarkdown = normalizeLiveMarkdownUpdate(
|
||||
props.modelValue || '',
|
||||
editorMarkdown,
|
||||
);
|
||||
if (
|
||||
!ready ||
|
||||
fidelityLoss.value ||
|
||||
syncingFromOutside ||
|
||||
!hasUserEdited ||
|
||||
nextMarkdown === props.modelValue ||
|
||||
nextMarkdown === synchronizedMarkdown ||
|
||||
!hasMeaningfulMarkdownDifference(props.modelValue || '', nextMarkdown)
|
||||
) {
|
||||
synchronizedMarkdown = nextMarkdown;
|
||||
return;
|
||||
}
|
||||
synchronizedMarkdown = nextMarkdown;
|
||||
emit('update:modelValue', nextMarkdown);
|
||||
});
|
||||
});
|
||||
try {
|
||||
await crepe.create();
|
||||
if (destroyed) {
|
||||
await crepe.destroy();
|
||||
return;
|
||||
}
|
||||
const latestModelValue = props.modelValue || '';
|
||||
if (latestModelValue !== initialModelValue) {
|
||||
syncingFromOutside = true;
|
||||
try {
|
||||
crepe.editor.action(replaceAll(latestModelValue, true));
|
||||
} finally {
|
||||
syncingFromOutside = false;
|
||||
}
|
||||
}
|
||||
normalizeListSpreadAttributes(crepe);
|
||||
synchronizedMarkdown = normalizeLiveMarkdownUpdate(
|
||||
latestModelValue,
|
||||
crepe.getMarkdown(),
|
||||
);
|
||||
if (
|
||||
hasMeaningfulMarkdownDifference(latestModelValue, synchronizedMarkdown)
|
||||
) {
|
||||
reportFidelityLoss(crepe);
|
||||
}
|
||||
crepe.setReadonly(props.readonly || fidelityLoss.value);
|
||||
crepeRef.value = crepe;
|
||||
ready = true;
|
||||
emit('ready');
|
||||
} catch (error_) {
|
||||
failed.value = true;
|
||||
const error =
|
||||
error_ instanceof Error ? error_ : new Error('实时编辑器加载失败');
|
||||
emit('error', error);
|
||||
try {
|
||||
await crepe.destroy();
|
||||
} catch {
|
||||
// Best-effort cleanup after a partial editor initialization.
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
const crepe = crepeRef.value;
|
||||
if (!crepe) return;
|
||||
const editorMarkdown = crepe.getMarkdown();
|
||||
const comparableMarkdown = normalizeLiveMarkdownUpdate(
|
||||
value || '',
|
||||
editorMarkdown,
|
||||
);
|
||||
if (comparableMarkdown === (value || '')) {
|
||||
synchronizedMarkdown = comparableMarkdown;
|
||||
return;
|
||||
}
|
||||
syncingFromOutside = true;
|
||||
hasUserEdited = false;
|
||||
try {
|
||||
crepe.editor.action(replaceAll(value || '', true));
|
||||
normalizeListSpreadAttributes(crepe);
|
||||
synchronizedMarkdown = crepe.getMarkdown();
|
||||
if (hasMeaningfulMarkdownDifference(value || '', synchronizedMarkdown)) {
|
||||
reportFidelityLoss(crepe);
|
||||
}
|
||||
} finally {
|
||||
syncingFromOutside = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.readonly,
|
||||
(value) => crepeRef.value?.setReadonly(value || fidelityLoss.value),
|
||||
);
|
||||
|
||||
onMounted(createEditor);
|
||||
onMounted(() => {
|
||||
hostRef.value?.addEventListener('click', handleLinkClick);
|
||||
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
|
||||
hostRef.value?.addEventListener('beforeinput', markUserEdited);
|
||||
hostRef.value?.addEventListener('drop', markUserEdited);
|
||||
hostRef.value?.addEventListener('keydown', handleKeydown);
|
||||
hostRef.value?.addEventListener('paste', markUserEdited);
|
||||
if (hostRef.value) {
|
||||
domObserver = new MutationObserver(sanitizeRenderedLinks);
|
||||
domObserver.observe(hostRef.value, {
|
||||
attributeFilter: ['href'],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
sanitizeRenderedLinks();
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(async () => {
|
||||
destroyed = true;
|
||||
ready = false;
|
||||
hostRef.value?.removeEventListener('click', handleLinkClick);
|
||||
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
|
||||
hostRef.value?.removeEventListener('beforeinput', markUserEdited);
|
||||
hostRef.value?.removeEventListener('drop', markUserEdited);
|
||||
hostRef.value?.removeEventListener('keydown', handleKeydown);
|
||||
hostRef.value?.removeEventListener('paste', markUserEdited);
|
||||
domObserver?.disconnect();
|
||||
domObserver = undefined;
|
||||
await crepeRef.value?.destroy();
|
||||
crepeRef.value = undefined;
|
||||
});
|
||||
|
||||
function handleLinkClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const anchor = target?.closest<HTMLAnchorElement>('a[href]');
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (!href || !isSafeMarkdownLinkHref(href)) {
|
||||
event.preventDefault();
|
||||
emit('error', new Error('该链接协议不受支持'));
|
||||
return;
|
||||
}
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.target = '_blank';
|
||||
return;
|
||||
}
|
||||
if (/^(?:mailto:|#)/i.test(href)) return;
|
||||
event.preventDefault();
|
||||
emit('linkClick', href);
|
||||
}
|
||||
|
||||
function handleFrontmatterActivate(event: Event) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const frontmatter = target.closest<HTMLElement>('.milkdown-frontmatter');
|
||||
if (!frontmatter) return;
|
||||
event.preventDefault();
|
||||
emit('frontmatterActivate');
|
||||
}
|
||||
|
||||
function markUserEdited() {
|
||||
hasUserEdited = true;
|
||||
}
|
||||
|
||||
function sanitizeRenderedLinks() {
|
||||
hostRef.value
|
||||
?.querySelectorAll<HTMLAnchorElement>('a[href]')
|
||||
.forEach((anchor) => {
|
||||
const href = anchor.getAttribute('href') || '';
|
||||
if (!isSafeMarkdownLinkHref(href)) {
|
||||
anchor.removeAttribute('href');
|
||||
anchor.setAttribute('aria-disabled', 'true');
|
||||
return;
|
||||
}
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.target = '_blank';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
if (!props.readonly) emit('save');
|
||||
}
|
||||
}
|
||||
|
||||
function reportFidelityLoss(crepe: Crepe) {
|
||||
if (fidelityLoss.value) return;
|
||||
fidelityLoss.value = true;
|
||||
crepe.setReadonly(true);
|
||||
emit('fidelityLoss');
|
||||
}
|
||||
|
||||
function hasMeaningfulMarkdownDifference(source: string, normalized: string) {
|
||||
return !hasEquivalentMarkdownSemantics(source, normalized);
|
||||
}
|
||||
|
||||
function normalizeListSpreadAttributes(crepe: Crepe) {
|
||||
let repaired = false;
|
||||
crepe.editor.action((ctx) => {
|
||||
const state = ctx.get(editorStateCtx);
|
||||
const transaction = state.tr;
|
||||
let changed = false;
|
||||
state.doc.descendants((node, position) => {
|
||||
if (
|
||||
!['bullet_list', 'list_item', 'ordered_list'].includes(node.type.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const spread = node.attrs.spread;
|
||||
if (spread !== 'true' && spread !== 'false') return;
|
||||
transaction.setNodeMarkup(
|
||||
position,
|
||||
undefined,
|
||||
{ ...node.attrs, spread: spread === 'true' },
|
||||
node.marks,
|
||||
);
|
||||
changed = true;
|
||||
});
|
||||
if (changed) {
|
||||
repaired = true;
|
||||
transaction.setMeta('addToHistory', false);
|
||||
ctx.get(editorViewCtx).dispatch(transaction);
|
||||
}
|
||||
});
|
||||
return repaired;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="easyflow-markdown-live-editor">
|
||||
<div
|
||||
ref="hostRef"
|
||||
class="easyflow-markdown-live-editor__host"
|
||||
role="region"
|
||||
aria-label="Markdown 实时编辑器"
|
||||
></div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="easyflow-markdown-live-editor__state"
|
||||
aria-live="polite"
|
||||
>
|
||||
正在加载编辑器…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="failed"
|
||||
class="easyflow-markdown-live-editor__state is-error"
|
||||
role="alert"
|
||||
>
|
||||
实时编辑器加载失败,请切换到源码模式继续编辑。
|
||||
</div>
|
||||
<div
|
||||
v-else-if="fidelityLoss"
|
||||
class="easyflow-markdown-live-editor__state is-error"
|
||||
role="alert"
|
||||
>
|
||||
当前内容包含实时编辑器无法保真处理的语法,请切换到源码模式。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.easyflow-markdown-live-editor {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: hsl(var(--surface-panel));
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor__host {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor__state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-panel) / 92%);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor__state.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.milkdown) {
|
||||
--crepe-color-background: hsl(var(--surface-panel));
|
||||
--crepe-color-on-background: hsl(var(--foreground));
|
||||
--crepe-color-surface: hsl(var(--surface-subtle));
|
||||
--crepe-color-surface-low: hsl(var(--surface-contrast-soft));
|
||||
--crepe-color-on-surface: hsl(var(--foreground));
|
||||
--crepe-color-on-surface-variant: hsl(var(--text-muted));
|
||||
--crepe-color-outline: hsl(var(--line-subtle));
|
||||
--crepe-color-primary: hsl(var(--primary));
|
||||
--crepe-color-secondary: hsl(var(--nav-item-active));
|
||||
--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-error: hsl(var(--destructive));
|
||||
--crepe-color-hover: hsl(var(--nav-item-hover));
|
||||
--crepe-color-selected: hsl(var(--nav-item-active));
|
||||
--crepe-color-inline-area: hsl(var(--surface-contrast-soft));
|
||||
--crepe-font-default: var(--font-family);
|
||||
--crepe-font-title: var(--font-family);
|
||||
--crepe-font-code:
|
||||
ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
|
||||
--crepe-shadow-1: var(--shadow-subtle);
|
||||
--crepe-shadow-2: var(--shadow-toolbar);
|
||||
|
||||
min-height: 100%;
|
||||
color: hsl(var(--foreground));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.ProseMirror) {
|
||||
box-sizing: border-box;
|
||||
width: min(100%, 960px);
|
||||
min-height: 100%;
|
||||
padding: var(--space-8) clamp(var(--space-4), 5vw, 72px) 64px;
|
||||
margin: 0 auto;
|
||||
font-size: 15px;
|
||||
line-height: 1.76;
|
||||
color: hsl(var(--text-strong));
|
||||
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(.milkdown-frontmatter) {
|
||||
box-sizing: border-box;
|
||||
margin-block: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 0;
|
||||
border-left: 3px solid hsl(var(--primary) / 36%);
|
||||
border-radius: 0;
|
||||
transition:
|
||||
border-color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color 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__title) {
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: hsl(var(--text-strong));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(a) {
|
||||
color: hsl(var(--primary));
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(h1),
|
||||
.easyflow-markdown-live-editor :deep(h2),
|
||||
.easyflow-markdown-live-editor :deep(h3),
|
||||
.easyflow-markdown-live-editor :deep(h4) {
|
||||
color: hsl(var(--text-strong));
|
||||
letter-spacing: -0.012em;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(h1) {
|
||||
padding-bottom: var(--space-3);
|
||||
margin-top: 0;
|
||||
font-size: 28px;
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(h2) {
|
||||
margin-top: var(--space-8);
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(h3) {
|
||||
margin-top: var(--space-6);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(p),
|
||||
.easyflow-markdown-live-editor :deep(ul),
|
||||
.easyflow-markdown-live-editor :deep(ol),
|
||||
.easyflow-markdown-live-editor :deep(blockquote),
|
||||
.easyflow-markdown-live-editor :deep(pre),
|
||||
.easyflow-markdown-live-editor :deep(table) {
|
||||
margin-block: var(--space-4);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(blockquote) {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-left: 3px solid hsl(var(--primary) / 48%);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(pre) {
|
||||
padding: var(--space-4);
|
||||
overflow: auto;
|
||||
background: hsl(var(--surface-contrast-soft));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.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(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(th),
|
||||
.easyflow-markdown-live-editor :deep(td) {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
text-align: left;
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(th) {
|
||||
font-weight: 600;
|
||||
background: hsl(var(--surface-subtle));
|
||||
}
|
||||
|
||||
@supports not (color: color-mix(in srgb, red, blue)) {
|
||||
.easyflow-markdown-live-editor :deep(.ProseMirror code),
|
||||
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
|
||||
background: var(--crepe-color-inline-area);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.ProseMirror hr),
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-toolbar .divider),
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-top-bar .top-bar-divider) {
|
||||
background-color: var(--crepe-color-outline);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor
|
||||
:deep(.ProseMirror hr.ProseMirror-selectednode) {
|
||||
background-color: var(--crepe-color-selected);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group),
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-table-block td),
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-table-block th),
|
||||
.easyflow-markdown-live-editor :deep(.milkdown-top-bar) {
|
||||
border-color: var(--crepe-color-outline);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor
|
||||
:deep(.milkdown-slash-menu .menu-groups .menu-group h6),
|
||||
.easyflow-markdown-live-editor
|
||||
:deep(.milkdown-code-block .preview-panel .preview-label) {
|
||||
color: var(--crepe-color-on-surface);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.crepe-placeholder::before),
|
||||
.easyflow-markdown-live-editor
|
||||
: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);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor
|
||||
:deep(.milkdown-image-block.selected > .image-edit::before),
|
||||
.easyflow-markdown-live-editor
|
||||
:deep(.milkdown-image-block.selected > .image-wrapper::before) {
|
||||
background: var(--crepe-color-selected);
|
||||
}
|
||||
|
||||
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
|
||||
background-color: var(--crepe-color-outline);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.easyflow-markdown-live-editor :deep(.ProseMirror) {
|
||||
padding: var(--space-6) var(--space-4) var(--space-8);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.easyflow-markdown-live-editor :deep(*) {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import CodeEditor from './CodeEditor.vue';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
highlight?: boolean;
|
||||
lineWrapping?: boolean;
|
||||
modelValue: string;
|
||||
readonly?: boolean;
|
||||
}>(),
|
||||
{ highlight: true, lineWrapping: true, readonly: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
cursorChange: [{ column: number; line: number }];
|
||||
save: [];
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const editorRef = ref<InstanceType<typeof CodeEditor>>();
|
||||
|
||||
defineExpose({
|
||||
focus: () => editorRef.value?.focus(),
|
||||
openSearch: () => editorRef.value?.openSearch(),
|
||||
scrollToLine: (line: number, column = 1) =>
|
||||
editorRef.value?.scrollToLine(line, column),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CodeEditor
|
||||
ref="editorRef"
|
||||
:model-value="modelValue"
|
||||
:language="highlight ? 'markdown' : 'text'"
|
||||
:line-wrapping="lineWrapping"
|
||||
:readonly="readonly"
|
||||
@cursor-change="emit('cursorChange', $event)"
|
||||
@save="emit('save')"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as CodeEditor } from './CodeEditor.vue';
|
||||
export { default as CodeViewer } from './CodeViewer.vue';
|
||||
export * from './markdown-security';
|
||||
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
|
||||
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
hasEquivalentMarkdownSemantics,
|
||||
normalizeLiveMarkdownUpdate,
|
||||
} from './markdown-fidelity';
|
||||
|
||||
describe('markdown semantic fidelity', () => {
|
||||
it('accepts equivalent list markers, rules, and table delimiter widths', () => {
|
||||
const source = [
|
||||
'- first',
|
||||
'- second',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'| A | B |',
|
||||
'| --- | :---: |',
|
||||
'| 1 | 2 |',
|
||||
].join('\n');
|
||||
const normalized = [
|
||||
'* first',
|
||||
'* second',
|
||||
'',
|
||||
'***',
|
||||
'',
|
||||
'| A | B |',
|
||||
'| - | :-: |',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
expect(hasEquivalentMarkdownSemantics(source, normalized)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a valid single-column table', () => {
|
||||
expect(
|
||||
hasEquivalentMarkdownSemantics('| A |\n| --- |', '| A |\n| - |'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mixed list boundaries', '- first\n* second', '* first\n* second'],
|
||||
['tight and loose lists', '- first\n- second', '* first\n\n* second'],
|
||||
['setext and thematic breaks', 'Heading\n---', 'Heading\n***'],
|
||||
['fenced code content', '```\n- code\n```', '```\n* code\n```'],
|
||||
['indented code content', ' - code', ' * code'],
|
||||
['invalid table-like text', 'A | B | C\n--- | ---', 'A | B | C\n- | -'],
|
||||
])('rejects %s changes', (_name, source, normalized) => {
|
||||
expect(hasEquivalentMarkdownSemantics(source, normalized)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('live markdown normalization', () => {
|
||||
it('removes empty paragraph markers introduced by the live editor', () => {
|
||||
expect(
|
||||
normalizeLiveMarkdownUpdate(
|
||||
'# Title\n\nFirst\n\nSecond\n',
|
||||
'# Title\n\n<br />\n\nFirst\n\n<br />\n\nSecond\n',
|
||||
),
|
||||
).toBe('# Title\n\nFirst\n\nSecond\n');
|
||||
});
|
||||
|
||||
it('preserves explicit standalone HTML breaks from source mode', () => {
|
||||
const source = '# Title\n\n<br />\n\nText\n';
|
||||
expect(normalizeLiveMarkdownUpdate(source, source)).toBe(source);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
|
||||
const markdownParser = unified().use(remarkParse).use(remarkGfm);
|
||||
const STANDALONE_BREAK_LINE = /^[\t ]*<br\s*\/?>[\t ]*$/im;
|
||||
|
||||
/**
|
||||
* Compares Markdown by parsed GFM structure so harmless marker formatting is
|
||||
* accepted while list, table, heading, code, and raw HTML changes are rejected.
|
||||
*/
|
||||
export function hasEquivalentMarkdownSemantics(
|
||||
source: string,
|
||||
normalized: string,
|
||||
) {
|
||||
try {
|
||||
return semanticTree(source) === semanticTree(normalized);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes blank-paragraph markers generated by the live editor without
|
||||
* 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');
|
||||
}
|
||||
|
||||
function semanticTree(markdown: string) {
|
||||
const normalizedLineEndings = markdown
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n');
|
||||
return JSON.stringify(
|
||||
markdownParser.parse(normalizedLineEndings),
|
||||
(key, value) => (key === 'position' ? undefined : value),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
EMPTY_IMAGE_DATA_URL,
|
||||
isBlockedMarkdownImageUrl,
|
||||
isSafeMarkdownLinkHref,
|
||||
resolveSafeExternalMarkdownImageUrl,
|
||||
} from './markdown-security';
|
||||
|
||||
describe('markdown image URL safety', () => {
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'file:///etc/passwd',
|
||||
'blob:https://example.com/foreign',
|
||||
'data:image/svg+xml,<svg onload=alert(1)>',
|
||||
'http://example.com/insecure.png',
|
||||
'//example.com/image.png',
|
||||
'../assets/image.png',
|
||||
])('blocks URLs that require an application resolver: %s', (url) => {
|
||||
expect(isBlockedMarkdownImageUrl(url)).toBe(true);
|
||||
expect(resolveSafeExternalMarkdownImageUrl(url)).toBe(EMPTY_IMAGE_DATA_URL);
|
||||
});
|
||||
|
||||
it('allows credential-free HTTPS images', () => {
|
||||
expect(resolveSafeExternalMarkdownImageUrl('https://cdn.test/a.png')).toBe(
|
||||
'https://cdn.test/a.png',
|
||||
);
|
||||
expect(
|
||||
isBlockedMarkdownImageUrl('https://user:secret@cdn.test/a.png'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'file:///etc/passwd',
|
||||
'blob:https://example.com/foreign',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'//example.com/path',
|
||||
'https://user:secret@example.com/path',
|
||||
])('blocks unsafe rendered links: %s', (href) => {
|
||||
expect(isSafeMarkdownLinkHref(href)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'#section',
|
||||
'mailto:team@example.com',
|
||||
'https://example.com/path',
|
||||
'http://example.com/path',
|
||||
'../references/guide.md',
|
||||
])('allows supported rendered links: %s', (href) => {
|
||||
expect(isSafeMarkdownLinkHref(href)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const EMPTY_IMAGE_DATA_URL =
|
||||
'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
|
||||
|
||||
/** Return a safe URL for an externally hosted Markdown image. */
|
||||
export function resolveSafeExternalMarkdownImageUrl(url: string) {
|
||||
const value = url.trim();
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol === 'https:' && !parsed.username && !parsed.password) {
|
||||
return parsed.href;
|
||||
}
|
||||
} catch {
|
||||
// Relative and malformed values require an application-level resolver.
|
||||
}
|
||||
return EMPTY_IMAGE_DATA_URL;
|
||||
}
|
||||
|
||||
export function isBlockedMarkdownImageUrl(url: string) {
|
||||
return resolveSafeExternalMarkdownImageUrl(url) === EMPTY_IMAGE_DATA_URL;
|
||||
}
|
||||
|
||||
/** Keep only navigable web/mail/anchor links and application-resolved relative paths. */
|
||||
export function isSafeMarkdownLinkHref(href: string) {
|
||||
const value = href.trim();
|
||||
if (!value || hasControlCharacter(value) || value.startsWith('//')) {
|
||||
return false;
|
||||
}
|
||||
if (value.startsWith('#') || /^mailto:/i.test(value)) return true;
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return !parsed.username && !parsed.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !/^[a-z][a-z\d+.-]*:/i.test(value);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string) {
|
||||
return [...value].some((character) => {
|
||||
const code = character.codePointAt(0) || 0;
|
||||
return code < 32 || code === 127;
|
||||
});
|
||||
}
|
||||
|
||||
export { EMPTY_IMAGE_DATA_URL };
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Editor } from '@milkdown/kit/core';
|
||||
|
||||
import { remarkStringifyOptionsCtx } from '@milkdown/kit/core';
|
||||
import { $nodeSchema, $remark } from '@milkdown/kit/utils';
|
||||
|
||||
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
||||
|
||||
interface MdastNode {
|
||||
children?: unknown[];
|
||||
position?: {
|
||||
end?: { offset?: number };
|
||||
start?: { offset?: number };
|
||||
};
|
||||
type?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
interface MdastTree {
|
||||
children?: MdastNode[];
|
||||
}
|
||||
|
||||
interface MdastFile {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
function getStringValue(node: unknown): string {
|
||||
if (typeof node !== 'object' || node === null || !('value' in node)) {
|
||||
return '';
|
||||
}
|
||||
return typeof node.value === 'string' ? node.value : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 自实现的 frontmatter remark 插件:在 mdast 阶段把文档开头的
|
||||
* `---\n...\n---` 包装成 `yaml` 节点,避免默认 remark 把它解析成
|
||||
* thematic break + 段落文本。
|
||||
*
|
||||
* 注意:`$remark` 工厂需要三层函数:`(ctx) => (processor) => (tree, file) => void`。
|
||||
*/
|
||||
export const remarkFrontmatterMilkdown = $remark(
|
||||
'remarkFrontmatter',
|
||||
() => () => (tree: MdastTree, file: MdastFile) => {
|
||||
const value = String(file.value ?? '');
|
||||
const match = FRONTMATTER_PATTERN.exec(value);
|
||||
if (!match) return;
|
||||
const endOffset = match[0].length;
|
||||
const yamlNode = {
|
||||
type: 'yaml',
|
||||
value: match[1] || '',
|
||||
};
|
||||
const children = Array.isArray(tree.children) ? tree.children : [];
|
||||
const filtered = children.filter((child) => {
|
||||
const start = child.position?.start?.offset ?? Number.POSITIVE_INFINITY;
|
||||
return start >= endOffset;
|
||||
});
|
||||
tree.children = [yamlNode as MdastNode, ...filtered];
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
|
||||
* 序列化时再写回 mdast `yaml` 节点。这样 SKILL.md 的 frontmatter 在实时
|
||||
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
|
||||
*/
|
||||
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
|
||||
group: 'block',
|
||||
atom: true,
|
||||
isolating: true,
|
||||
defining: true,
|
||||
draggable: false,
|
||||
allowGapCursor: false,
|
||||
content: '',
|
||||
attrs: {
|
||||
value: { default: '' },
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'div[data-frontmatter]',
|
||||
getAttrs: (dom) => ({
|
||||
value: (dom as HTMLElement).dataset.value || '',
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'div',
|
||||
{
|
||||
'data-frontmatter': '',
|
||||
'data-value': node.attrs.value,
|
||||
contenteditable: 'false',
|
||||
class: 'milkdown-frontmatter',
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
title: '点击进入源码模式编辑',
|
||||
'aria-label': 'Skill 元数据,点击进入源码模式编辑',
|
||||
},
|
||||
['div', { class: 'milkdown-frontmatter__title' }, 'Skill 元数据'],
|
||||
['pre', { class: 'milkdown-frontmatter__code' }, node.attrs.value],
|
||||
],
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === 'yaml',
|
||||
runner: (state, node, type) => {
|
||||
state.addNode(type, { value: getStringValue(node) });
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === 'frontmatter',
|
||||
runner: (state, node) => {
|
||||
state.addNode('yaml', undefined, node.attrs.value as string);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 在 Crepe 创建前安装 frontmatter 支持。
|
||||
*
|
||||
* 在 {@link import('@milkdown/crepe').Crepe} 实例化后、`create()` 之前调用:
|
||||
* `crepe.addFeature(installFrontmatter)`,或直接 `crepe.editor.use(...)`。
|
||||
*/
|
||||
export function installFrontmatter(editor: Editor) {
|
||||
editor.config((ctx) => {
|
||||
ctx.update(remarkStringifyOptionsCtx, (prev) => ({
|
||||
...prev,
|
||||
handlers: {
|
||||
...(prev as { handlers?: Record<string, unknown> }).handlers,
|
||||
yaml: (node: { value?: unknown }) =>
|
||||
`---\n${typeof node.value === 'string' ? node.value : ''}\n---\n\n`,
|
||||
},
|
||||
}));
|
||||
});
|
||||
editor.use(remarkFrontmatterMilkdown);
|
||||
editor.use(frontmatterSchema);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export type EditorLanguage =
|
||||
| 'javascript'
|
||||
| 'json'
|
||||
| 'markdown'
|
||||
| 'python'
|
||||
| 'shell'
|
||||
| 'text';
|
||||
|
||||
export interface EditorCursorPosition {
|
||||
column: number;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export type EditorSaveState = 'dirty' | 'error' | 'saved' | 'saving';
|
||||
|
||||
/** Rich Markdown is disabled above this size to keep editing responsive. */
|
||||
export const MAX_LIVE_MARKDOWN_CHARACTERS = 300_000;
|
||||
|
||||
/** Syntax parsing is disabled above this size while plain-text editing remains available. */
|
||||
export const MAX_HIGHLIGHTED_TEXT_CHARACTERS = 800_000;
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@easyflow/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@easyflow-core/editor-ui": "workspace:*",
|
||||
"@easyflow-core/form-ui": "workspace:*",
|
||||
"@easyflow-core/popup-ui": "workspace:*",
|
||||
"@easyflow-core/preferences": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { onKeyStroke } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
inspectorOpen?: boolean;
|
||||
treeLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
inspectorOpen: false,
|
||||
treeLabel: '文件',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
closeInspector: [];
|
||||
}>();
|
||||
|
||||
const mobileTreeOpen = ref(false);
|
||||
const mobileTriggerRef = ref<HTMLButtonElement>();
|
||||
const mobileCloseRef = ref<HTMLButtonElement>();
|
||||
const inspectorCloseRef = ref<HTMLButtonElement>();
|
||||
let inspectorReturnFocus: HTMLElement | undefined;
|
||||
|
||||
watch(mobileTreeOpen, async (open) => {
|
||||
await nextTick();
|
||||
if (open) mobileCloseRef.value?.focus();
|
||||
else mobileTriggerRef.value?.focus();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.inspectorOpen,
|
||||
async (open) => {
|
||||
if (open) {
|
||||
inspectorReturnFocus = document.activeElement as HTMLElement;
|
||||
await nextTick();
|
||||
inspectorCloseRef.value?.focus();
|
||||
} else {
|
||||
inspectorReturnFocus?.focus();
|
||||
inspectorReturnFocus = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (mobileTreeOpen.value) {
|
||||
mobileTreeOpen.value = false;
|
||||
} else if (props.inspectorOpen) {
|
||||
emit('closeInspector');
|
||||
}
|
||||
});
|
||||
|
||||
function trapMobileTreeFocus(event: KeyboardEvent) {
|
||||
if (!mobileTreeOpen.value || event.key !== 'Tab') return;
|
||||
const panel = mobileCloseRef.value?.closest<HTMLElement>(
|
||||
'.document-editor-workbench__tree',
|
||||
);
|
||||
if (!panel) return;
|
||||
const focusable = [
|
||||
...panel.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
].filter((item) => item.offsetParent !== null);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', trapMobileTreeFocus));
|
||||
onBeforeUnmount(() =>
|
||||
document.removeEventListener('keydown', trapMobileTreeFocus),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="document-editor-workbench">
|
||||
<button
|
||||
ref="mobileTriggerRef"
|
||||
class="document-editor-workbench__mobile-trigger"
|
||||
type="button"
|
||||
@click="mobileTreeOpen = true"
|
||||
>
|
||||
{{ treeLabel }}
|
||||
</button>
|
||||
<aside
|
||||
class="document-editor-workbench__tree"
|
||||
:class="{ 'is-mobile-open': mobileTreeOpen }"
|
||||
:aria-label="treeLabel"
|
||||
:aria-modal="mobileTreeOpen || undefined"
|
||||
:role="mobileTreeOpen ? 'dialog' : 'region'"
|
||||
>
|
||||
<div class="document-editor-workbench__tree-mobile-head">
|
||||
<strong>{{ treeLabel }}</strong>
|
||||
<button
|
||||
ref="mobileCloseRef"
|
||||
type="button"
|
||||
aria-label="关闭文件列表"
|
||||
@click="mobileTreeOpen = false"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<slot name="tree" :close="() => (mobileTreeOpen = false)"></slot>
|
||||
</aside>
|
||||
<button
|
||||
v-if="mobileTreeOpen"
|
||||
class="document-editor-workbench__scrim"
|
||||
type="button"
|
||||
aria-label="关闭文件列表"
|
||||
@click="mobileTreeOpen = false"
|
||||
></button>
|
||||
<main
|
||||
class="document-editor-workbench__main"
|
||||
:aria-hidden="mobileTreeOpen || undefined"
|
||||
:inert="mobileTreeOpen || undefined"
|
||||
>
|
||||
<div v-if="$slots.toolbar" class="document-editor-workbench__toolbar">
|
||||
<slot name="toolbar"></slot>
|
||||
</div>
|
||||
<div class="document-editor-workbench__editor">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<slot name="status"></slot>
|
||||
</main>
|
||||
<Transition name="document-inspector">
|
||||
<aside
|
||||
v-if="inspectorOpen"
|
||||
class="document-editor-workbench__inspector"
|
||||
role="complementary"
|
||||
aria-label="文档检查面板"
|
||||
>
|
||||
<button
|
||||
ref="inspectorCloseRef"
|
||||
class="document-editor-workbench__inspector-close"
|
||||
type="button"
|
||||
aria-label="关闭检查面板"
|
||||
@click="emit('closeInspector')"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<slot name="inspector"></slot>
|
||||
</aside>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.document-editor-workbench {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex: 1;
|
||||
grid-template-columns: minmax(248px, 272px) minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-subtle);
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-right: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.document-editor-workbench__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.document-editor-workbench__toolbar {
|
||||
z-index: 2;
|
||||
min-height: 48px;
|
||||
background: hsl(var(--toolbar-bg) / 92%);
|
||||
border-bottom: 1px solid hsl(var(--toolbar-border));
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
}
|
||||
|
||||
.document-editor-workbench__editor {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
width: min(360px, 88%);
|
||||
overflow: auto;
|
||||
background: hsl(var(--surface-elevated));
|
||||
border-left: 1px solid hsl(var(--line-subtle));
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close,
|
||||
.document-editor-workbench__tree-mobile-head,
|
||||
.document-editor-workbench__mobile-trigger,
|
||||
.document-editor-workbench__scrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close {
|
||||
position: sticky;
|
||||
top: var(--space-2);
|
||||
z-index: 1;
|
||||
float: right;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
margin: var(--space-2);
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-subtle));
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.document-editor-workbench__inspector-close:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.document-inspector-enter-active,
|
||||
.document-inspector-leave-active {
|
||||
transition:
|
||||
opacity var(--motion-duration-base) var(--motion-ease-standard),
|
||||
transform var(--motion-duration-medium) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.document-inspector-enter-from,
|
||||
.document-inspector-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.document-editor-workbench {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.document-editor-workbench__mobile-trigger {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
z-index: 3;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-3);
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--nav-item-active));
|
||||
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);
|
||||
}
|
||||
|
||||
.document-editor-workbench__mobile-trigger:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-tool-hover));
|
||||
}
|
||||
|
||||
.document-editor-workbench__mobile-trigger:focus-visible,
|
||||
.document-editor-workbench__tree-mobile-head button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 6;
|
||||
visibility: hidden;
|
||||
width: min(320px, 88%);
|
||||
box-shadow: var(--shadow-float);
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
transition:
|
||||
visibility 0s linear var(--motion-duration-medium),
|
||||
opacity var(--motion-duration-base) var(--motion-ease-standard),
|
||||
transform var(--motion-duration-medium) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree.is-mobile-open {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree-mobile-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.document-editor-workbench__tree-mobile-head button {
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.document-editor-workbench__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--overlay));
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.document-editor-workbench__toolbar {
|
||||
padding-left: calc(var(--space-8) * 3 + var(--space-2));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.document-editor-workbench__tree,
|
||||
.document-inspector-enter-active,
|
||||
.document-inspector-leave-active {
|
||||
transition-duration: 0.01ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { EditorSaveState } from '@easyflow-core/editor-ui';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
column?: number;
|
||||
encoding?: string;
|
||||
language?: string;
|
||||
line?: number;
|
||||
lines?: number;
|
||||
mode?: 'code' | 'document';
|
||||
saveState?: EditorSaveState;
|
||||
sizeLabel?: string;
|
||||
wordCount?: number;
|
||||
}>(),
|
||||
{
|
||||
column: 1,
|
||||
encoding: 'utf8',
|
||||
language: 'Text',
|
||||
line: 1,
|
||||
lines: 1,
|
||||
mode: 'code',
|
||||
saveState: 'saved',
|
||||
sizeLabel: '0 B',
|
||||
wordCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const saveLabels: Record<EditorSaveState, string> = {
|
||||
dirty: '未保存',
|
||||
error: '保存失败',
|
||||
saved: '已保存',
|
||||
saving: '保存中',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="editor-status-bar" aria-live="polite">
|
||||
<div v-if="mode === 'code'">
|
||||
<span>{{ language }}</span>
|
||||
<span>{{ encoding }}</span>
|
||||
<span>{{ sizeLabel }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span>{{ wordCount }} 字</span>
|
||||
</div>
|
||||
<div>
|
||||
<span v-if="mode === 'code'">行 {{ line }},列 {{ column }}</span>
|
||||
<span v-if="mode === 'code'">{{ lines }} 行</span>
|
||||
<span class="editor-status-bar__save" :class="`is-${saveState}`">
|
||||
{{ saveLabels[saveState] }}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-status-bar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--text-muted));
|
||||
background: hsl(var(--surface-subtle));
|
||||
border-top: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.editor-status-bar > div {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-status-bar__save.is-dirty,
|
||||
.editor-status-bar__save.is-saving {
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.editor-status-bar__save.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.editor-status-bar > div:first-child span:not(:first-child),
|
||||
.editor-status-bar > div:last-child span:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentFileNode } from './types';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
File,
|
||||
FileCode2,
|
||||
FileImage,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
Ellipsis,
|
||||
Plus,
|
||||
} from '@easyflow/icons';
|
||||
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
const props = defineProps<{
|
||||
currentPath?: string;
|
||||
dirtyPaths?: Set<string>;
|
||||
errorCounts?: Record<string, number>;
|
||||
focusedPath?: string;
|
||||
level?: number;
|
||||
node: DocumentFileNode;
|
||||
readonly?: boolean;
|
||||
warningCounts?: Record<string, number>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [node: DocumentFileNode];
|
||||
download: [node: DocumentFileNode];
|
||||
focusPath: [path: string];
|
||||
remove: [node: DocumentFileNode];
|
||||
rename: [node: DocumentFileNode];
|
||||
select: [node: DocumentFileNode];
|
||||
upload: [node: DocumentFileNode];
|
||||
}>();
|
||||
|
||||
const expanded = ref(true);
|
||||
const menuOpen = ref(false);
|
||||
const menuRef = ref<HTMLElement>();
|
||||
const isDirectory = computed(() => props.node.type === 'DIRECTORY');
|
||||
const isRootSkill = computed(() => props.node.path === 'SKILL.md');
|
||||
const isCurrent = computed(() => props.node.path === props.currentPath);
|
||||
const isFocusTarget = computed(
|
||||
() =>
|
||||
props.node.path === props.focusedPath ||
|
||||
(isDirectory.value &&
|
||||
!expanded.value &&
|
||||
Boolean(props.focusedPath?.startsWith(`${props.node.path}/`))),
|
||||
);
|
||||
const icon = computed(() => {
|
||||
if (isDirectory.value) return expanded.value ? FolderOpen : FolderClosed;
|
||||
const extension = props.node.name.split('.').pop()?.toLowerCase();
|
||||
if (props.node.name === 'SKILL.md' || extension === 'md') return FileText;
|
||||
if (['js', 'py', 'sh'].includes(extension || '')) return FileCode2;
|
||||
if (['gif', 'jpeg', 'jpg', 'png', 'webp'].includes(extension || ''))
|
||||
return FileImage;
|
||||
return File;
|
||||
});
|
||||
|
||||
onClickOutside(menuRef, () => {
|
||||
menuOpen.value = false;
|
||||
});
|
||||
|
||||
function activate() {
|
||||
emit('focusPath', props.node.path);
|
||||
if (isDirectory.value) {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
emit('select', props.node);
|
||||
}
|
||||
|
||||
function runAction(action: 'download' | 'remove' | 'rename') {
|
||||
menuOpen.value = false;
|
||||
if (action === 'download') emit('download', props.node);
|
||||
else if (action === 'remove') emit('remove', props.node);
|
||||
else emit('rename', props.node);
|
||||
}
|
||||
|
||||
function focusTreeItem(target?: HTMLElement | null) {
|
||||
if (!target) return;
|
||||
const tree = target.closest<HTMLElement>('[role="tree"]');
|
||||
tree
|
||||
?.querySelectorAll<HTMLElement>('[role="treeitem"]')
|
||||
.forEach((item) => (item.tabIndex = -1));
|
||||
target.tabIndex = 0;
|
||||
emit('focusPath', target.dataset.treePath || '');
|
||||
target.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight' && isDirectory.value) {
|
||||
event.preventDefault();
|
||||
if (!expanded.value) {
|
||||
expanded.value = true;
|
||||
emit('focusPath', props.node.path);
|
||||
return;
|
||||
}
|
||||
const row = event.currentTarget as HTMLElement;
|
||||
const nodeItem = row.closest('.file-tree-node');
|
||||
const group = [...(nodeItem?.children || [])].find(
|
||||
(element) => element.getAttribute('role') === 'group',
|
||||
);
|
||||
const firstNode = group?.children.item(0);
|
||||
const firstChild =
|
||||
firstNode?.querySelector<HTMLElement>('[role="treeitem"]');
|
||||
focusTreeItem(firstChild);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
if (isDirectory.value && expanded.value) {
|
||||
expanded.value = false;
|
||||
emit('focusPath', props.node.path);
|
||||
return;
|
||||
}
|
||||
const row = event.currentTarget as HTMLElement;
|
||||
const nodeItem = row.closest('.file-tree-node');
|
||||
const parentNode = nodeItem?.parentElement?.closest('.file-tree-node');
|
||||
const parentRow = [...(parentNode?.children || [])].find((element) =>
|
||||
element.classList.contains('file-tree-node__row-wrap'),
|
||||
);
|
||||
const parentItem =
|
||||
parentRow?.querySelector<HTMLElement>('[role="treeitem"]');
|
||||
focusTreeItem(parentItem);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="file-tree-node" role="none">
|
||||
<div
|
||||
class="file-tree-node__row-wrap"
|
||||
:style="{ '--tree-level': level || 0 }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
class="file-tree-node__row"
|
||||
:class="{ 'is-current': isCurrent }"
|
||||
:data-tree-path="node.path"
|
||||
:aria-current="isCurrent ? 'page' : undefined"
|
||||
:aria-expanded="isDirectory ? expanded : undefined"
|
||||
:aria-level="(level || 0) + 1"
|
||||
:aria-selected="isCurrent"
|
||||
:tabindex="isFocusTarget ? 0 : -1"
|
||||
@click="activate"
|
||||
@focus="emit('focusPath', node.path)"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<ChevronRight
|
||||
v-if="isDirectory"
|
||||
class="file-tree-node__chevron"
|
||||
:class="{ 'is-expanded': expanded }"
|
||||
:size="14"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<component
|
||||
:is="icon"
|
||||
class="file-tree-node__icon"
|
||||
:size="15"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="file-tree-node__name">{{ node.name }}</span>
|
||||
<span
|
||||
v-if="dirtyPaths?.has(node.path)"
|
||||
class="file-tree-node__dirty"
|
||||
title="未保存"
|
||||
>
|
||||
<span aria-hidden="true">●</span>
|
||||
<span class="file-tree-node__sr-only">未保存</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="errorCounts?.[node.path]"
|
||||
class="file-tree-node__count is-error"
|
||||
:aria-label="`${errorCounts[node.path]} 个错误`"
|
||||
>
|
||||
{{ errorCounts[node.path] }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="warningCounts?.[node.path]"
|
||||
class="file-tree-node__count is-warning"
|
||||
:aria-label="`${warningCounts[node.path]} 个警告`"
|
||||
>
|
||||
{{ warningCounts[node.path] }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="file-tree-node__actions">
|
||||
<button
|
||||
v-if="isDirectory && !readonly"
|
||||
type="button"
|
||||
aria-label="在此目录新建文件"
|
||||
title="在此目录新建文件"
|
||||
@click.stop="emit('create', node)"
|
||||
>
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
v-if="!isDirectory || !readonly"
|
||||
ref="menuRef"
|
||||
class="file-tree-node__menu-wrap"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="更多操作"
|
||||
title="更多操作"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="menuOpen"
|
||||
@click.stop="menuOpen = !menuOpen"
|
||||
>
|
||||
<Ellipsis :size="14" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="menuOpen" class="file-tree-node__menu" role="menu">
|
||||
<button
|
||||
v-if="isDirectory && !readonly"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="
|
||||
menuOpen = false;
|
||||
emit('upload', node);
|
||||
"
|
||||
>
|
||||
上传资源
|
||||
</button>
|
||||
<button
|
||||
v-if="!isDirectory"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runAction('download')"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
<template v-if="!readonly && !isDirectory && !isRootSkill">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runAction('rename')"
|
||||
>
|
||||
重命名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="is-danger"
|
||||
@click="runAction('remove')"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
v-if="isDirectory && expanded && node.children?.length"
|
||||
class="file-tree-node__children"
|
||||
role="group"
|
||||
>
|
||||
<FileTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.path"
|
||||
:node="child"
|
||||
:level="(level || 0) + 1"
|
||||
:current-path="currentPath"
|
||||
:focused-path="focusedPath"
|
||||
:dirty-paths="dirtyPaths"
|
||||
:error-counts="errorCounts"
|
||||
:warning-counts="warningCounts"
|
||||
:readonly="readonly"
|
||||
@create="emit('create', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@focus-path="emit('focusPath', $event)"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="emit('rename', $event)"
|
||||
@select="emit('select', $event)"
|
||||
@upload="emit('upload', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-node,
|
||||
.file-tree-node__children {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% - 16px);
|
||||
margin: 1px 8px;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.file-tree-node__row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 var(--radius-control) 0
|
||||
calc(var(--radius-control) + var(--tree-level) * 14px);
|
||||
margin: 0;
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
text-align: left;
|
||||
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);
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap:hover .file-tree-node__row {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap:hover .file-tree-node__row,
|
||||
.file-tree-node__row-wrap:focus-within .file-tree-node__row {
|
||||
padding-right: 66px;
|
||||
}
|
||||
|
||||
.file-tree-node__row.is-current {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.file-tree-node__row:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-tree-node__icon {
|
||||
flex: none;
|
||||
width: 15px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.file-tree-node__chevron {
|
||||
flex: none;
|
||||
color: hsl(var(--text-muted));
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.file-tree-node__chevron.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.file-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-tree-node__actions {
|
||||
position: absolute;
|
||||
right: var(--space-1);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.file-tree-node__row-wrap:hover .file-tree-node__actions,
|
||||
.file-tree-node__row-wrap:focus-within .file-tree-node__actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-tree-node__actions button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 var(--space-1);
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-elevated) / 92%);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.file-tree-node__actions button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-tool-hover));
|
||||
}
|
||||
|
||||
.file-tree-node__actions button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-tree-node__menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-tree-node__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
width: 112px;
|
||||
padding: var(--space-1);
|
||||
background: hsl(var(--surface-elevated));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.file-tree-node__menu button {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.file-tree-node__menu button.is-danger {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.file-tree-node__dirty {
|
||||
font-size: 8px;
|
||||
color: hsl(var(--warning));
|
||||
}
|
||||
|
||||
.file-tree-node__sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.file-tree-node__count {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
font-size: 11px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.file-tree-node__count.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 12%);
|
||||
}
|
||||
|
||||
.file-tree-node__count.is-warning {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 14%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentFileNode } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Plus, Upload } from '@easyflow/icons';
|
||||
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
import FileTreeNode from './FileTreeNode.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
busy?: boolean;
|
||||
currentPath?: string;
|
||||
dirtyPaths?: Set<string>;
|
||||
errorCounts?: Record<string, number>;
|
||||
nodes: DocumentFileNode[];
|
||||
readonly?: boolean;
|
||||
title?: string;
|
||||
warningCounts?: Record<string, number>;
|
||||
}>(),
|
||||
{
|
||||
busy: false,
|
||||
currentPath: '',
|
||||
dirtyPaths: () => new Set<string>(),
|
||||
errorCounts: () => ({}),
|
||||
readonly: false,
|
||||
title: 'Skill 文件',
|
||||
warningCounts: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [node?: DocumentFileNode];
|
||||
download: [node: DocumentFileNode];
|
||||
remove: [node: DocumentFileNode];
|
||||
rename: [node: DocumentFileNode];
|
||||
select: [node: DocumentFileNode];
|
||||
upload: [node?: DocumentFileNode];
|
||||
}>();
|
||||
|
||||
const effectiveCurrentPath = computed(
|
||||
() => props.currentPath || props.nodes[0]?.path || '',
|
||||
);
|
||||
const focusedPath = ref('');
|
||||
const createMenuOpen = ref(false);
|
||||
const createMenuRef = ref<HTMLElement>();
|
||||
|
||||
onClickOutside(createMenuRef, () => {
|
||||
createMenuOpen.value = false;
|
||||
});
|
||||
|
||||
watch(
|
||||
effectiveCurrentPath,
|
||||
(path) => {
|
||||
focusedPath.value = path;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function focusTreeItem(items: HTMLElement[], index: number) {
|
||||
const target = items[index];
|
||||
if (!target) return;
|
||||
items.forEach((item) => (item.tabIndex = -1));
|
||||
target.tabIndex = 0;
|
||||
focusedPath.value = target.dataset.treePath || '';
|
||||
target.focus();
|
||||
}
|
||||
|
||||
function handleTreeKeydown(event: KeyboardEvent) {
|
||||
if (!['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key)) return;
|
||||
const tree = event.currentTarget as HTMLElement;
|
||||
const items = [...tree.querySelectorAll<HTMLElement>('[role="treeitem"]')];
|
||||
if (items.length === 0) return;
|
||||
const current = (event.target as HTMLElement).closest<HTMLElement>(
|
||||
'[role="treeitem"]',
|
||||
);
|
||||
const currentIndex = current ? items.indexOf(current) : -1;
|
||||
let nextIndex = currentIndex;
|
||||
if (event.key === 'ArrowDown')
|
||||
nextIndex = Math.min(currentIndex + 1, items.length - 1);
|
||||
if (event.key === 'ArrowUp')
|
||||
nextIndex =
|
||||
currentIndex < 0 ? items.length - 1 : Math.max(currentIndex - 1, 0);
|
||||
if (event.key === 'Home') nextIndex = 0;
|
||||
if (event.key === 'End') nextIndex = items.length - 1;
|
||||
if (nextIndex >= 0) {
|
||||
event.preventDefault();
|
||||
focusTreeItem(items, nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function runHeaderAction(action: 'create' | 'upload') {
|
||||
createMenuOpen.value = false;
|
||||
if (action === 'create') emit('create');
|
||||
else emit('upload');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="file-tree-panel">
|
||||
<header class="file-tree-panel__header">
|
||||
<strong>{{ title }}</strong>
|
||||
<div
|
||||
v-if="!readonly"
|
||||
ref="createMenuRef"
|
||||
class="file-tree-panel__actions"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="添加资源"
|
||||
title="添加资源"
|
||||
:disabled="busy"
|
||||
:aria-expanded="createMenuOpen"
|
||||
aria-haspopup="menu"
|
||||
@click="createMenuOpen = !createMenuOpen"
|
||||
>
|
||||
<Plus :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-if="createMenuOpen" class="file-tree-panel__menu" role="menu">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runHeaderAction('create')"
|
||||
>
|
||||
<Plus :size="15" aria-hidden="true" />
|
||||
新建文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="runHeaderAction('upload')"
|
||||
>
|
||||
<Upload :size="15" aria-hidden="true" />
|
||||
上传资源
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="file-tree-panel__body" aria-label="Skill 文件树">
|
||||
<ul
|
||||
v-if="nodes.length > 0"
|
||||
class="file-tree-panel__list"
|
||||
role="tree"
|
||||
aria-label="Skill 文件"
|
||||
@keydown="handleTreeKeydown"
|
||||
>
|
||||
<FileTreeNode
|
||||
v-for="node in nodes"
|
||||
:key="node.path"
|
||||
:node="node"
|
||||
:current-path="effectiveCurrentPath"
|
||||
:focused-path="focusedPath"
|
||||
:dirty-paths="dirtyPaths"
|
||||
:error-counts="errorCounts"
|
||||
:warning-counts="warningCounts"
|
||||
:readonly="readonly"
|
||||
@create="emit('create', $event)"
|
||||
@download="emit('download', $event)"
|
||||
@focus-path="focusedPath = $event"
|
||||
@remove="emit('remove', $event)"
|
||||
@rename="emit('rename', $event)"
|
||||
@select="emit('select', $event)"
|
||||
@upload="emit('upload', $event)"
|
||||
/>
|
||||
</ul>
|
||||
<div v-else class="file-tree-panel__empty">暂无文件</div>
|
||||
</nav>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.file-tree-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 48px;
|
||||
padding: 0 var(--space-4);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.file-tree-panel__actions {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: hsl(var(--text-muted));
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--nav-item-hover));
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.file-tree-panel__actions button:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.file-tree-panel__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + var(--space-1));
|
||||
right: 0;
|
||||
z-index: 8;
|
||||
display: grid;
|
||||
width: 136px;
|
||||
padding: var(--space-1);
|
||||
background: hsl(var(--surface-elevated));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-control);
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.file-tree-panel__menu button {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.file-tree-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-2) 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.file-tree-panel__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-tree-panel__empty {
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: hsl(var(--text-muted));
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentValidationIssue } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
issues: DocumentValidationIssue[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
locate: [issue: DocumentValidationIssue];
|
||||
}>();
|
||||
|
||||
const errorCount = computed(
|
||||
() => props.issues.filter((issue) => issue.severity === 'ERROR').length,
|
||||
);
|
||||
const warningCount = computed(
|
||||
() => props.issues.filter((issue) => issue.severity === 'WARNING').length,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="validation-panel">
|
||||
<header class="validation-panel__header">
|
||||
<div>
|
||||
<h3>校验结果</h3>
|
||||
<p>{{ errorCount }} 个错误 · {{ warningCount }} 个警告</p>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="issues.length > 0" class="validation-panel__list">
|
||||
<button
|
||||
v-for="(issue, index) in issues"
|
||||
:key="`${issue.path}-${issue.line}-${issue.code}-${index}`"
|
||||
type="button"
|
||||
class="validation-panel__item"
|
||||
:class="`is-${issue.severity.toLowerCase()}`"
|
||||
@click="emit('locate', issue)"
|
||||
>
|
||||
<span class="validation-panel__severity">
|
||||
{{
|
||||
issue.severity === 'ERROR'
|
||||
? '错误'
|
||||
: issue.severity === 'WARNING'
|
||||
? '警告'
|
||||
: '提示'
|
||||
}}
|
||||
</span>
|
||||
<strong>{{ issue.message }}</strong>
|
||||
<small v-if="issue.path">
|
||||
{{ issue.path
|
||||
}}<template v-if="issue.line">:{{ issue.line }}</template>
|
||||
</small>
|
||||
<span v-if="issue.suggestion" class="validation-panel__suggestion">
|
||||
建议:{{ issue.suggestion }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="validation-panel__empty">
|
||||
<strong>校验通过</strong>
|
||||
<span>未发现需要处理的问题。</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.validation-panel {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.validation-panel__header {
|
||||
padding: var(--space-6);
|
||||
border-bottom: 1px solid hsl(var(--line-subtle));
|
||||
}
|
||||
|
||||
.validation-panel__header h3,
|
||||
.validation-panel__header p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.validation-panel__header p {
|
||||
margin-top: var(--space-1);
|
||||
font-size: 13px;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.validation-panel__list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.validation-panel__item {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: hsl(var(--foreground));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-toolbar);
|
||||
}
|
||||
|
||||
.validation-panel__item:hover {
|
||||
border-color: hsl(var(--primary) / 42%);
|
||||
}
|
||||
|
||||
.validation-panel__item:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.validation-panel__severity {
|
||||
width: fit-content;
|
||||
padding: 2px 7px;
|
||||
font-size: 11px;
|
||||
border-radius: var(--radius-pill, 999px);
|
||||
}
|
||||
|
||||
.validation-panel__item.is-error .validation-panel__severity {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 12%);
|
||||
}
|
||||
|
||||
.validation-panel__item.is-warning .validation-panel__severity {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 14%);
|
||||
}
|
||||
|
||||
.validation-panel__item small {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
|
||||
.validation-panel__suggestion {
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
}
|
||||
|
||||
.validation-panel__empty {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: calc(var(--space-6) * 2) var(--space-6);
|
||||
color: hsl(var(--text-muted));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.validation-panel__empty strong {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,200 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import FileTreePanel from '../FileTreePanel.vue';
|
||||
|
||||
const nodes = [
|
||||
{ name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
|
||||
{
|
||||
children: [
|
||||
{ name: 'guide.md', path: 'references/guide.md', type: 'REFERENCE' },
|
||||
{
|
||||
children: [
|
||||
{
|
||||
name: 'deep.md',
|
||||
path: 'references/nested/deep.md',
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
],
|
||||
name: 'nested',
|
||||
path: 'references/nested',
|
||||
type: 'DIRECTORY',
|
||||
},
|
||||
],
|
||||
name: 'references',
|
||||
path: 'references',
|
||||
type: 'DIRECTORY',
|
||||
},
|
||||
{
|
||||
children: [{ name: 'run.py', path: 'scripts/run.py', type: 'SCRIPT' }],
|
||||
name: 'scripts',
|
||||
path: 'scripts',
|
||||
type: 'DIRECTORY',
|
||||
},
|
||||
];
|
||||
|
||||
const wrappers: Array<ReturnType<typeof mount>> = [];
|
||||
|
||||
function mountTree(currentPath = 'SKILL.md') {
|
||||
const wrapper = mount(FileTreePanel, {
|
||||
attachTo: document.body,
|
||||
props: { currentPath, nodes },
|
||||
});
|
||||
wrappers.push(wrapper);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function treeItem(path: string) {
|
||||
return document.querySelector<HTMLElement>(
|
||||
`[role="treeitem"][data-tree-path="${path}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
function expectFocused(path: string) {
|
||||
const target = treeItem(path);
|
||||
expect(document.activeElement).toBe(target);
|
||||
expect(target?.tabIndex).toBe(0);
|
||||
expect(
|
||||
document.querySelectorAll('[role="treeitem"][tabindex="0"]'),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('file tree panel keyboard navigation', () => {
|
||||
it('moves through every visible node with ArrowUp, ArrowDown, Home and End', async () => {
|
||||
mountTree();
|
||||
treeItem('SKILL.md')?.focus();
|
||||
|
||||
await treeItem('SKILL.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowDown' }),
|
||||
);
|
||||
expectFocused('references');
|
||||
|
||||
treeItem('scripts/run.py')?.focus();
|
||||
treeItem('scripts/run.py')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Home' }),
|
||||
);
|
||||
expectFocused('SKILL.md');
|
||||
|
||||
treeItem('SKILL.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'End' }),
|
||||
);
|
||||
expectFocused('scripts/run.py');
|
||||
|
||||
treeItem('scripts/run.py')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowUp' }),
|
||||
);
|
||||
expectFocused('scripts');
|
||||
});
|
||||
|
||||
it('opens, enters, leaves and closes nested directories with ArrowRight and ArrowLeft', async () => {
|
||||
mountTree();
|
||||
treeItem('references')?.focus();
|
||||
|
||||
treeItem('references')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
expectFocused('references/guide.md');
|
||||
|
||||
treeItem('references/nested')?.focus();
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
expectFocused('references/nested/deep.md');
|
||||
|
||||
treeItem('references/nested/deep.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }),
|
||||
);
|
||||
expectFocused('references/nested');
|
||||
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(treeItem('references/nested')?.getAttribute('aria-expanded')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(treeItem('references/nested/deep.md')).toBeNull();
|
||||
expectFocused('references/nested');
|
||||
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(treeItem('references/nested')?.getAttribute('aria-expanded')).toBe(
|
||||
'true',
|
||||
);
|
||||
expectFocused('references/nested');
|
||||
|
||||
treeItem('references/nested')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
|
||||
);
|
||||
expectFocused('references/nested/deep.md');
|
||||
});
|
||||
|
||||
it('selects files and directories with Enter or Space', async () => {
|
||||
const wrapper = mountTree();
|
||||
|
||||
treeItem('references/guide.md')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
|
||||
);
|
||||
expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({
|
||||
path: 'references/guide.md',
|
||||
});
|
||||
|
||||
treeItem('references')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { bubbles: true, key: ' ' }),
|
||||
);
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits contextual create and file operations from row actions', async () => {
|
||||
const wrapper = mountTree();
|
||||
const referenceRow = treeItem('references')?.parentElement;
|
||||
|
||||
await referenceRow
|
||||
?.querySelector<HTMLButtonElement>('[aria-label="在此目录新建文件"]')
|
||||
?.click();
|
||||
expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
|
||||
path: 'references',
|
||||
});
|
||||
|
||||
const guideRow = treeItem('references/guide.md')?.parentElement;
|
||||
guideRow
|
||||
?.querySelector<HTMLButtonElement>('[aria-label="更多操作"]')
|
||||
?.click();
|
||||
await nextTick();
|
||||
const download = [...(guideRow?.querySelectorAll('button') || [])].find(
|
||||
(button) => button.textContent?.trim() === '下载',
|
||||
);
|
||||
download?.click();
|
||||
expect(wrapper.emitted('download')?.[0]?.[0]).toMatchObject({
|
||||
path: 'references/guide.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the single tab stop when the selected path changes', async () => {
|
||||
const wrapper = mountTree();
|
||||
|
||||
await wrapper.setProps({ currentPath: 'scripts/run.py' });
|
||||
|
||||
expect(treeItem('scripts/run.py')?.tabIndex).toBe(0);
|
||||
expect(
|
||||
document.querySelectorAll('[role="treeitem"][tabindex="0"]'),
|
||||
).toHaveLength(1);
|
||||
expect(treeItem('scripts/run.py')?.getAttribute('aria-selected')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as DocumentEditorWorkbench } from './DocumentEditorWorkbench.vue';
|
||||
export { default as EditorStatusBar } from './EditorStatusBar.vue';
|
||||
export { default as FileTreePanel } from './FileTreePanel.vue';
|
||||
export type * from './types';
|
||||
export { default as ValidationPanel } from './ValidationPanel.vue';
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface DocumentFileNode {
|
||||
children?: DocumentFileNode[];
|
||||
key?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface DocumentValidationIssue {
|
||||
code?: string;
|
||||
column?: number;
|
||||
line?: number;
|
||||
message: string;
|
||||
path?: string;
|
||||
severity: 'ERROR' | 'INFO' | 'WARNING' | string;
|
||||
suggestion?: string;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from './chat-thinking';
|
||||
export * from './chat-timeline';
|
||||
export * from './col-page';
|
||||
export * from './count-to';
|
||||
export * from './document-editor';
|
||||
export * from './ellipsis-text';
|
||||
export * from './icon-picker';
|
||||
export * from './json-viewer';
|
||||
|
||||
1135
easyflow-ui-admin/pnpm-lock.yaml
generated
1135
easyflow-ui-admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user