feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互 - 提供 Markdown 实时编辑、源码与脚本资源工作台 - 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
@@ -13,6 +13,14 @@
|
|||||||
"#/*": "./src/*"
|
"#/*": "./src/*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-core/shadcn-ui": "workspace:*",
|
||||||
"@easyflow/access": "workspace:*",
|
"@easyflow/access": "workspace:*",
|
||||||
"@easyflow/common-ui": "workspace:*",
|
"@easyflow/common-ui": "workspace:*",
|
||||||
@@ -28,13 +36,6 @@
|
|||||||
"@easyflow/styles": "workspace:*",
|
"@easyflow/styles": "workspace:*",
|
||||||
"@easyflow/types": "workspace:*",
|
"@easyflow/types": "workspace:*",
|
||||||
"@easyflow/utils": "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",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@tinyflow-ai/vue": "workspace:*",
|
"@tinyflow-ai/vue": "workspace:*",
|
||||||
"@ungap/structured-clone": "1.3.0",
|
"@ungap/structured-clone": "1.3.0",
|
||||||
@@ -55,7 +56,8 @@
|
|||||||
"vue-element-plus-x": "catalog:",
|
"vue-element-plus-x": "catalog:",
|
||||||
"vue-router": "catalog:",
|
"vue-router": "catalog:",
|
||||||
"vue3-json-viewer": "^2.4.1",
|
"vue3-json-viewer": "^2.4.1",
|
||||||
"wicg-inert": "3.1.3"
|
"wicg-inert": "3.1.3",
|
||||||
|
"yaml": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node-forge": "^1.3.14",
|
"@types/node-forge": "^1.3.14",
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ const handleReset = () => {
|
|||||||
emit('search', '');
|
emit('search', '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
reset: handleReset,
|
||||||
|
});
|
||||||
|
|
||||||
const handleButtonClick = (button) => {
|
const handleButtonClick = (button) => {
|
||||||
emitButtonEvent({
|
emitButtonEvent({
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@@ -133,6 +137,7 @@ const handleDropdownClick = (button) => {
|
|||||||
:type="button.type || 'default'"
|
:type="button.type || 'default'"
|
||||||
:icon="button.icon"
|
:icon="button.icon"
|
||||||
:disabled="button.disabled"
|
:disabled="button.disabled"
|
||||||
|
:loading="button.loading"
|
||||||
v-access:code="button.permission"
|
v-access:code="button.permission"
|
||||||
@click="handleButtonClick(button)"
|
@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 { preferences } from '@easyflow/preferences';
|
||||||
|
|
||||||
import { ElEmpty, ElPagination } from 'element-plus';
|
import { ElButton, ElEmpty, ElPagination } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||||
@@ -28,7 +28,9 @@ const props = withDefaults(defineProps<PageDataProps>(), {
|
|||||||
// 响应式数据
|
// 响应式数据
|
||||||
const pageList = ref<PageDataRow[]>([]);
|
const pageList = ref<PageDataRow[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadError = ref<unknown>();
|
||||||
const queryParams = ref<Record<string, any>>({});
|
const queryParams = ref<Record<string, any>>({});
|
||||||
|
let pageRequest = 0;
|
||||||
|
|
||||||
const pageInfo = reactive({
|
const pageInfo = reactive({
|
||||||
pageNumber: 1,
|
pageNumber: 1,
|
||||||
@@ -38,8 +40,6 @@ const pageInfo = reactive({
|
|||||||
|
|
||||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||||
const doGet = async (params: Record<string, any>) => {
|
const doGet = async (params: Record<string, any>) => {
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
// 这里替换为你的实际 API 调用
|
// 这里替换为你的实际 API 调用
|
||||||
// 例如:return await api.get(props.pageUrl, { params })
|
// 例如:return await api.get(props.pageUrl, { params })
|
||||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||||
@@ -47,13 +47,13 @@ const doGet = async (params: Record<string, any>) => {
|
|||||||
});
|
});
|
||||||
const data = await response.data;
|
const data = await response.data;
|
||||||
return { data };
|
return { data };
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取页面数据
|
// 获取页面数据
|
||||||
const getPageList = async () => {
|
const getPageList = async () => {
|
||||||
|
const request = ++pageRequest;
|
||||||
|
loading.value = true;
|
||||||
|
loadError.value = undefined;
|
||||||
try {
|
try {
|
||||||
const res = await doGet({
|
const res = await doGet({
|
||||||
pageNumber: pageInfo.pageNumber,
|
pageNumber: pageInfo.pageNumber,
|
||||||
@@ -61,13 +61,19 @@ const getPageList = async () => {
|
|||||||
...props.extraQueryParams,
|
...props.extraQueryParams,
|
||||||
...queryParams.value,
|
...queryParams.value,
|
||||||
});
|
});
|
||||||
|
if (request === pageRequest) {
|
||||||
pageList.value = res.data?.records || [];
|
pageList.value = res.data?.records || [];
|
||||||
pageInfo.total = res.data?.totalRow || 0;
|
pageInfo.total = res.data?.totalRow || 0;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('get data error:', error);
|
if (request === pageRequest) {
|
||||||
|
loadError.value = error;
|
||||||
pageList.value = [];
|
pageList.value = [];
|
||||||
pageInfo.total = 0;
|
pageInfo.total = 0;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (request === pageRequest) loading.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 分页事件处理
|
// 分页事件处理
|
||||||
@@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record<string, any>) => {
|
|||||||
pageInfo.pageNumber = 1;
|
pageInfo.pageNumber = 1;
|
||||||
pageInfo.pageSize = props.pageSize;
|
pageInfo.pageSize = props.pageSize;
|
||||||
queryParams.value = newQueryParams;
|
queryParams.value = newQueryParams;
|
||||||
getPageList();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
@@ -116,7 +121,7 @@ defineExpose({
|
|||||||
|
|
||||||
// 监听器
|
// 监听器
|
||||||
watch(
|
watch(
|
||||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
|
[() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value],
|
||||||
() => {
|
() => {
|
||||||
getPageList();
|
getPageList();
|
||||||
},
|
},
|
||||||
@@ -150,6 +155,16 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<slot v-else name="empty">
|
||||||
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
|
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
|
||||||
</slot>
|
</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 }">
|
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
import { ref, watch } from 'vue';
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { preferences } from '@easyflow/preferences';
|
import { preferences } from '@easyflow/preferences';
|
||||||
import { cn } from '@easyflow/utils';
|
|
||||||
|
|
||||||
import { MoreFilled } from '@element-plus/icons-vue';
|
import { MoreFilled } from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
@@ -14,119 +13,256 @@ import {
|
|||||||
ElDropdownMenu,
|
ElDropdownMenu,
|
||||||
ElEmpty,
|
ElEmpty,
|
||||||
ElIcon,
|
ElIcon,
|
||||||
|
ElTree,
|
||||||
} from 'element-plus';
|
} from 'element-plus';
|
||||||
|
|
||||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||||
|
|
||||||
interface Props {
|
interface ControlButton<Item> {
|
||||||
title?: string;
|
disabled?: ((item: Item) => boolean) | boolean;
|
||||||
menus: T[];
|
|
||||||
labelKey: string;
|
|
||||||
valueKey: string;
|
|
||||||
iconSize?: number;
|
|
||||||
controlBtns?: {
|
|
||||||
icon?: any;
|
icon?: any;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: (_: T) => void;
|
onClick: (item: Item) => void;
|
||||||
type?: any;
|
type?: any;
|
||||||
}[];
|
visible?: (item: Item) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
childrenKey?: string;
|
||||||
|
controlBtns?: ControlButton<T>[];
|
||||||
|
defaultExpandAll?: boolean;
|
||||||
|
defaultSelected?: number | string;
|
||||||
footerButton?: {
|
footerButton?: {
|
||||||
|
disabled?: boolean;
|
||||||
icon?: any;
|
icon?: any;
|
||||||
label: string;
|
label: string;
|
||||||
|
loading?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
defaultSelected?: string;
|
iconSize?: number;
|
||||||
|
labelKey: string;
|
||||||
|
menus: T[];
|
||||||
|
title?: string;
|
||||||
|
valueKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: '',
|
childrenKey: '',
|
||||||
iconSize: 16,
|
|
||||||
controlBtns: () => [],
|
controlBtns: () => [],
|
||||||
footerButton: undefined,
|
defaultExpandAll: true,
|
||||||
defaultSelected: '',
|
defaultSelected: '',
|
||||||
|
footerButton: undefined,
|
||||||
|
iconSize: 16,
|
||||||
|
title: '',
|
||||||
});
|
});
|
||||||
const emits = defineEmits<{
|
const emits = defineEmits<{
|
||||||
(e: 'change', item: T): void;
|
(e: 'change', item: T): void;
|
||||||
}>();
|
}>();
|
||||||
const panelWidth = ref(225);
|
|
||||||
const selected = ref<string>(props.defaultSelected ?? '');
|
|
||||||
const hoverId = ref<string>();
|
|
||||||
|
|
||||||
const handleChange = (item: T) => {
|
const panelWidth = 225;
|
||||||
selected.value = item[props.valueKey];
|
const selected = ref<number | string>(props.defaultSelected ?? '');
|
||||||
emits('change', item);
|
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||||
};
|
const lastEmittedDefault = ref('');
|
||||||
// 监听 defaultSelected 的变化
|
const treeProps = computed(() => ({
|
||||||
watch(
|
children: props.childrenKey,
|
||||||
() => props.defaultSelected,
|
label: props.labelKey,
|
||||||
(newVal) => {
|
}));
|
||||||
if (newVal) {
|
|
||||||
selected.value = newVal;
|
function menuValue(item: T) {
|
||||||
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
|
return item[props.valueKey] as number | string;
|
||||||
if (item) {
|
}
|
||||||
emits('change', item);
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="page-side" :style="{ width: `${panelWidth}px` }">
|
||||||
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
|
<div class="page-side__main">
|
||||||
:style="{ width: `${panelWidth}px` }"
|
<h3 v-if="title" class="page-side__title">{{ title }}</h3>
|
||||||
>
|
|
||||||
<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="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
|
<div
|
||||||
v-for="item in menus"
|
v-for="item in menus"
|
||||||
:key="item[valueKey]"
|
:key="menuValue(item)"
|
||||||
class="group list-item"
|
class="page-side__item"
|
||||||
:class="{
|
:class="{
|
||||||
selected: selected === item[valueKey],
|
'is-selected':
|
||||||
|
normalizedValue(selected) === normalizedValue(menuValue(item)),
|
||||||
}"
|
}"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
@click="handleChange(item)"
|
@click="handleChange(item)"
|
||||||
|
@keydown.enter.prevent="handleChange(item)"
|
||||||
|
@keydown.space.prevent="handleChange(item)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="page-side__label">
|
||||||
<div
|
<div v-if="item.icon" class="page-side__icon">
|
||||||
v-if="item.icon"
|
|
||||||
class="ml-[-3px] flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<div
|
<div
|
||||||
v-if="isSvgString(item.icon)"
|
v-if="isSvgString(item.icon)"
|
||||||
v-html="item.icon"
|
v-html="item.icon"
|
||||||
|
class="page-side__svg"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${iconSize}px`,
|
width: `${iconSize}px`,
|
||||||
height: `${iconSize}px`,
|
height: `${iconSize}px`,
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}"
|
}"
|
||||||
class="svg-container"
|
|
||||||
></div>
|
></div>
|
||||||
<!-- eslint-enable vue/no-v-html -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
<img
|
<img
|
||||||
@@ -137,55 +273,53 @@ const isSvgString = (icon: any) => {
|
|||||||
:style="{
|
:style="{
|
||||||
width: `${iconSize}px`,
|
width: `${iconSize}px`,
|
||||||
height: `${iconSize}px`,
|
height: `${iconSize}px`,
|
||||||
objectFit: 'contain',
|
|
||||||
}"
|
}"
|
||||||
|
alt=""
|
||||||
/>
|
/>
|
||||||
<ElIcon v-else>
|
<ElIcon v-else>
|
||||||
<component :is="item.icon as Component" v-bind="$attrs" />
|
<component :is="item.icon as Component" />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<slot name="label" :item="item">
|
||||||
|
<span class="page-side__label-text">
|
||||||
{{ item[labelKey] }}
|
{{ item[labelKey] }}
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<ElDropdown
|
<ElDropdown v-if="hasActions(item)" trigger="click" @click.stop>
|
||||||
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
|
<ElButton
|
||||||
|
class="page-side__more"
|
||||||
|
:aria-label="`管理${item[labelKey]}`"
|
||||||
|
:icon="MoreFilled"
|
||||||
|
text
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
:class="
|
|
||||||
cn(
|
|
||||||
'group-hover:!inline-flex',
|
|
||||||
(!hoverId || item.id !== hoverId) && '!hidden',
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<ElIcon>
|
|
||||||
<MoreFilled />
|
|
||||||
</ElIcon>
|
|
||||||
</div>
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<div
|
|
||||||
@mouseenter="handleMouseEvent(item.id)"
|
|
||||||
@mouseleave="handleMouseEvent()"
|
|
||||||
>
|
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
<ElDropdownItem
|
<ElDropdownItem
|
||||||
v-for="btn in controlBtns"
|
v-for="button in visibleActions(item)"
|
||||||
:key="btn.label"
|
:key="button.label"
|
||||||
@click="btn.onClick(item)"
|
:disabled="actionDisabled(button, item)"
|
||||||
|
@click="runAction(button, item)"
|
||||||
>
|
>
|
||||||
<ElButton :type="btn.type" :icon="btn.icon" link>
|
<ElButton
|
||||||
{{ btn.label }}
|
:type="button.type"
|
||||||
|
:icon="button.icon"
|
||||||
|
:disabled="actionDisabled(button, item)"
|
||||||
|
link
|
||||||
|
>
|
||||||
|
{{ button.label }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElDropdownItem>
|
</ElDropdownItem>
|
||||||
</ElDropdownMenu>
|
</ElDropdownMenu>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ElDropdown>
|
</ElDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<ElEmpty
|
<ElEmpty
|
||||||
v-if="menus.length <= 0"
|
v-if="menus.length === 0"
|
||||||
:image="getEmptyStateImageUrl(preferences.theme.mode)"
|
:image="getEmptyStateImageUrl(preferences.theme.mode)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,9 +327,12 @@ const isSvgString = (icon: any) => {
|
|||||||
|
|
||||||
<ElButton
|
<ElButton
|
||||||
v-if="footerButton"
|
v-if="footerButton"
|
||||||
@click="footerButton.onClick"
|
class="page-side__footer"
|
||||||
|
:disabled="footerButton.disabled"
|
||||||
:icon="footerButton.icon"
|
:icon="footerButton.icon"
|
||||||
|
:loading="footerButton.loading"
|
||||||
plain
|
plain
|
||||||
|
@click="footerButton.onClick"
|
||||||
>
|
>
|
||||||
{{ footerButton.label }}
|
{{ footerButton.label }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
@@ -203,34 +340,164 @@ const isSvgString = (icon: any) => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.list-item {
|
.page-side {
|
||||||
display: flex;
|
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;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px;
|
min-width: 0;
|
||||||
margin-bottom: 4px;
|
}
|
||||||
|
|
||||||
|
.page-side__item {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: hsl(var(--nav-item-muted-foreground));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-control);
|
||||||
transition: all 0.2s;
|
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 {
|
.page-side__item:hover {
|
||||||
background-color: hsl(var(--accent));
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--accent));
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item.selected {
|
.page-side__item.is-selected {
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--nav-item-active-foreground));
|
||||||
background-color: hsl(var(--primary) / 15%);
|
background: hsl(var(--nav-item-active));
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item.selected:where(.dark, .dark *) {
|
.page-side__item:focus-visible {
|
||||||
color: hsl(var(--accent-foreground));
|
outline: 2px solid hsl(var(--primary) / 72%);
|
||||||
background-color: hsl(var(--accent));
|
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%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: 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 {
|
import type {
|
||||||
RequestResult,
|
RequestResult,
|
||||||
|
SkillCapabilityBinding,
|
||||||
|
SkillCapabilityCandidate,
|
||||||
|
SkillCapabilityReplaceResult,
|
||||||
|
SkillCapabilityTools,
|
||||||
|
SkillCapabilityType,
|
||||||
|
SkillCategory,
|
||||||
|
SkillCategoryDraft,
|
||||||
|
SkillExportFormat,
|
||||||
SkillFileContent,
|
SkillFileContent,
|
||||||
SkillFileNode,
|
SkillFileNode,
|
||||||
SkillImportPreview,
|
SkillImportPreview,
|
||||||
SkillInfo,
|
SkillInfo,
|
||||||
|
SkillValidationResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import {api} from '#/api/request';
|
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) {
|
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 },
|
params: { id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSkill(skill: SkillInfo) {
|
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) {
|
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() {
|
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' },
|
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) {
|
export function submitSkillPublishApproval(id: number | string) {
|
||||||
return api.post<RequestResult<number | string>>(
|
return api.post<RequestResult<null | number | string>>(
|
||||||
'/api/v1/skill/submitPublishApproval',
|
'/api/v1/skill/submitPublishApproval',
|
||||||
{ id },
|
{ id },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitSkillOfflineApproval(id: number | string) {
|
export function submitSkillOfflineApproval(id: number | string) {
|
||||||
return api.post<RequestResult<number | string>>(
|
return api.post<RequestResult<null | number | string>>(
|
||||||
'/api/v1/skill/submitOfflineApproval',
|
'/api/v1/skill/submitOfflineApproval',
|
||||||
{ id },
|
{ id },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitSkillDeleteApproval(id: number | string) {
|
export function submitSkillDeleteApproval(id: number | string) {
|
||||||
return api.post<RequestResult<number | string>>(
|
return api.post<RequestResult<null | number | string>>(
|
||||||
'/api/v1/skill/submitDeleteApproval',
|
'/api/v1/skill/submitDeleteApproval',
|
||||||
{ id },
|
{ id },
|
||||||
);
|
);
|
||||||
@@ -56,23 +121,146 @@ export function getSkillFileTree(skillId: number | string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getSkillFileContent(skillId: number | string, path: string) {
|
export function getSkillFileContent(skillId: number | string, path: string) {
|
||||||
return api.get<RequestResult<SkillFileContent>>('/api/v1/skill/file/content', {
|
return api.get<RequestResult<SkillFileContent>>(
|
||||||
params: { skillId, path },
|
'/api/v1/skill/file/content',
|
||||||
});
|
{
|
||||||
|
params: { path, skillId },
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSkillFile(
|
export function saveSkillFile(
|
||||||
skillId: number | string,
|
skillId: number | string,
|
||||||
path: string,
|
path: string,
|
||||||
content: string,
|
content: string,
|
||||||
|
expectedContentHash?: string,
|
||||||
) {
|
) {
|
||||||
return api.post<RequestResult<SkillFileContent>>('/api/v1/skill/file/save', {
|
return api.post<RequestResult<SkillFileContent>>('/api/v1/skill/file/save', {
|
||||||
skillId,
|
|
||||||
path,
|
|
||||||
content,
|
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) {
|
export function importSkillPreview(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
@@ -82,25 +270,35 @@ export function importSkillPreview(file: File) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importSkillConfirm(
|
export function importSkillConfirm(payload: {
|
||||||
file: File,
|
capabilityMappings?: Array<{
|
||||||
categoryId?: number | string,
|
bindingKey: string;
|
||||||
overwriteDraft = false,
|
disabled?: boolean;
|
||||||
) {
|
targetId?: number | string;
|
||||||
const formData = new FormData();
|
}>;
|
||||||
formData.append('file', file);
|
categoryId?: number | string;
|
||||||
if (categoryId) {
|
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||||
formData.append('categoryId', String(categoryId));
|
importToken: string;
|
||||||
}
|
renames?: Record<string, string>;
|
||||||
formData.append('overwriteDraft', String(overwriteDraft));
|
}) {
|
||||||
return api.postFile<RequestResult<SkillInfo[]>>(
|
return api.post<RequestResult<SkillInfo[]>>(
|
||||||
'/api/v1/skill/import/confirm',
|
'/api/v1/skill/import/confirm',
|
||||||
formData,
|
payload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSkillAssetUrl(skillId: number | string, path: string) {
|
export function cancelSkillImport(importToken: string) {
|
||||||
return `/api/v1/skill/file/asset?skillId=${encodeURIComponent(
|
return api.post<RequestResult<void>>('/api/v1/skill/import/cancel', {
|
||||||
String(skillId),
|
importToken,
|
||||||
)}&path=${encodeURIComponent(path)}`;
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
data: T;
|
||||||
errorCode: number;
|
errorCode: number;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SkillCapabilityType = 'MCP' | 'PLUGIN_ITEM' | 'WORKFLOW';
|
||||||
|
export type SkillExportFormat = 'EASYFLOW' | 'STANDARD';
|
||||||
|
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
||||||
|
|
||||||
export interface SkillInfo {
|
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;
|
approvalPending?: boolean;
|
||||||
currentApprovalActionType?: string;
|
assetCount?: number;
|
||||||
currentApprovalInstanceId?: number | string;
|
bindings?: SkillCapabilityBinding[];
|
||||||
|
capabilityCount?: number;
|
||||||
|
capabilityHash?: string;
|
||||||
|
categoryId?: number | string;
|
||||||
|
categoryName?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
createdByName?: string;
|
createdByName?: string;
|
||||||
references?: SkillReference[];
|
currentApprovalActionType?: string;
|
||||||
scripts?: SkillScript[];
|
currentApprovalInstanceId?: number | string;
|
||||||
assets?: SkillAsset[];
|
description?: string;
|
||||||
[key: string]: any;
|
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 {
|
export interface SkillResource {
|
||||||
id?: number | string;
|
|
||||||
path: string;
|
|
||||||
name?: string;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
contentHash?: 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;
|
contentRef?: string;
|
||||||
contentHash?: string;
|
id?: number | string;
|
||||||
|
isText?: boolean;
|
||||||
|
kind?: string;
|
||||||
|
language?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
metadataJson?: Record<string, unknown>;
|
||||||
|
path: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
metadataJson?: Record<string, any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillFileNode {
|
export interface SkillFileNode {
|
||||||
|
children?: SkillFileNode[];
|
||||||
|
contentHash?: string;
|
||||||
|
isText?: boolean;
|
||||||
key: string;
|
key: string;
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
type: 'ASSET' | 'DIRECTORY' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
|
||||||
language?: string;
|
language?: string;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
children?: SkillFileNode[];
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkillFileContent {
|
export interface SkillFileContent {
|
||||||
path: string;
|
|
||||||
type: 'ASSET' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
|
||||||
content?: string;
|
content?: string;
|
||||||
|
contentHash?: string;
|
||||||
|
isText?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
|
path: string;
|
||||||
size?: number;
|
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 {
|
export interface SkillImportPreviewItem {
|
||||||
packageId: string;
|
assetCount?: number;
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
referenceCount: number;
|
|
||||||
scriptCount: number;
|
|
||||||
assetCount: number;
|
|
||||||
conflict: boolean;
|
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 {
|
export interface SkillImportPreview {
|
||||||
|
capabilityMappings?: SkillCapabilityMapping[];
|
||||||
|
expiresAt?: string;
|
||||||
|
format: SkillExportFormat;
|
||||||
|
importToken: string;
|
||||||
|
issues?: SkillValidationIssue[];
|
||||||
skills: SkillImportPreviewItem[];
|
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 KnowledgeApprovalSnapshotPreview from '#/views/system/approval/components/KnowledgeApprovalSnapshotPreview.vue';
|
||||||
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
|
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
|
||||||
|
|
||||||
|
import { formatApprovalAccount } from './approval-format';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const detail = ref<any>(null);
|
const detail = ref<any>(null);
|
||||||
@@ -171,23 +173,6 @@ function formatPayload(payload: Record<string, any>) {
|
|||||||
return JSON.stringify(payload || {}, null, 2);
|
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(
|
function formatOperatorId(
|
||||||
account?: null | string,
|
account?: null | string,
|
||||||
fallbackId?: null | number | string,
|
fallbackId?: null | number | string,
|
||||||
@@ -334,7 +319,7 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
|
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
|
||||||
{{
|
{{
|
||||||
formatAccountDisplay(
|
formatApprovalAccount(
|
||||||
detail.applicantName,
|
detail.applicantName,
|
||||||
detail.applicantAccount,
|
detail.applicantAccount,
|
||||||
detail.applicantId,
|
detail.applicantId,
|
||||||
@@ -390,7 +375,7 @@ function formatEventInfo(row: Record<string, any>) {
|
|||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn :label="$t('approval.fields.actedBy')" width="180">
|
<ElTableColumn :label="$t('approval.fields.actedBy')" width="180">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ formatAccountDisplay(row.actedByName, row.actedBy) }}
|
{{ formatApprovalAccount(row.actedByName, row.actedBy) }}
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import PageData from '#/components/page/PageData.vue';
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { router } from '#/router';
|
import { router } from '#/router';
|
||||||
|
|
||||||
|
import { formatApprovalAccount } from './approval-format';
|
||||||
import ApprovalFlowModal from './ApprovalFlowModal.vue';
|
import ApprovalFlowModal from './ApprovalFlowModal.vue';
|
||||||
|
|
||||||
type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed';
|
type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed';
|
||||||
@@ -785,6 +786,20 @@ function formatApplicationReason(value?: null | string) {
|
|||||||
{{ formatApplicationReason(row.applicationReason) }}
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicant')"
|
||||||
|
min-width="180"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{
|
||||||
|
formatApprovalAccount(
|
||||||
|
row.applicantName,
|
||||||
|
row.applicantAccount,
|
||||||
|
row.applicantId,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
:label="$t('approval.fields.resourceType')"
|
:label="$t('approval.fields.resourceType')"
|
||||||
width="120"
|
width="120"
|
||||||
@@ -969,6 +984,20 @@ function formatApplicationReason(value?: null | string) {
|
|||||||
{{ formatApplicationReason(row.applicationReason) }}
|
{{ formatApplicationReason(row.applicationReason) }}
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn
|
||||||
|
:label="$t('approval.fields.applicant')"
|
||||||
|
min-width="180"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{
|
||||||
|
formatApprovalAccount(
|
||||||
|
row.applicantName,
|
||||||
|
row.applicantAccount,
|
||||||
|
row.applicantId,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn
|
||||||
:label="$t('approval.fields.resourceType')"
|
:label="$t('approval.fields.resourceType')"
|
||||||
width="120"
|
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",
|
"esno",
|
||||||
"etag",
|
"etag",
|
||||||
"execa",
|
"execa",
|
||||||
|
"hitl",
|
||||||
"iconify",
|
"iconify",
|
||||||
"iconoir",
|
"iconoir",
|
||||||
"intlify",
|
"intlify",
|
||||||
|
|||||||
@@ -85,6 +85,27 @@
|
|||||||
/* Border radius for card, input and buttons */
|
/* Border radius for card, input and buttons */
|
||||||
--radius: 0.5rem;
|
--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 ============= */
|
/* ============= custom ============= */
|
||||||
|
|
||||||
/* 遮罩颜色 */
|
/* 遮罩颜色 */
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
File,
|
||||||
|
FileCode2,
|
||||||
|
FileImage,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
FoldHorizontal,
|
FoldHorizontal,
|
||||||
Fullscreen,
|
Fullscreen,
|
||||||
Github,
|
Github,
|
||||||
@@ -43,10 +49,10 @@ export {
|
|||||||
LogOut,
|
LogOut,
|
||||||
MailCheck,
|
MailCheck,
|
||||||
Maximize,
|
Maximize,
|
||||||
MessageSquare,
|
|
||||||
ArrowRightFromLine as MdiMenuClose,
|
ArrowRightFromLine as MdiMenuClose,
|
||||||
ArrowLeftFromLine as MdiMenuOpen,
|
ArrowLeftFromLine as MdiMenuOpen,
|
||||||
Menu,
|
Menu,
|
||||||
|
MessageSquare,
|
||||||
Minimize,
|
Minimize,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
MoonStar,
|
MoonStar,
|
||||||
@@ -67,6 +73,7 @@ export {
|
|||||||
Sun,
|
Sun,
|
||||||
SunMoon,
|
SunMoon,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
|
Upload,
|
||||||
UserRoundPen,
|
UserRoundPen,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-vue-next';
|
} 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": {
|
"dependencies": {
|
||||||
|
"@easyflow-core/editor-ui": "workspace:*",
|
||||||
"@easyflow-core/form-ui": "workspace:*",
|
"@easyflow-core/form-ui": "workspace:*",
|
||||||
"@easyflow-core/popup-ui": "workspace:*",
|
"@easyflow-core/popup-ui": "workspace:*",
|
||||||
"@easyflow-core/preferences": "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 './chat-timeline';
|
||||||
export * from './col-page';
|
export * from './col-page';
|
||||||
export * from './count-to';
|
export * from './count-to';
|
||||||
|
export * from './document-editor';
|
||||||
export * from './ellipsis-text';
|
export * from './ellipsis-text';
|
||||||
export * from './icon-picker';
|
export * from './icon-picker';
|
||||||
export * from './json-viewer';
|
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