feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互 - 提供 Markdown 实时编辑、源码与脚本资源工作台 - 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
@@ -13,6 +13,14 @@
|
||||
"#/*": "./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@easyflow-core/editor-ui": "workspace:*",
|
||||
"@easyflow-core/shadcn-ui": "workspace:*",
|
||||
"@easyflow/access": "workspace:*",
|
||||
"@easyflow/common-ui": "workspace:*",
|
||||
@@ -28,13 +36,6 @@
|
||||
"@easyflow/styles": "workspace:*",
|
||||
"@easyflow/types": "workspace:*",
|
||||
"@easyflow/utils": "workspace:*",
|
||||
"@codemirror/commands": "^6.10.2",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/legacy-modes": "^6.5.1",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@tinyflow-ai/vue": "workspace:*",
|
||||
"@ungap/structured-clone": "1.3.0",
|
||||
@@ -55,7 +56,8 @@
|
||||
"vue-element-plus-x": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vue3-json-viewer": "^2.4.1",
|
||||
"wicg-inert": "3.1.3"
|
||||
"wicg-inert": "3.1.3",
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node-forge": "^1.3.14",
|
||||
|
||||
@@ -73,6 +73,10 @@ const handleReset = () => {
|
||||
emit('search', '');
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
reset: handleReset,
|
||||
});
|
||||
|
||||
const handleButtonClick = (button) => {
|
||||
emitButtonEvent({
|
||||
type: 'button',
|
||||
@@ -133,6 +137,7 @@ const handleDropdownClick = (button) => {
|
||||
:type="button.type || 'default'"
|
||||
:icon="button.icon"
|
||||
:disabled="button.disabled"
|
||||
:loading="button.loading"
|
||||
v-access:code="button.permission"
|
||||
@click="handleButtonClick(button)"
|
||||
>
|
||||
|
||||
28
easyflow-ui-admin/app/src/components/page/PageData.test.ts
Normal file
28
easyflow-ui-admin/app/src/components/page/PageData.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageData from './PageData.vue';
|
||||
|
||||
describe('page data recovery', () => {
|
||||
it('shows a retry action after a page request fails', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce({ data: { records: [], totalRow: 0 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('数据加载失败,请重试');
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.text()).not.toContain('数据加载失败,请重试');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElEmpty, ElPagination } from 'element-plus';
|
||||
import { ElButton, ElEmpty, ElPagination } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
@@ -28,7 +28,9 @@ const props = withDefaults(defineProps<PageDataProps>(), {
|
||||
// 响应式数据
|
||||
const pageList = ref<PageDataRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<unknown>();
|
||||
const queryParams = ref<Record<string, any>>({});
|
||||
let pageRequest = 0;
|
||||
|
||||
const pageInfo = reactive({
|
||||
pageNumber: 1,
|
||||
@@ -38,22 +40,20 @@ const pageInfo = reactive({
|
||||
|
||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||
const doGet = async (params: Record<string, any>) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
};
|
||||
|
||||
// 获取页面数据
|
||||
const getPageList = async () => {
|
||||
const request = ++pageRequest;
|
||||
loading.value = true;
|
||||
loadError.value = undefined;
|
||||
try {
|
||||
const res = await doGet({
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
@@ -61,12 +61,18 @@ const getPageList = async () => {
|
||||
...props.extraQueryParams,
|
||||
...queryParams.value,
|
||||
});
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
if (request === pageRequest) {
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get data error:', error);
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
if (request === pageRequest) {
|
||||
loadError.value = error;
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
}
|
||||
} finally {
|
||||
if (request === pageRequest) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
pageInfo.pageSize = props.pageSize;
|
||||
queryParams.value = newQueryParams;
|
||||
getPageList();
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
@@ -116,7 +121,7 @@ defineExpose({
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value],
|
||||
() => {
|
||||
getPageList();
|
||||
},
|
||||
@@ -150,6 +155,16 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<slot
|
||||
v-else-if="loadError"
|
||||
name="error"
|
||||
:error="loadError"
|
||||
:retry="getPageList"
|
||||
>
|
||||
<ElEmpty description="数据加载失败,请重试">
|
||||
<ElButton type="primary" @click="getPageList">重新加载</ElButton>
|
||||
</ElEmpty>
|
||||
</slot>
|
||||
<slot v-else name="empty">
|
||||
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
|
||||
</slot>
|
||||
|
||||
141
easyflow-ui-admin/app/src/components/page/PageSide.test.ts
Normal file
141
easyflow-ui-admin/app/src/components/page/PageSide.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageSide from './PageSide.vue';
|
||||
|
||||
/* eslint-disable vue/one-component-per-file -- Inline dropdown stubs keep this component test focused. */
|
||||
|
||||
const DropdownStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'dropdown-stub' }, [
|
||||
slots.default?.(),
|
||||
slots.dropdown?.(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const DropdownMenuStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'dropdown-menu-stub' }, slots.default?.());
|
||||
},
|
||||
});
|
||||
const DropdownItemStub = defineComponent({
|
||||
props: { disabled: Boolean },
|
||||
emits: ['click'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'dropdown-item-stub',
|
||||
'data-disabled': String(props.disabled),
|
||||
onClick: () => {
|
||||
if (!props.disabled) emit('click');
|
||||
},
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
describe('page side', () => {
|
||||
it('keeps the existing flat menu selection and footer behavior', async () => {
|
||||
const add = vi.fn();
|
||||
const wrapper = mount(PageSide, {
|
||||
props: {
|
||||
defaultSelected: '',
|
||||
footerButton: { label: '添加', onClick: add },
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{ id: 'docs', name: '文档' },
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('.page-side__item')).toHaveLength(2);
|
||||
expect(wrapper.findAll('.page-side__item')[0]?.classes()).toContain(
|
||||
'is-selected',
|
||||
);
|
||||
|
||||
await wrapper.findAll('.page-side__item')[1]?.trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toMatchObject({ id: 'docs' });
|
||||
|
||||
await wrapper.get('.page-side__footer').trigger('click');
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders nested menus and emits the selected child', async () => {
|
||||
const wrapper = mount(PageSide, {
|
||||
props: {
|
||||
childrenKey: 'children',
|
||||
defaultSelected: '',
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{
|
||||
children: [{ id: 2, name: '平台' }],
|
||||
id: 1,
|
||||
name: '研发',
|
||||
},
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.el-tree-node__content')).toHaveLength(3);
|
||||
const all = wrapper
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('全部'));
|
||||
expect(all?.element.parentElement?.classList).toContain('is-current');
|
||||
const child = wrapper
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('平台'));
|
||||
expect(child).toBeTruthy();
|
||||
await child?.trigger('click');
|
||||
expect(wrapper.emitted('change')?.at(-1)?.[0]).toMatchObject({ id: 2 });
|
||||
});
|
||||
|
||||
it('supports per-node disabled action states', async () => {
|
||||
const edit = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const wrapper = mount(PageSide, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDropdown: DropdownStub,
|
||||
ElDropdownItem: DropdownItemStub,
|
||||
ElDropdownMenu: DropdownMenuStub,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
controlBtns: [
|
||||
{
|
||||
disabled: () => true,
|
||||
label: '编辑',
|
||||
onClick: edit,
|
||||
},
|
||||
{ label: '删除', onClick: remove },
|
||||
],
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{ id: 1, name: '研发' },
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
|
||||
const actions = wrapper.findAll('.dropdown-item-stub');
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]?.attributes('data-disabled')).toBe('true');
|
||||
await actions[0]?.trigger('click');
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
|
||||
await actions[1]?.trigger('click');
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
@@ -14,178 +13,313 @@ import {
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElTree,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
|
||||
interface ControlButton<Item> {
|
||||
disabled?: ((item: Item) => boolean) | boolean;
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (item: Item) => void;
|
||||
type?: any;
|
||||
visible?: (item: Item) => boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
menus: T[];
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
iconSize?: number;
|
||||
controlBtns?: {
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (_: T) => void;
|
||||
type?: any;
|
||||
}[];
|
||||
childrenKey?: string;
|
||||
controlBtns?: ControlButton<T>[];
|
||||
defaultExpandAll?: boolean;
|
||||
defaultSelected?: number | string;
|
||||
footerButton?: {
|
||||
disabled?: boolean;
|
||||
icon?: any;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
defaultSelected?: string;
|
||||
iconSize?: number;
|
||||
labelKey: string;
|
||||
menus: T[];
|
||||
title?: string;
|
||||
valueKey: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
iconSize: 16,
|
||||
childrenKey: '',
|
||||
controlBtns: () => [],
|
||||
footerButton: undefined,
|
||||
defaultExpandAll: true,
|
||||
defaultSelected: '',
|
||||
footerButton: undefined,
|
||||
iconSize: 16,
|
||||
title: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', item: T): void;
|
||||
}>();
|
||||
const panelWidth = ref(225);
|
||||
const selected = ref<string>(props.defaultSelected ?? '');
|
||||
const hoverId = ref<string>();
|
||||
|
||||
const handleChange = (item: T) => {
|
||||
selected.value = item[props.valueKey];
|
||||
const panelWidth = 225;
|
||||
const selected = ref<number | string>(props.defaultSelected ?? '');
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const lastEmittedDefault = ref('');
|
||||
const treeProps = computed(() => ({
|
||||
children: props.childrenKey,
|
||||
label: props.labelKey,
|
||||
}));
|
||||
|
||||
function menuValue(item: T) {
|
||||
return item[props.valueKey] as number | string;
|
||||
}
|
||||
|
||||
function normalizedValue(value: unknown) {
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
function findMenu(items: T[], value: number | string): T | undefined {
|
||||
for (const item of items) {
|
||||
if (normalizedValue(menuValue(item)) === normalizedValue(value)) {
|
||||
return item;
|
||||
}
|
||||
if (props.childrenKey) {
|
||||
const children = item[props.childrenKey] as T[] | undefined;
|
||||
const matched = children?.length ? findMenu(children, value) : undefined;
|
||||
if (matched) return matched;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function syncTreeSelection() {
|
||||
if (!props.childrenKey) return;
|
||||
void nextTick(() => treeRef.value?.setCurrentKey(selected.value));
|
||||
}
|
||||
|
||||
function handleChange(item: T) {
|
||||
selected.value = menuValue(item);
|
||||
lastEmittedDefault.value = normalizedValue(selected.value);
|
||||
emits('change', item);
|
||||
};
|
||||
// 监听 defaultSelected 的变化
|
||||
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,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
selected.value = newVal;
|
||||
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
|
||||
if (item) {
|
||||
emits('change', item);
|
||||
}
|
||||
[() => props.defaultSelected, () => props.menus],
|
||||
([defaultSelected]) => {
|
||||
selected.value = defaultSelected ?? '';
|
||||
syncTreeSelection();
|
||||
if (defaultSelected === '' || defaultSelected === undefined) return;
|
||||
const matched = findMenu(props.menus, defaultSelected);
|
||||
const normalized = normalizedValue(defaultSelected);
|
||||
if (matched && lastEmittedDefault.value !== normalized) {
|
||||
lastEmittedDefault.value = normalized;
|
||||
emits('change', matched);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
const handleMouseEvent = (id?: string) => {
|
||||
if (id === undefined) {
|
||||
setTimeout(() => {
|
||||
hoverId.value = id;
|
||||
}, 200);
|
||||
} else {
|
||||
hoverId.value = id;
|
||||
}
|
||||
};
|
||||
const isComponent = (icon: any) => {
|
||||
return typeof icon !== 'string';
|
||||
};
|
||||
const isSvgString = (icon: any) => {
|
||||
if (typeof icon !== 'string') return false;
|
||||
// 简单判断:是否包含 SVG 根标签
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
|
||||
:style="{ width: `${panelWidth}px` }"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-5 overflow-hidden">
|
||||
<h3 v-if="title && title.length > 0" class="text-base font-medium">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="page-side" :style="{ width: `${panelWidth}px` }">
|
||||
<div class="page-side__main">
|
||||
<h3 v-if="title" class="page-side__title">{{ title }}</h3>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="item[valueKey]"
|
||||
class="group list-item"
|
||||
:class="{
|
||||
selected: selected === item[valueKey],
|
||||
}"
|
||||
@click="handleChange(item)"
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="ml-[-3px] flex items-center justify-center"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
class="svg-container"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
v-else-if="
|
||||
typeof item.icon === 'string' && !isComponent(item.icon)
|
||||
"
|
||||
:src="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
objectFit: 'contain',
|
||||
}"
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" v-bind="$attrs" />
|
||||
</ElIcon>
|
||||
<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>
|
||||
<div>
|
||||
{{ item[labelKey] }}
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
|
||||
@click.stop
|
||||
</template>
|
||||
</ElTree>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="menuValue(item)"
|
||||
class="page-side__item"
|
||||
:class="{
|
||||
'is-selected':
|
||||
normalizedValue(selected) === normalizedValue(menuValue(item)),
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleChange(item)"
|
||||
@keydown.enter.prevent="handleChange(item)"
|
||||
@keydown.space.prevent="handleChange(item)"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group-hover:!inline-flex',
|
||||
(!hoverId || item.id !== hoverId) && '!hidden',
|
||||
)
|
||||
"
|
||||
>
|
||||
<ElIcon>
|
||||
<MoreFilled />
|
||||
</ElIcon>
|
||||
<div class="page-side__label">
|
||||
<div v-if="item.icon" class="page-side__icon">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
class="page-side__svg"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
v-else-if="
|
||||
typeof item.icon === 'string' && !isComponent(item.icon)
|
||||
"
|
||||
:src="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<slot name="label" :item="item">
|
||||
<span class="page-side__label-text">
|
||||
{{ item[labelKey] }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<div
|
||||
@mouseenter="handleMouseEvent(item.id)"
|
||||
@mouseleave="handleMouseEvent()"
|
||||
>
|
||||
|
||||
<ElDropdown v-if="hasActions(item)" trigger="click" @click.stop>
|
||||
<ElButton
|
||||
class="page-side__more"
|
||||
:aria-label="`管理${item[labelKey]}`"
|
||||
:icon="MoreFilled"
|
||||
text
|
||||
@click.stop
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="btn in controlBtns"
|
||||
:key="btn.label"
|
||||
@click="btn.onClick(item)"
|
||||
v-for="button in visibleActions(item)"
|
||||
:key="button.label"
|
||||
:disabled="actionDisabled(button, item)"
|
||||
@click="runAction(button, item)"
|
||||
>
|
||||
<ElButton :type="btn.type" :icon="btn.icon" link>
|
||||
{{ btn.label }}
|
||||
<ElButton
|
||||
:type="button.type"
|
||||
:icon="button.icon"
|
||||
:disabled="actionDisabled(button, item)"
|
||||
link
|
||||
>
|
||||
{{ button.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty
|
||||
v-if="menus.length <= 0"
|
||||
v-if="menus.length === 0"
|
||||
:image="getEmptyStateImageUrl(preferences.theme.mode)"
|
||||
/>
|
||||
</div>
|
||||
@@ -193,9 +327,12 @@ const isSvgString = (icon: any) => {
|
||||
|
||||
<ElButton
|
||||
v-if="footerButton"
|
||||
@click="footerButton.onClick"
|
||||
class="page-side__footer"
|
||||
:disabled="footerButton.disabled"
|
||||
:icon="footerButton.icon"
|
||||
:loading="footerButton.loading"
|
||||
plain
|
||||
@click="footerButton.onClick"
|
||||
>
|
||||
{{ footerButton.label }}
|
||||
</ElButton>
|
||||
@@ -203,34 +340,164 @@ const isSvgString = (icon: any) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
.page-side {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
|
||||
.page-side__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-side__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-side__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-side__item,
|
||||
.page-side__tree-node {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-side__item {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
border-radius: var(--radius-control);
|
||||
outline: none;
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
.page-side__item:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 15%);
|
||||
.page-side__item.is-selected {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.list-item.selected:where(.dark, .dark *) {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background-color: hsl(var(--accent));
|
||||
.page-side__item:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.svg-container :deep(svg) {
|
||||
.page-side__label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-side__label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-side__icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.page-side__icon img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.page-side__svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-side__more {
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.page-side__item:hover .page-side__more,
|
||||
.page-side__item:focus-within .page-side__more,
|
||||
.page-side__tree-node:hover .page-side__more,
|
||||
.page-side__tree-node:focus-within .page-side__more {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.page-side__footer {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.page-side__tree {
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node__content) {
|
||||
height: 40px;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-control);
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node__content:hover) {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.page-side__tree-node {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.page-side__svg :deep(svg) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillCapabilityPanel from './SkillCapabilityPanel.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSkillCapabilityBindings: vi.fn(),
|
||||
getSkillCapabilityCandidates: vi.fn(),
|
||||
getSkillCapabilityTools: vi.fn(),
|
||||
getSkillDetail: vi.fn(),
|
||||
replaceSkillCapabilityBindings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
const modalStub = {
|
||||
name: 'EasyFlowFormModal',
|
||||
props: ['open'],
|
||||
template:
|
||||
'<div v-if="open" data-testid="capability-dialog"><slot></slot><button data-testid="modal-confirm" @click="$emit(\'confirm\')">保存</button></div>',
|
||||
};
|
||||
|
||||
function mountPanel() {
|
||||
wrapper = mount(SkillCapabilityPanel, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
stubs: { EasyFlowFormModal: modalStub },
|
||||
},
|
||||
props: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
skillId: 101,
|
||||
},
|
||||
});
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function mountEmbeddedPanel() {
|
||||
wrapper = mount(SkillCapabilityPanel, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
stubs: { EasyFlowFormModal: modalStub },
|
||||
},
|
||||
props: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
embedded: true,
|
||||
skillId: 101,
|
||||
},
|
||||
});
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('skill capability panel load isolation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.getSkillCapabilityBindings.mockImplementation(
|
||||
(skillId: number | string) =>
|
||||
Promise.resolve(
|
||||
String(skillId) === '101'
|
||||
? {
|
||||
data: [
|
||||
{
|
||||
capabilityType: 'WORKFLOW',
|
||||
enabled: true,
|
||||
runtimeName: 'old_workflow',
|
||||
targetId: 11,
|
||||
targetName: '旧 Skill 工作流',
|
||||
targetStatus: 'AVAILABLE',
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
}
|
||||
: {
|
||||
data: undefined,
|
||||
errorCode: 1,
|
||||
message: 'load failed',
|
||||
},
|
||||
),
|
||||
);
|
||||
apiMocks.getSkillCapabilityCandidates.mockResolvedValue({
|
||||
data: [],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.getSkillCapabilityTools.mockResolvedValue({
|
||||
data: { status: 'AVAILABLE', targetId: 11, toolNames: [] },
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.getSkillDetail.mockResolvedValue({
|
||||
data: { capabilityHash: 'b'.repeat(64) },
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.replaceSkillCapabilityBindings.mockResolvedValue({
|
||||
data: { bindings: [], capabilityHash: 'c'.repeat(64) },
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('clears the previous Skill bindings and disables mutations when the next load fails', async () => {
|
||||
wrapper = mount(SkillCapabilityPanel, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
stubs: {
|
||||
EasyFlowFormModal: modalStub,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
skillId: 101,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('.skill-capability-panel__title').text()).toContain('1');
|
||||
expect(wrapper.find('.skill-capability-panel__list').exists()).toBe(true);
|
||||
|
||||
const addButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('添加能力'));
|
||||
expect(addButton).toBeDefined();
|
||||
await addButton?.trigger('click');
|
||||
await flushPromises();
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'EasyFlowFormModal' }).props('open'),
|
||||
).toBe(true);
|
||||
|
||||
await wrapper.setProps({
|
||||
capabilityHash: 'b'.repeat(64),
|
||||
skillId: 202,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getSkillCapabilityBindings).toHaveBeenNthCalledWith(1, 101);
|
||||
expect(apiMocks.getSkillCapabilityBindings).toHaveBeenNthCalledWith(2, 202);
|
||||
expect(wrapper.get('.skill-capability-panel__title').text()).toContain('0');
|
||||
expect(wrapper.find('.skill-capability-panel__list').exists()).toBe(false);
|
||||
expect(wrapper.text()).toContain('load failed');
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'EasyFlowFormModal' }).props('open'),
|
||||
).toBe(false);
|
||||
|
||||
expect(wrapper.text()).not.toContain('保存绑定');
|
||||
expect(wrapper.text()).not.toContain('校验');
|
||||
const addButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((button) => button.text().includes('添加'));
|
||||
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
addButtons.every((button) => button.attributes('disabled') !== undefined),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('removes duplicated embedded labels and keeps only actionable save states', async () => {
|
||||
mountEmbeddedPanel();
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper?.find('.skill-capability-panel__title').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(wrapper?.text()).not.toContain('已保存');
|
||||
|
||||
apiMocks.replaceSkillCapabilityBindings.mockImplementation(
|
||||
() => new Promise(() => undefined),
|
||||
);
|
||||
wrapper?.findComponent({ name: 'ElSwitch' }).vm.$emit('change', false);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(wrapper?.text()).toContain('保存中');
|
||||
});
|
||||
|
||||
it('loads MCP tools only for a selected scope and keeps failures retryable', async () => {
|
||||
apiMocks.getSkillCapabilityCandidates.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
capabilityType: 'MCP',
|
||||
name: '知识库 MCP',
|
||||
status: 'AVAILABLE',
|
||||
targetId: 88,
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.getSkillCapabilityTools
|
||||
.mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
errorCode: 1,
|
||||
message: 'load failed',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: {
|
||||
status: 'AVAILABLE',
|
||||
targetId: 88,
|
||||
toolNames: ['search'],
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
wrapper = mount(SkillCapabilityPanel, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
stubs: {
|
||||
EasyFlowFormModal: modalStub,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
skillId: 101,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const addButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('添加能力'));
|
||||
expect(addButton).toBeDefined();
|
||||
await addButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const typeSegmented = wrapper.findComponent({ name: 'ElSegmented' });
|
||||
expect(typeSegmented.exists()).toBe(true);
|
||||
typeSegmented.vm.$emit('update:modelValue', 'MCP');
|
||||
await flushPromises();
|
||||
|
||||
let selects = wrapper.findAllComponents({ name: 'ElSelect' });
|
||||
const candidateSelect = selects.find((select) => select.props('remote'));
|
||||
expect(candidateSelect).toBeDefined();
|
||||
candidateSelect?.vm.$emit('update:modelValue', 88);
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getSkillCapabilityTools).not.toHaveBeenCalled();
|
||||
|
||||
selects = wrapper.findAllComponents({ name: 'ElSelect' });
|
||||
const scopeSelect = selects.find(
|
||||
(select) => select.props('modelValue') === 'ALL',
|
||||
);
|
||||
expect(scopeSelect).toBeDefined();
|
||||
scopeSelect?.vm.$emit('update:modelValue', 'SELECTED');
|
||||
scopeSelect?.vm.$emit('change', 'SELECTED');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getSkillCapabilityTools).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getSkillCapabilityTools).toHaveBeenLastCalledWith(88);
|
||||
expect(
|
||||
wrapper
|
||||
.findAllComponents({ name: 'ElFormItem' })
|
||||
.some((item) => item.props('error') === 'load failed'),
|
||||
).toBe(true);
|
||||
|
||||
const retryButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '重新加载');
|
||||
await retryButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getSkillCapabilityTools).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
wrapper
|
||||
.findAllComponents({ name: 'ElFormItem' })
|
||||
.some((item) => item.props('error') === 'load failed'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
.findAllComponents({ name: 'ElOption' })
|
||||
.some((option) => option.props('value') === 'search'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('generates a stable runtime name for resources with a Chinese name', async () => {
|
||||
apiMocks.getSkillCapabilityCandidates.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
capabilityType: 'WORKFLOW',
|
||||
name: '测试工作流',
|
||||
status: 'AVAILABLE',
|
||||
targetId: 88,
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
const addButton = wrapper
|
||||
?.findAll('button')
|
||||
.find((button) => button.text().includes('添加能力'));
|
||||
await addButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
const candidateSelect = wrapper
|
||||
?.findAllComponents({ name: 'ElSelect' })
|
||||
.find((select) => select.props('remote'));
|
||||
candidateSelect?.vm.$emit('update:modelValue', 88);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper?.findComponent({ name: 'ElInput' }).props('modelValue'),
|
||||
).toBe('workflow_88');
|
||||
});
|
||||
|
||||
it('automatically saves toggles with the expected hash and updates the quiet state', async () => {
|
||||
apiMocks.replaceSkillCapabilityBindings.mockImplementation(
|
||||
(_skillId: number | string, nextBindings: unknown[]) =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
bindings: structuredClone(nextBindings),
|
||||
capabilityHash: 'c'.repeat(64),
|
||||
},
|
||||
errorCode: 0,
|
||||
}),
|
||||
);
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
wrapper?.findComponent({ name: 'ElSwitch' }).vm.$emit('change', false);
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenCalledWith(
|
||||
101,
|
||||
[expect.objectContaining({ enabled: false })],
|
||||
'a'.repeat(64),
|
||||
);
|
||||
expect(wrapper?.text()).toContain('已保存');
|
||||
expect(
|
||||
(wrapper?.vm as unknown as { hasDirty: () => boolean }).hasDirty(),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('serializes rapid changes and persists the latest desired state', async () => {
|
||||
let resolveFirst:
|
||||
| ((value: {
|
||||
data: { bindings: unknown[]; capabilityHash: string };
|
||||
errorCode: number;
|
||||
}) => void)
|
||||
| undefined;
|
||||
apiMocks.replaceSkillCapabilityBindings
|
||||
.mockImplementationOnce(
|
||||
(_skillId: number | string, _nextBindings: unknown[]) =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
(_skillId: number | string, nextBindings: unknown[]) =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
bindings: structuredClone(nextBindings),
|
||||
capabilityHash: 'd'.repeat(64),
|
||||
},
|
||||
errorCode: 0,
|
||||
}),
|
||||
);
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
const toggle = wrapper?.findComponent({ name: 'ElSwitch' });
|
||||
toggle?.vm.$emit('change', false);
|
||||
await Promise.resolve();
|
||||
toggle?.vm.$emit('change', true);
|
||||
resolveFirst?.({
|
||||
data: {
|
||||
bindings: [
|
||||
{
|
||||
capabilityType: 'WORKFLOW',
|
||||
enabled: false,
|
||||
runtimeName: 'old_workflow',
|
||||
targetId: 11,
|
||||
targetName: '旧 Skill 工作流',
|
||||
targetStatus: 'AVAILABLE',
|
||||
},
|
||||
],
|
||||
capabilityHash: 'c'.repeat(64),
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenCalledTimes(2);
|
||||
expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
101,
|
||||
[expect.objectContaining({ enabled: true })],
|
||||
'c'.repeat(64),
|
||||
);
|
||||
expect(wrapper?.text()).toContain('已保存');
|
||||
});
|
||||
|
||||
it('rolls back an optimistic toggle when automatic save fails', async () => {
|
||||
apiMocks.replaceSkillCapabilityBindings.mockRejectedValue(
|
||||
new Error('服务暂时不可用'),
|
||||
);
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
const toggle = wrapper?.findComponent({ name: 'ElSwitch' });
|
||||
toggle?.vm.$emit('change', false);
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper?.findComponent({ name: 'ElSwitch' }).props('modelValue'),
|
||||
).toBe(true);
|
||||
expect(wrapper?.text()).toContain('服务暂时不可用');
|
||||
expect(wrapper?.text()).toContain('保存失败');
|
||||
});
|
||||
|
||||
it('keeps local changes on conflict and can reapply them with a refreshed hash', async () => {
|
||||
apiMocks.replaceSkillCapabilityBindings
|
||||
.mockRejectedValueOnce({
|
||||
response: {
|
||||
data: { message: '能力配置已被其他操作更新' },
|
||||
status: 409,
|
||||
},
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
(_skillId: number | string, nextBindings: unknown[]) =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
bindings: structuredClone(nextBindings),
|
||||
capabilityHash: 'd'.repeat(64),
|
||||
},
|
||||
errorCode: 0,
|
||||
}),
|
||||
);
|
||||
mountPanel();
|
||||
await flushPromises();
|
||||
|
||||
const toggle = wrapper?.findComponent({ name: 'ElSwitch' });
|
||||
toggle?.vm.$emit('change', false);
|
||||
await flushPromises();
|
||||
|
||||
expect(toggle?.props('modelValue')).toBe(false);
|
||||
expect(wrapper?.text()).toContain('本地修改已保留');
|
||||
|
||||
const reapply = wrapper
|
||||
?.findAll('button')
|
||||
.find((button) => button.text().trim() === '重新应用');
|
||||
await reapply?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.replaceSkillCapabilityBindings).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
101,
|
||||
[expect.objectContaining({ enabled: false })],
|
||||
'b'.repeat(64),
|
||||
);
|
||||
expect(wrapper?.text()).toContain('已保存');
|
||||
});
|
||||
});
|
||||
1359
easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue
Normal file
1359
easyflow-ui-admin/app/src/views/ai/skill/SkillCapabilityPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillCategoryFormDialog from './SkillCategoryFormDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSkillCategoryTree: vi.fn(),
|
||||
saveSkillCategory: vi.fn(),
|
||||
updateSkillCategory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
vi.mock('@easyflow/common-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
EasyFlowFormModal: defineComponent({
|
||||
props: {
|
||||
open: Boolean,
|
||||
submitting: Boolean,
|
||||
title: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
emits: ['confirm', 'update:open'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('section', { class: 'form-modal-stub' }, [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'form-modal-confirm',
|
||||
disabled: props.submitting,
|
||||
onClick: () => emit('confirm'),
|
||||
type: 'button',
|
||||
},
|
||||
'保存',
|
||||
),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('skill category form dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.getSkillCategoryTree.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
categoryName: '研发',
|
||||
id: 1,
|
||||
levelNo: 1,
|
||||
status: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
levelNo: 2,
|
||||
parentId: 1,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.saveSkillCategory.mockResolvedValue({ data: {}, errorCode: 0 });
|
||||
apiMocks.updateSkillCategory.mockResolvedValue({
|
||||
data: {},
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a root category with the compact shared form modal', async () => {
|
||||
const wrapper = mount(SkillCategoryFormDialog, {
|
||||
props: { modelValue: true },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('添加分类');
|
||||
await wrapper.get('input').setValue('内容审核');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.saveSkillCategory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryName: '内容审核',
|
||||
parentId: '',
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
}),
|
||||
);
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1);
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]);
|
||||
});
|
||||
|
||||
it('edits a category while retaining its legal parent', async () => {
|
||||
const wrapper = mount(SkillCategoryFormDialog, {
|
||||
props: {
|
||||
category: {
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
levelNo: 2,
|
||||
parentId: 1,
|
||||
sortNo: 5,
|
||||
status: 1,
|
||||
},
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('编辑分类');
|
||||
await wrapper.get('input').setValue('平台工具');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.updateSkillCategory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryName: '平台工具',
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
sortNo: 5,
|
||||
}),
|
||||
);
|
||||
expect(apiMocks.saveSkillCategory).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCategory, SkillCategoryDraft } from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElInputNumber,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getSkillCategoryTree,
|
||||
saveSkillCategory,
|
||||
updateSkillCategory,
|
||||
} from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { getSkillCategoryParentOptions } from './skill-category';
|
||||
|
||||
const props = defineProps<{
|
||||
category?: SkillCategory;
|
||||
modelValue: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const categories = ref<SkillCategory[]>([]);
|
||||
const categoriesLoading = ref(false);
|
||||
const saveLoading = ref(false);
|
||||
const loadError = ref('');
|
||||
const actionError = ref('');
|
||||
const form = reactive<SkillCategoryDraft>({
|
||||
categoryName: '',
|
||||
parentId: '',
|
||||
sortNo: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saveLoading.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const parentOptions = computed(() =>
|
||||
getSkillCategoryParentOptions(categories.value, form.id),
|
||||
);
|
||||
const dialogTitle = computed(() => (form.id ? '编辑分类' : '添加分类'));
|
||||
const formRules: FormRules = {
|
||||
categoryName: [
|
||||
{ message: '请输入分类名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: '分类名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
categoryName: props.category?.categoryName || '',
|
||||
id: props.category?.id,
|
||||
parentId: props.category?.parentId ?? '',
|
||||
sortNo: props.category?.sortNo ?? 0,
|
||||
status: props.category?.status === 0 ? 0 : 1,
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
if (categoriesLoading.value) return;
|
||||
categoriesLoading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const res = await getSkillCategoryTree();
|
||||
if (res.errorCode !== 0) {
|
||||
loadError.value = res.message || '分类加载失败,请重试';
|
||||
return;
|
||||
}
|
||||
categories.value = res.data || [];
|
||||
} catch (error) {
|
||||
loadError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'分类加载失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
categoriesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCategory() {
|
||||
if (saveLoading.value || categoriesLoading.value) return;
|
||||
form.categoryName = form.categoryName.trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saveLoading.value = true;
|
||||
actionError.value = '';
|
||||
const editing = Boolean(form.id);
|
||||
try {
|
||||
const res = editing
|
||||
? await updateSkillCategory(form)
|
||||
: await saveSkillCategory(form);
|
||||
if (res.errorCode !== 0) {
|
||||
actionError.value = res.message || '分类保存失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success(editing ? '分类已更新' : '分类已创建');
|
||||
emit('update:modelValue', false);
|
||||
emit('saved');
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'分类保存失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saveLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (!visible) return;
|
||||
resetForm();
|
||||
void loadCategories();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.category,
|
||||
() => {
|
||||
if (props.modelValue) resetForm();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saveLoading"
|
||||
:confirm-loading="saveLoading"
|
||||
confirm-text="保存"
|
||||
:submitting="saveLoading || categoriesLoading"
|
||||
:title="dialogTitle"
|
||||
@confirm="submitCategory"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-category-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
:rules="formRules"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="loadError"
|
||||
class="skill-category-form__alert"
|
||||
:closable="false"
|
||||
:title="loadError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
class="skill-category-form__alert"
|
||||
:closable="false"
|
||||
:title="actionError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<ElFormItem label="分类名称" prop="categoryName">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.categoryName"
|
||||
maxlength="128"
|
||||
placeholder="请输入分类名称"
|
||||
show-word-limit
|
||||
@keyup.enter="submitCategory"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="上级分类">
|
||||
<ElSelect
|
||||
v-model="form.parentId"
|
||||
clearable
|
||||
:loading="categoriesLoading"
|
||||
placeholder="无(根分类)"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in parentOptions"
|
||||
:key="option.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(option.depth)}${option.categoryName}`"
|
||||
:value="option.id"
|
||||
:disabled="option.status === 0"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="排序">
|
||||
<ElInputNumber
|
||||
v-model="form.sortNo"
|
||||
:max="999_999"
|
||||
:min="-999_999"
|
||||
controls-position="right"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="启用">
|
||||
<ElSwitch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-category-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-category-form :deep(.el-select),
|
||||
.skill-category-form :deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillCreateDialog from './SkillCreateDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
saveSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
vi.mock('@easyflow/common-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
EasyFlowFormModal: defineComponent({
|
||||
props: {
|
||||
open: Boolean,
|
||||
submitting: Boolean,
|
||||
title: { default: '', type: String },
|
||||
},
|
||||
emits: ['confirm', 'update:open'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h('section', { class: 'form-modal-stub' }, [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'form-modal-confirm',
|
||||
disabled: props.submitting,
|
||||
onClick: () => emit('confirm'),
|
||||
type: 'button',
|
||||
},
|
||||
'创建并进入详情',
|
||||
),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('skill create dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.saveSkill.mockResolvedValue({
|
||||
data: {
|
||||
displayName: '研究助手',
|
||||
id: 101,
|
||||
publishStatus: 'DRAFT',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a valid draft from the compact business form', async () => {
|
||||
const wrapper = mount(SkillCreateDialog, {
|
||||
props: {
|
||||
canPublish: true,
|
||||
categories: [{ categoryName: '研发', id: 8, status: 1 }],
|
||||
defaultCategoryId: 8,
|
||||
modelValue: true,
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('新建 Skill');
|
||||
await wrapper.get('input').setValue('研究助手');
|
||||
const radioGroup = wrapper.getComponent({ name: 'ElRadioGroup' });
|
||||
radioGroup.vm.$emit('update:modelValue', 'PUBLISH');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.saveSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 8,
|
||||
displayName: '研究助手',
|
||||
enabled: true,
|
||||
publishStatus: 'DRAFT',
|
||||
visibilityScope: 'PRIVATE',
|
||||
}),
|
||||
);
|
||||
expect(apiMocks.saveSkill.mock.calls[0]?.[0].skillContent).toContain(
|
||||
'# Instructions',
|
||||
);
|
||||
expect(wrapper.emitted('created')?.[0]?.[0]).toMatchObject({
|
||||
intent: 'PUBLISH',
|
||||
skill: { id: 101 },
|
||||
});
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([false]);
|
||||
});
|
||||
|
||||
it('keeps publish intent unavailable without publish permission', async () => {
|
||||
const wrapper = mount(SkillCreateDialog, {
|
||||
props: { categories: [], modelValue: true },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const publishOption = wrapper
|
||||
.findAllComponents({ name: 'ElRadioButton' })
|
||||
.find((option) => option.text().includes('创建后发布'));
|
||||
expect(publishOption?.props('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
235
easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue
Normal file
235
easyflow-ui-admin/app/src/views/ai/skill/SkillCreateDialog.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCreateIntent } from './skill-create';
|
||||
import type { SkillCategory, SkillInfo } from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElRadioButton,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
|
||||
import { saveSkill } from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
import { buildInitialSkillDraft } from './skill-create';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canPublish?: boolean;
|
||||
categories: SkillCategory[];
|
||||
categoriesLoading?: boolean;
|
||||
defaultCategoryId?: number | string;
|
||||
modelValue: boolean;
|
||||
}>(),
|
||||
{
|
||||
canPublish: false,
|
||||
categoriesLoading: false,
|
||||
defaultCategoryId: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: [payload: { intent: SkillCreateIntent; skill: SkillInfo }];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const saving = ref(false);
|
||||
const actionError = ref('');
|
||||
const form = reactive({
|
||||
categoryId: '' as number | string,
|
||||
displayName: '',
|
||||
intent: 'DRAFT' as SkillCreateIntent,
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saving.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const categoryOptions = computed(() =>
|
||||
flattenSkillCategories(props.categories),
|
||||
);
|
||||
const rules: FormRules = {
|
||||
displayName: [
|
||||
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: 'Skill 名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
categoryId: props.defaultCategoryId || '',
|
||||
displayName: '',
|
||||
intent: 'DRAFT',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || props.categoriesLoading) return;
|
||||
form.displayName = form.displayName.trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saving.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await saveSkill(
|
||||
buildInitialSkillDraft({
|
||||
categoryId: form.categoryId || undefined,
|
||||
displayName: form.displayName,
|
||||
visibilityScope: form.visibilityScope,
|
||||
}),
|
||||
);
|
||||
if (response.errorCode !== 0 || !response.data?.id) {
|
||||
actionError.value = response.message || 'Skill 创建失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success('Skill 已创建');
|
||||
emit('update:modelValue', false);
|
||||
emit('created', { intent: form.intent, skill: response.data });
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'Skill 创建失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) resetForm();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saving"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="创建并进入详情"
|
||||
:submitting="saving || categoriesLoading"
|
||||
title="新建 Skill"
|
||||
@confirm="submit"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-create-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
class="skill-create-form__alert"
|
||||
:closable="false"
|
||||
:title="actionError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<ElFormItem label="Skill 名称" prop="displayName">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.displayName"
|
||||
maxlength="128"
|
||||
placeholder="请输入 Skill 名称"
|
||||
show-word-limit
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="分类">
|
||||
<ElSelect
|
||||
v-model="form.categoryId"
|
||||
clearable
|
||||
:loading="categoriesLoading"
|
||||
placeholder="未分类"
|
||||
>
|
||||
<ElOption
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 1"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="可见范围">
|
||||
<ElSelect v-model="form.visibilityScope">
|
||||
<ElOption label="仅自己" value="PRIVATE" />
|
||||
<ElOption label="本部门" value="DEPT" />
|
||||
<ElOption label="全员可见" value="PUBLIC" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="发布状态">
|
||||
<div class="skill-create-form__publish-field">
|
||||
<ElRadioGroup v-model="form.intent">
|
||||
<ElRadioButton value="DRAFT">草稿</ElRadioButton>
|
||||
<ElRadioButton value="PUBLISH" :disabled="!canPublish">
|
||||
创建后发布
|
||||
</ElRadioButton>
|
||||
</ElRadioGroup>
|
||||
<span v-if="form.intent === 'PUBLISH'">
|
||||
进入详情完善内容,通过校验后提交发布。
|
||||
</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-create-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-create-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-create-form__publish-field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.skill-create-form__publish-field > span {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
</style>
|
||||
449
easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts
Normal file
449
easyflow-ui-admin/app/src/views/ai/skill/SkillDetail.test.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
/* eslint-disable vue/one-component-per-file -- Inline stubs keep this publish-flow test isolated. */
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillDetail from './SkillDetail.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getSkillCategories: vi.fn(),
|
||||
getSkillDetail: vi.fn(),
|
||||
saveSkill: vi.fn(),
|
||||
submitSkillDeleteApproval: vi.fn(),
|
||||
submitSkillOfflineApproval: vi.fn(),
|
||||
submitSkillPublishApproval: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
const childMocks = vi.hoisted(() => ({
|
||||
capabilityHasDirty: vi.fn(() => false),
|
||||
capabilitySave: vi.fn(),
|
||||
resourceSave: vi.fn(async () => true),
|
||||
resourceValidate: vi.fn(),
|
||||
}));
|
||||
const permissionMocks = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn((_permissions: string[]) => true),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('vue-router')>();
|
||||
return {
|
||||
...original,
|
||||
onBeforeRouteLeave: vi.fn(),
|
||||
onBeforeRouteUpdate: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('./api', () => apiMocks);
|
||||
vi.mock('#/api/common/hasPermission', () => ({
|
||||
hasPermission: permissionMocks.hasPermission,
|
||||
}));
|
||||
vi.mock('./SkillResourceWorkbench.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillResourceWorkbench',
|
||||
props: {
|
||||
activePanel: { default: 'resources', type: String },
|
||||
capabilityAvailable: Boolean,
|
||||
readonly: Boolean,
|
||||
},
|
||||
emits: [
|
||||
'dirty',
|
||||
'issues',
|
||||
'locateCapability',
|
||||
'newContent',
|
||||
'requestSave',
|
||||
'update:activePanel',
|
||||
],
|
||||
setup(props, { expose, slots }) {
|
||||
expose({
|
||||
getSkillContent: () => undefined,
|
||||
saveAll: childMocks.resourceSave,
|
||||
validateAll: childMocks.resourceValidate,
|
||||
});
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
'data-readonly': String(props.readonly),
|
||||
'data-testid': 'resource-workbench',
|
||||
},
|
||||
slots.capability?.(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillSettingsDialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillSettingsDialog',
|
||||
props: {
|
||||
categoryLoadError: { default: '', type: String },
|
||||
modelValue: Boolean,
|
||||
},
|
||||
emits: ['retryCategories', 'saved', 'update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
return () =>
|
||||
props.modelValue
|
||||
? h('div', { 'data-testid': 'settings-dialog' }, [
|
||||
props.categoryLoadError,
|
||||
h('button', { onClick: () => emit('retryCategories') }, '重试'),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillCapabilityPanel.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillCapabilityPanel',
|
||||
props: { locked: Boolean, readonly: Boolean },
|
||||
setup(props, { expose }) {
|
||||
expose({
|
||||
flushPending: childMocks.capabilitySave,
|
||||
hasDirty: childMocks.capabilityHasDirty,
|
||||
});
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-locked': String(props.locked),
|
||||
'data-testid': 'capability-panel',
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let router: Router;
|
||||
|
||||
async function mountDetail(id = '101', navTitle?: string) {
|
||||
router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ component: { template: '<div />' }, path: '/ai/skill' },
|
||||
{
|
||||
component: { template: '<div />' },
|
||||
path: '/ai/skill/detail/:id',
|
||||
},
|
||||
],
|
||||
});
|
||||
await router.push({
|
||||
path: `/ai/skill/detail/${id}`,
|
||||
query: navTitle ? { navTitle } : undefined,
|
||||
});
|
||||
await router.isReady();
|
||||
const wrapper = mount(SkillDetail, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
plugins: [router],
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function publishButton(wrapper: ReturnType<typeof mount>) {
|
||||
const button = wrapper
|
||||
.findAll('button')
|
||||
.find((candidate) => candidate.text().trim() === '发布');
|
||||
if (!button) throw new Error('未找到发布按钮');
|
||||
return button;
|
||||
}
|
||||
|
||||
function markResourceDirty(wrapper: ReturnType<typeof mount>) {
|
||||
wrapper
|
||||
.getComponent({ name: 'SkillResourceWorkbench' })
|
||||
.vm.$emit('dirty', true);
|
||||
}
|
||||
|
||||
describe('skill detail publish transaction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
childMocks.capabilityHasDirty.mockReset().mockReturnValue(false);
|
||||
childMocks.capabilitySave.mockReset().mockResolvedValue(true);
|
||||
childMocks.resourceSave.mockReset().mockResolvedValue(true);
|
||||
childMocks.resourceValidate.mockReset().mockResolvedValue(true);
|
||||
permissionMocks.hasPermission.mockReset().mockReturnValue(true);
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
apiMocks.getSkillCategories.mockResolvedValue({ data: [], errorCode: 0 });
|
||||
apiMocks.getSkillDetail.mockResolvedValue({
|
||||
data: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
description: 'Demonstration skill',
|
||||
displayName: '演示 Skill',
|
||||
displayPublishStatus: 'DRAFT',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
manageable: true,
|
||||
name: 'demo-skill',
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent:
|
||||
'---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n',
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('locks editors and follows validate, confirm, submit order', async () => {
|
||||
const calls: string[] = [];
|
||||
childMocks.resourceSave.mockImplementation(async () => {
|
||||
calls.push('save');
|
||||
return true;
|
||||
});
|
||||
let resolveValidation: ((value: boolean) => void) | undefined;
|
||||
let resolveSubmit:
|
||||
| ((value: { data: number; errorCode: number; message: string }) => void)
|
||||
| undefined;
|
||||
childMocks.resourceValidate.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
calls.push('validate');
|
||||
resolveValidation = resolve;
|
||||
}),
|
||||
);
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockImplementation(async () => {
|
||||
calls.push('confirm');
|
||||
return { action: 'confirm' } as Awaited<
|
||||
ReturnType<typeof ElMessageBox.confirm>
|
||||
>;
|
||||
});
|
||||
apiMocks.submitSkillPublishApproval.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
calls.push('submit');
|
||||
resolveSubmit = resolve;
|
||||
}),
|
||||
);
|
||||
const wrapper = await mountDetail();
|
||||
markResourceDirty(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
await publishButton(wrapper).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toEqual(['save', 'validate']);
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="resource-workbench"]')
|
||||
.attributes('data-readonly'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'),
|
||||
).toBe('true');
|
||||
|
||||
resolveValidation?.(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toEqual(['save', 'validate', 'confirm', 'submit']);
|
||||
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
|
||||
expect(childMocks.resourceValidate).toHaveBeenCalledWith();
|
||||
|
||||
resolveSubmit?.({ data: 9001, errorCode: 0, message: '已提交' });
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="resource-workbench"]')
|
||||
.attributes('data-readonly'),
|
||||
).toBe('false');
|
||||
expect(
|
||||
wrapper.get('[data-testid="capability-panel"]').attributes('data-locked'),
|
||||
).toBe('false');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('does not confirm or submit when publish validation fails', async () => {
|
||||
childMocks.resourceValidate.mockResolvedValue(false);
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm');
|
||||
const wrapper = await mountDetail();
|
||||
|
||||
await publishButton(wrapper).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(childMocks.resourceValidate).toHaveBeenCalledWith();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('stops before validation when saving dirty resources fails', async () => {
|
||||
childMocks.resourceSave.mockResolvedValue(false);
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm');
|
||||
const wrapper = await mountDetail();
|
||||
markResourceDirty(wrapper);
|
||||
await flushPromises();
|
||||
|
||||
await publishButton(wrapper).trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
|
||||
expect(childMocks.resourceValidate).not.toHaveBeenCalled();
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(apiMocks.submitSkillPublishApproval).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('uses one resource and capability workspace and opens settings on demand', async () => {
|
||||
const wrapper = await mountDetail();
|
||||
const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' });
|
||||
|
||||
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0);
|
||||
expect(workbench.props('activePanel')).toBe('resources');
|
||||
expect(workbench.props('capabilityAvailable')).toBe(true);
|
||||
expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(wrapper.text()).not.toContain('设置');
|
||||
expect(wrapper.text()).not.toContain('校验');
|
||||
const lifecycleDropdown = wrapper.findComponent({ name: 'ElDropdown' });
|
||||
expect(lifecycleDropdown.props('trigger')).toBe('click');
|
||||
lifecycleDropdown.vm.$emit('command', 'settings');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('[data-testid="settings-dialog"]').exists()).toBe(true);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the routed display title when an older detail response omits it', async () => {
|
||||
apiMocks.getSkillDetail.mockResolvedValueOnce({
|
||||
data: {
|
||||
capabilityHash: 'a'.repeat(64),
|
||||
displayName: '',
|
||||
displayPublishStatus: 'DRAFT',
|
||||
manageable: true,
|
||||
name: '',
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent:
|
||||
'---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const wrapper = await mountDetail('101', '验收 Skill');
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('验收 Skill');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps one file save action and handles workbench and keyboard save requests', async () => {
|
||||
const wrapper = await mountDetail();
|
||||
const saveButtons = wrapper
|
||||
.findAll('button')
|
||||
.filter((button) => button.text().trim() === '保存');
|
||||
expect(saveButtons).toHaveLength(1);
|
||||
expect(saveButtons[0]?.attributes('disabled')).toBeDefined();
|
||||
|
||||
markResourceDirty(wrapper);
|
||||
await flushPromises();
|
||||
expect(saveButtons[0]?.attributes('disabled')).toBeUndefined();
|
||||
|
||||
wrapper
|
||||
.getComponent({ name: 'SkillResourceWorkbench' })
|
||||
.vm.$emit('requestSave');
|
||||
await flushPromises();
|
||||
expect(childMocks.resourceSave).toHaveBeenCalledOnce();
|
||||
|
||||
markResourceDirty(wrapper);
|
||||
await flushPromises();
|
||||
const shortcut = new KeyboardEvent('keydown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
key: 's',
|
||||
metaKey: true,
|
||||
});
|
||||
window.dispatchEvent(shortcut);
|
||||
await flushPromises();
|
||||
|
||||
expect(shortcut.defaultPrevented).toBe(true);
|
||||
expect(childMocks.resourceSave).toHaveBeenCalledTimes(2);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('redirects the retired direct-new route back to the Skill list', async () => {
|
||||
const wrapper = await mountDetail('new');
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/ai/skill');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps validation navigation on resources without capability permission', async () => {
|
||||
permissionMocks.hasPermission.mockImplementation(
|
||||
(permissions: string[]) =>
|
||||
!permissions.includes('/api/v1/skill/capability'),
|
||||
);
|
||||
const warning = vi.spyOn(ElMessage, 'warning');
|
||||
const wrapper = await mountDetail();
|
||||
|
||||
const workbench = wrapper.getComponent({ name: 'SkillResourceWorkbench' });
|
||||
workbench.vm.$emit('locateCapability');
|
||||
await flushPromises();
|
||||
|
||||
expect(workbench.props('activePanel')).toBe('resources');
|
||||
expect(workbench.props('capabilityAvailable')).toBe(false);
|
||||
expect(wrapper.find('[data-testid="capability-panel"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
expect(warning).toHaveBeenCalledWith('当前没有查看能力绑定的权限');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('distinguishes a category request failure from an unavailable category and supports retry', async () => {
|
||||
apiMocks.getSkillCategories.mockRejectedValueOnce(new Error('network'));
|
||||
apiMocks.getSkillDetail.mockResolvedValueOnce({
|
||||
data: {
|
||||
categoryId: 77,
|
||||
categoryName: '研发工具',
|
||||
description: 'Demonstration skill',
|
||||
displayName: '演示 Skill',
|
||||
displayPublishStatus: 'DRAFT',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
manageable: true,
|
||||
name: 'demo-skill',
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent:
|
||||
'---\nname: demo-skill\ndescription: Demonstration skill\n---\n\n# Instructions\n',
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
errorCode: 0,
|
||||
});
|
||||
const wrapper = await mountDetail();
|
||||
|
||||
wrapper
|
||||
.findComponent({ name: 'ElDropdown' })
|
||||
.vm.$emit('command', 'settings');
|
||||
await flushPromises();
|
||||
expect(wrapper.get('[data-testid="settings-dialog"]').text()).toContain(
|
||||
'分类加载失败,请重试',
|
||||
);
|
||||
|
||||
apiMocks.getSkillCategories.mockResolvedValueOnce({
|
||||
data: [{ categoryName: '研发工具', id: 77, status: 1 }],
|
||||
errorCode: 0,
|
||||
});
|
||||
await wrapper
|
||||
.get('[data-testid="settings-dialog"] button')
|
||||
.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.getSkillCategories).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.text()).not.toContain('分类加载失败,请重试');
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
216
easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
Normal file
216
easyflow-ui-admin/app/src/views/ai/skill/SkillList.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillList from './SkillList.vue';
|
||||
import skillListSource from './SkillList.vue?raw';
|
||||
|
||||
/* eslint-disable vue/one-component-per-file -- Inline stubs keep this layout test isolated. */
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
deleteSkillCategory: vi.fn(),
|
||||
getSkillCategories: vi.fn(),
|
||||
}));
|
||||
const permissionMocks = vi.hoisted(() => ({
|
||||
hasPermission: vi.fn((_permissions: string[]) => true),
|
||||
}));
|
||||
const pageDataMocks = vi.hoisted(() => ({
|
||||
reload: vi.fn(),
|
||||
setQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/api/common/hasPermission', () => permissionMocks);
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => (key === 'common.allCategories' ? '全部' : key),
|
||||
}));
|
||||
vi.mock('./api', () => ({
|
||||
cancelSkillImport: vi.fn(),
|
||||
copySkill: vi.fn(),
|
||||
deleteSkillCategory: apiMocks.deleteSkillCategory,
|
||||
exportSkills: vi.fn(),
|
||||
getSkillCapabilityCandidates: vi.fn(),
|
||||
getSkillCategories: apiMocks.getSkillCategories,
|
||||
importSkillConfirm: vi.fn(),
|
||||
importSkillPreview: vi.fn(),
|
||||
submitSkillDeleteApproval: vi.fn(),
|
||||
submitSkillOfflineApproval: vi.fn(),
|
||||
submitSkillPublishApproval: vi.fn(),
|
||||
}));
|
||||
vi.mock('#/components/page/PageData.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'PageData',
|
||||
setup(_, { expose }) {
|
||||
expose(pageDataMocks);
|
||||
return () => h('div', { class: 'page-data-container' });
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillCategoryFormDialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillCategoryFormDialog',
|
||||
setup: () => () => h('div'),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('./SkillCreateDialog.vue', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
default: defineComponent({
|
||||
name: 'SkillCreateDialog',
|
||||
props: { modelValue: Boolean },
|
||||
emits: ['created', 'update:modelValue'],
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', {
|
||||
'data-open': String(props.modelValue),
|
||||
'data-testid': 'skill-create-dialog',
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
async function mountSkillList() {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ component: { template: '<div />' }, path: '/ai/skill' },
|
||||
{
|
||||
component: { template: '<div />' },
|
||||
path: '/ai/skill/detail/:id?',
|
||||
},
|
||||
],
|
||||
});
|
||||
await router.push('/ai/skill');
|
||||
await router.isReady();
|
||||
|
||||
const wrapper = mount(SkillList, {
|
||||
global: {
|
||||
directives: { access: {}, loading: {} },
|
||||
plugins: [router],
|
||||
stubs: { ElDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('skill list layout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
permissionMocks.hasPermission.mockReturnValue(true);
|
||||
apiMocks.getSkillCategories.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
categoryName: '研发',
|
||||
id: 1,
|
||||
status: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
parentId: 1,
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('places the toolbar above a full-height category and list workspace', async () => {
|
||||
const wrapper = await mountSkillList();
|
||||
|
||||
const header = wrapper.get('.skill-list-page__header');
|
||||
const toolbar = header.get('.custom-header');
|
||||
expect(toolbar.text()).toContain('导入');
|
||||
expect(toolbar.text()).toContain('导出');
|
||||
expect(toolbar.text()).toContain('新建 Skill');
|
||||
expect(toolbar.get('.search-group').findAll('button')).toHaveLength(2);
|
||||
expect(toolbar.find('input').attributes()).toMatchObject({
|
||||
placeholder: '请输入 Skill 名称或描述',
|
||||
});
|
||||
|
||||
const workspace = wrapper.get('.skill-list-page__workspace');
|
||||
expect(workspace.find('.skill-list-page__header').exists()).toBe(false);
|
||||
expect(workspace.element.children[0]?.classList).toContain('page-side');
|
||||
expect(workspace.element.children[1]?.classList).toContain(
|
||||
'skill-list-page__content',
|
||||
);
|
||||
expect(workspace.get('.skill-list-page__content').classes()).toContain(
|
||||
'skill-list-page__content',
|
||||
);
|
||||
|
||||
const sidebar = workspace.get('#skill-category-sidebar');
|
||||
expect(sidebar.text()).toContain('全部');
|
||||
expect(sidebar.text()).toContain('研发');
|
||||
expect(sidebar.text()).toContain('平台');
|
||||
expect(sidebar.text()).not.toContain('未分类');
|
||||
expect(sidebar.get('.page-side__footer').text()).toContain('添加');
|
||||
|
||||
const developmentNode = sidebar
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('研发'));
|
||||
expect(developmentNode).toBeTruthy();
|
||||
await developmentNode?.trigger('click');
|
||||
expect(pageDataMocks.setQuery).toHaveBeenLastCalledWith({
|
||||
categoryId: 1,
|
||||
displayName: undefined,
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('keeps the complete flex height chain for the list area', () => {
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page\s*\{[^}]*display:\s*flex;[^}]*height:\s*100%;[^}]*min-height:\s*0;/,
|
||||
);
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page__workspace\s*\{[^}]*display:\s*grid;[^}]*flex:\s*1;[^}]*min-height:\s*0;/,
|
||||
);
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page__content\s*\{[^}]*display:\s*flex;[^}]*flex:\s*1;[^}]*height:\s*100%;[^}]*min-height:\s*0;/,
|
||||
);
|
||||
expect(skillListSource).toMatch(
|
||||
/\.skill-list-page\s+:deep\(\.page-data-container\)\s*\{[^}]*height:\s*100%;/,
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the compact creation dialog without navigating to a fake detail', async () => {
|
||||
const wrapper = await mountSkillList();
|
||||
const createButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('新建 Skill'));
|
||||
|
||||
await createButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.get('[data-testid="skill-create-dialog"]')
|
||||
.attributes('data-open'),
|
||||
).toBe('true');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it('hides category write actions without category management permission', async () => {
|
||||
permissionMocks.hasPermission.mockImplementation(
|
||||
(permissions: string[]) =>
|
||||
!permissions.includes('/api/v1/skill/category'),
|
||||
);
|
||||
const wrapper = await mountSkillList();
|
||||
const sidebar = wrapper.get('#skill-category-sidebar');
|
||||
|
||||
expect(sidebar.find('.page-side__footer').exists()).toBe(false);
|
||||
expect(sidebar.findAll('.page-side__more')).toHaveLength(0);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,380 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillResourceWorkbench from './SkillResourceWorkbench.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createSkillFile: vi.fn(),
|
||||
deleteSkillFile: vi.fn(),
|
||||
downloadSkillFile: vi.fn(),
|
||||
getSkillFileContent: vi.fn(),
|
||||
getSkillFileTree: vi.fn(),
|
||||
previewSkillFile: vi.fn(),
|
||||
renameSkillFile: vi.fn(),
|
||||
saveSkillFile: vi.fn(),
|
||||
uploadSkillFile: vi.fn(),
|
||||
validateSkillForPublish: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
|
||||
const resources = [
|
||||
{
|
||||
isText: false,
|
||||
key: 'examples/archive.bin',
|
||||
mediaType: 'application/octet-stream',
|
||||
name: 'archive.bin',
|
||||
path: 'examples/archive.bin',
|
||||
size: 2048,
|
||||
type: 'EXAMPLE',
|
||||
},
|
||||
{
|
||||
isText: false,
|
||||
key: 'resources/diagram.png',
|
||||
mediaType: 'image/png',
|
||||
name: 'diagram.png',
|
||||
path: 'resources/diagram.png',
|
||||
size: 4096,
|
||||
type: 'OTHER',
|
||||
},
|
||||
{
|
||||
isText: false,
|
||||
key: 'references/manual.pdf',
|
||||
mediaType: 'application/pdf',
|
||||
name: 'manual.pdf',
|
||||
path: 'references/manual.pdf',
|
||||
size: 8192,
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
{
|
||||
content: '{"ok":true}\n',
|
||||
isText: true,
|
||||
key: 'references/data.json',
|
||||
language: undefined,
|
||||
mediaType: 'application/json',
|
||||
name: 'data.json',
|
||||
path: 'references/data.json',
|
||||
size: 12,
|
||||
type: 'REFERENCE',
|
||||
},
|
||||
];
|
||||
|
||||
let wrapper: ReturnType<typeof mount> | undefined;
|
||||
|
||||
async function mountAndSelect(path: string, readonly = false) {
|
||||
wrapper = mount(SkillResourceWorkbench, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: {
|
||||
CodeEditor: {
|
||||
props: ['language'],
|
||||
template:
|
||||
'<div data-testid="code-editor" :data-language="language"></div>',
|
||||
},
|
||||
CodeViewer: {
|
||||
props: ['filename', 'language'],
|
||||
template:
|
||||
'<div data-testid="code-viewer" :data-filename="filename" :data-language="language"><button>复制</button><button>下载</button></div>',
|
||||
},
|
||||
MarkdownLiveEditor: {
|
||||
template: '<div data-testid="markdown-live-editor"></div>',
|
||||
},
|
||||
MarkdownSourceEditor: {
|
||||
template: '<div data-testid="markdown-source-editor"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: { readonly, skillId: 101 },
|
||||
});
|
||||
await flushPromises();
|
||||
await wrapper.get(`[data-tree-path="${path}"]`).trigger('click');
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function toolbarButton(label: string) {
|
||||
return wrapper?.find(`button[aria-label="${label}"]`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:skill-preview'),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
apiMocks.getSkillFileTree.mockResolvedValue({
|
||||
data: [
|
||||
{ key: 'SKILL.md', name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
|
||||
...resources,
|
||||
],
|
||||
errorCode: 0,
|
||||
});
|
||||
apiMocks.getSkillFileContent.mockImplementation(
|
||||
(_skillId: number, path: string) => {
|
||||
if (path === 'SKILL.md') {
|
||||
return Promise.resolve({ data: undefined, errorCode: 1 });
|
||||
}
|
||||
const file = resources.find((item) => item.path === path);
|
||||
return Promise.resolve({
|
||||
data: { ...file, content: '', contentHash: 'a'.repeat(64) },
|
||||
errorCode: file ? 0 : 1,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount();
|
||||
wrapper = undefined;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('skill resource workbench binary resources', () => {
|
||||
it('routes a JSON reference to the code editor instead of Markdown live mode', async () => {
|
||||
await mountAndSelect('references/data.json');
|
||||
|
||||
expect(wrapper?.find('[data-testid="code-editor"]').exists()).toBe(true);
|
||||
expect(
|
||||
wrapper?.find('[data-testid="code-editor"]').attributes('data-language'),
|
||||
).toBe('json');
|
||||
expect(wrapper?.find('[data-testid="markdown-live-editor"]').exists()).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('gives readonly generic text the shared copy and download viewer', async () => {
|
||||
await mountAndSelect('references/data.json', true);
|
||||
|
||||
expect(wrapper?.find('[data-testid="code-editor"]').exists()).toBe(false);
|
||||
const viewer = wrapper?.get('[data-testid="code-viewer"]');
|
||||
expect(viewer?.attributes('data-filename')).toBe('references/data.json');
|
||||
expect(viewer?.attributes('data-language')).toBe('json');
|
||||
expect(viewer?.text()).toContain('复制');
|
||||
expect(viewer?.text()).toContain('下载');
|
||||
});
|
||||
|
||||
it('renders generic EXAMPLE binaries with metadata and only meaningful actions', async () => {
|
||||
await mountAndSelect('examples/archive.bin');
|
||||
|
||||
expect(wrapper?.text()).toContain('application/octet-stream');
|
||||
expect(wrapper?.text()).toContain('2.0 KB');
|
||||
expect(wrapper?.text()).toContain('此格式不支持在线预览');
|
||||
expect(toolbarButton('搜索')?.exists()).toBe(false);
|
||||
expect(toolbarButton('切换换行')?.exists()).toBe(false);
|
||||
expect(
|
||||
wrapper?.findAll('button').some((button) => button.text() === '保存'),
|
||||
).toBe(false);
|
||||
expect(toolbarButton('进入全屏')?.exists()).toBe(true);
|
||||
expect(toolbarButton('重命名文件')?.exists()).toBe(false);
|
||||
expect(toolbarButton('删除文件')?.exists()).toBe(false);
|
||||
expect(
|
||||
wrapper
|
||||
?.get('[data-tree-path="examples/archive.bin"]')
|
||||
.element.parentElement?.querySelector('[aria-label="更多操作"]'),
|
||||
).not.toBeNull();
|
||||
expect(toolbarButton('下载资源')?.exists()).toBe(true);
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset button').text(),
|
||||
).toBe('下载');
|
||||
expect(apiMocks.previewSkillFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('previews an OTHER image only after the response MIME type is verified', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['image'], { type: 'image/png' }),
|
||||
);
|
||||
|
||||
await mountAndSelect('resources/diagram.png');
|
||||
|
||||
expect(apiMocks.previewSkillFile).toHaveBeenCalledWith(
|
||||
101,
|
||||
'resources/diagram.png',
|
||||
);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-preview').exists(),
|
||||
).toBe(true);
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-image').exists(),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a sandboxed PDF preview for any binary resource kind', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['pdf'], { type: 'application/pdf' }),
|
||||
);
|
||||
|
||||
await mountAndSelect('references/manual.pdf');
|
||||
|
||||
const iframe = wrapper?.get('iframe');
|
||||
expect(iframe?.attributes('src')).toBe('blob:skill-preview');
|
||||
expect(iframe?.attributes('sandbox')).toBe('');
|
||||
expect(iframe?.attributes('referrerpolicy')).toBe('no-referrer');
|
||||
expect(iframe?.attributes('title')).toContain('PDF 预览');
|
||||
});
|
||||
|
||||
it('rejects a preview response whose MIME type does not match the safe renderer', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['<html></html>'], { type: 'text/html' }),
|
||||
);
|
||||
|
||||
await mountAndSelect('resources/diagram.png');
|
||||
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-image').exists(),
|
||||
).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
expect(wrapper?.text()).toContain('可下载后使用本地应用打开');
|
||||
});
|
||||
|
||||
it('falls back to download when an image renderer rejects valid-MIME content', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['broken'], { type: 'image/png' }),
|
||||
);
|
||||
await mountAndSelect('resources/diagram.png');
|
||||
|
||||
await wrapper
|
||||
?.get('.skill-resource-workbench__asset-image')
|
||||
.trigger('error');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper?.find('.skill-resource-workbench__asset-image').exists(),
|
||||
).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
expect(wrapper?.text()).toContain('可下载后使用本地应用打开');
|
||||
});
|
||||
|
||||
it('falls back to download when the PDF iframe reports a render error', async () => {
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['broken'], { type: 'application/pdf' }),
|
||||
);
|
||||
await mountAndSelect('references/manual.pdf');
|
||||
|
||||
await wrapper?.get('iframe').trigger('error');
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper?.find('iframe').exists()).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
});
|
||||
|
||||
it('falls back when the PDF renderer never reports readiness', async () => {
|
||||
vi.useFakeTimers();
|
||||
apiMocks.previewSkillFile.mockResolvedValue(
|
||||
new Blob(['pdf'], { type: 'application/pdf' }),
|
||||
);
|
||||
await mountAndSelect('references/manual.pdf');
|
||||
|
||||
vi.advanceTimersByTime(8001);
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper?.find('iframe').exists()).toBe(false);
|
||||
expect(wrapper?.text()).toContain('预览加载失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill resource workbench validation', () => {
|
||||
it('uses the publish preflight endpoint for publish validation', async () => {
|
||||
apiMocks.validateSkillForPublish.mockResolvedValue({
|
||||
data: { issues: [], valid: true },
|
||||
errorCode: 0,
|
||||
});
|
||||
wrapper = mount(SkillResourceWorkbench, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: {
|
||||
MarkdownLiveEditor: {
|
||||
template: '<div data-testid="markdown-live-editor"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: { skillId: 101 },
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const valid = await (
|
||||
wrapper.vm as unknown as {
|
||||
validateAll: () => Promise<boolean>;
|
||||
}
|
||||
).validateAll();
|
||||
|
||||
expect(valid).toBe(true);
|
||||
expect(apiMocks.validateSkillForPublish).toHaveBeenCalledWith(101);
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill resource workbench navigation', () => {
|
||||
it('shows the standard directories and opens a directory-aware create form', async () => {
|
||||
await mountAndSelect('references');
|
||||
|
||||
expect(wrapper?.find('[data-tree-path="scripts"]').exists()).toBe(true);
|
||||
expect(wrapper?.find('[data-tree-path="assets"]').exists()).toBe(true);
|
||||
expect(wrapper?.text()).toContain('此目录暂无内容');
|
||||
|
||||
const createButton = wrapper
|
||||
?.findAll('.skill-resource-workbench__directory-state button')
|
||||
.find((button) => button.text().trim() === '新建文件');
|
||||
await createButton?.trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
?.findAllComponents({ name: 'ElSelect' })
|
||||
.some((select) => select.props('modelValue') === 'references'),
|
||||
).toBe(true);
|
||||
const fileNameInput = wrapper
|
||||
?.findAllComponents({ name: 'ElInput' })
|
||||
.find((input) => input.props('placeholder') === '例如 guide.md');
|
||||
expect(fileNameInput?.props('modelValue')).toBe('');
|
||||
});
|
||||
|
||||
it('keeps capability binding in the same left navigation and switches panels', async () => {
|
||||
wrapper = mount(SkillResourceWorkbench, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
stubs: {
|
||||
MarkdownLiveEditor: {
|
||||
template: '<div data-testid="markdown-live-editor"></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
activePanel: 'resources',
|
||||
capabilityAvailable: true,
|
||||
capabilityCount: 3,
|
||||
skillId: 101,
|
||||
},
|
||||
slots: {
|
||||
capability: '<div data-testid="capability-slot">能力配置</div>',
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
const entry = wrapper.get('.skill-resource-workbench__capability-entry');
|
||||
expect(entry.text()).toContain('能力绑定');
|
||||
expect(entry.text()).toContain('3');
|
||||
expect(
|
||||
wrapper
|
||||
.get('.skill-resource-workbench__capability-area')
|
||||
.attributes('style'),
|
||||
).toContain('display: none');
|
||||
|
||||
await entry.trigger('click');
|
||||
expect(wrapper.emitted('update:activePanel')?.at(-1)).toEqual([
|
||||
'capability',
|
||||
]);
|
||||
await wrapper.setProps({ activePanel: 'capability' });
|
||||
expect(
|
||||
wrapper
|
||||
.get('.skill-resource-workbench__capability-area')
|
||||
.attributes('style'),
|
||||
).toBeUndefined();
|
||||
expect(entry.attributes('aria-current')).toBe('page');
|
||||
});
|
||||
});
|
||||
1881
easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
Normal file
1881
easyflow-ui-admin/app/src/views/ai/skill/SkillResourceWorkbench.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import SkillSettingsDialog from './SkillSettingsDialog.vue';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
updateSkill: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./api', () => apiMocks);
|
||||
vi.mock('@easyflow/common-ui', async () => {
|
||||
const { defineComponent, h } = await import('vue');
|
||||
return {
|
||||
EasyFlowFormModal: defineComponent({
|
||||
props: {
|
||||
open: Boolean,
|
||||
submitting: Boolean,
|
||||
title: { default: '', type: String },
|
||||
},
|
||||
emits: ['confirm', 'update:open'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
props.open
|
||||
? h('section', [
|
||||
h('h2', props.title),
|
||||
slots.default?.(),
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
class: 'form-modal-confirm',
|
||||
disabled: props.submitting,
|
||||
onClick: () => emit('confirm'),
|
||||
type: 'button',
|
||||
},
|
||||
'保存',
|
||||
),
|
||||
])
|
||||
: null;
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('skill settings dialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMocks.updateSkill.mockResolvedValue({
|
||||
data: { displayName: '更新后的 Skill', id: 101 },
|
||||
errorCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates only management settings through the shared compact modal', async () => {
|
||||
const wrapper = mount(SkillSettingsDialog, {
|
||||
props: {
|
||||
categories: [{ categoryName: '研发', id: 8, status: 1 }],
|
||||
modelValue: true,
|
||||
skill: {
|
||||
categoryId: 8,
|
||||
displayName: '原 Skill',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('h2').text()).toBe('编辑基本信息');
|
||||
await wrapper.get('input').setValue('更新后的 Skill');
|
||||
const selects = wrapper.findAllComponents({ name: 'ElSelect' });
|
||||
selects[1]?.vm.$emit('update:modelValue', 'DEPT');
|
||||
await wrapper.get('.form-modal-confirm').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiMocks.updateSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 8,
|
||||
displayName: '更新后的 Skill',
|
||||
enabled: true,
|
||||
id: 101,
|
||||
visibilityScope: 'DEPT',
|
||||
}),
|
||||
);
|
||||
expect(wrapper.emitted('saved')?.[0]?.[0]).toMatchObject({ id: 101 });
|
||||
});
|
||||
|
||||
it('keeps category loading failure visible and retryable', async () => {
|
||||
const wrapper = mount(SkillSettingsDialog, {
|
||||
props: {
|
||||
categories: [],
|
||||
categoryLoadError: '分类加载失败,请重试',
|
||||
modelValue: true,
|
||||
skill: { displayName: '原 Skill', id: 101 },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('重新加载');
|
||||
const retry = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().trim() === '重新加载');
|
||||
await retry?.trigger('click');
|
||||
expect(wrapper.emitted('retryCategories')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
241
easyflow-ui-admin/app/src/views/ai/skill/SkillSettingsDialog.vue
Normal file
241
easyflow-ui-admin/app/src/views/ai/skill/SkillSettingsDialog.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance, FormRules } from 'element-plus';
|
||||
|
||||
import type { SkillCategory, SkillInfo } from './types';
|
||||
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
||||
|
||||
import {
|
||||
ElAlert,
|
||||
ElButton,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
ElOption,
|
||||
ElSelect,
|
||||
ElSwitch,
|
||||
} from 'element-plus';
|
||||
|
||||
import { updateSkill } from './api';
|
||||
import { resolveSkillApiErrorMessage } from './skill-api-error';
|
||||
import { flattenSkillCategories } from './skill-category';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
categories: SkillCategory[];
|
||||
categoriesLoading?: boolean;
|
||||
categoryLoadError?: string;
|
||||
modelValue: boolean;
|
||||
skill: SkillInfo;
|
||||
}>(),
|
||||
{ categoriesLoading: false, categoryLoadError: '' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
retryCategories: [];
|
||||
saved: [skill: SkillInfo];
|
||||
'update:modelValue': [value: boolean];
|
||||
}>();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const nameInputRef = ref<InstanceType<typeof ElInput>>();
|
||||
const saving = ref(false);
|
||||
const actionError = ref('');
|
||||
const form = reactive<SkillInfo>({});
|
||||
const categoryOptions = computed(() =>
|
||||
flattenSkillCategories(props.categories),
|
||||
);
|
||||
const currentCategoryAvailable = computed(() =>
|
||||
categoryOptions.value.some(
|
||||
(category) => String(category.id) === String(form.categoryId),
|
||||
),
|
||||
);
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
if (!value && saving.value) {
|
||||
ElMessage.info('请等待当前操作完成');
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', value);
|
||||
},
|
||||
});
|
||||
const rules: FormRules = {
|
||||
displayName: [
|
||||
{ message: '请输入 Skill 名称', required: true, trigger: 'blur' },
|
||||
{
|
||||
max: 128,
|
||||
message: 'Skill 名称不能超过 128 个字符',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
categoryId: props.skill.categoryId || '',
|
||||
categoryName: props.skill.categoryName,
|
||||
displayName: props.skill.displayName || '',
|
||||
enabled: props.skill.enabled !== false,
|
||||
id: props.skill.id,
|
||||
visibilityScope: props.skill.visibilityScope || 'PRIVATE',
|
||||
});
|
||||
actionError.value = '';
|
||||
formRef.value?.clearValidate();
|
||||
void nextTick(() => nameInputRef.value?.focus());
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value || props.categoriesLoading) return;
|
||||
form.displayName = String(form.displayName || '').trim();
|
||||
if (!(await formRef.value?.validate().catch(() => false))) return;
|
||||
|
||||
saving.value = true;
|
||||
actionError.value = '';
|
||||
try {
|
||||
const response = await updateSkill(form);
|
||||
if (response.errorCode !== 0) {
|
||||
actionError.value = response.message || '设置保存失败,请重试';
|
||||
return;
|
||||
}
|
||||
ElMessage.success('基本信息已保存');
|
||||
emit('update:modelValue', false);
|
||||
emit('saved', response.data);
|
||||
} catch (error) {
|
||||
actionError.value = resolveSkillApiErrorMessage(
|
||||
error,
|
||||
'设置保存失败,请重试',
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) resetForm();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EasyFlowFormModal
|
||||
v-model:open="dialogVisible"
|
||||
width="520px"
|
||||
:closable="!saving"
|
||||
:confirm-loading="saving"
|
||||
confirm-text="保存"
|
||||
:submitting="saving || categoriesLoading"
|
||||
title="编辑基本信息"
|
||||
@confirm="submit"
|
||||
>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
class="skill-settings-form easyflow-modal-form easyflow-modal-form--compact"
|
||||
label-position="top"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="actionError"
|
||||
class="skill-settings-form__alert"
|
||||
:closable="false"
|
||||
:title="actionError"
|
||||
type="error"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<ElFormItem label="Skill 名称" prop="displayName">
|
||||
<ElInput
|
||||
ref="nameInputRef"
|
||||
v-model="form.displayName"
|
||||
maxlength="128"
|
||||
show-word-limit
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="分类">
|
||||
<div class="skill-settings-form__category-field">
|
||||
<ElSelect
|
||||
v-model="form.categoryId"
|
||||
clearable
|
||||
:disabled="Boolean(categoryLoadError)"
|
||||
:loading="categoriesLoading"
|
||||
:placeholder="categoryLoadError ? '分类加载失败' : '未分类'"
|
||||
>
|
||||
<ElOption
|
||||
v-if="form.categoryId && !currentCategoryAvailable"
|
||||
:label="form.categoryName || '当前分类不可用'"
|
||||
:value="form.categoryId"
|
||||
disabled
|
||||
/>
|
||||
<ElOption
|
||||
v-for="category in categoryOptions"
|
||||
:key="category.id"
|
||||
:label="`${'\u00a0\u00a0'.repeat(category.depth)}${category.categoryName}`"
|
||||
:value="category.id"
|
||||
:disabled="category.status !== 1"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
v-if="categoryLoadError"
|
||||
link
|
||||
type="primary"
|
||||
:loading="categoriesLoading"
|
||||
@click="emit('retryCategories')"
|
||||
>
|
||||
重新加载
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="可见范围">
|
||||
<ElSelect v-model="form.visibilityScope">
|
||||
<ElOption label="仅自己" value="PRIVATE" />
|
||||
<ElOption label="本部门" value="DEPT" />
|
||||
<ElOption label="全员可见" value="PUBLIC" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="启用状态">
|
||||
<div class="skill-settings-form__switch">
|
||||
<ElSwitch v-model="form.enabled" />
|
||||
<span>{{ form.enabled === false ? '停用' : '启用' }}</span>
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</EasyFlowFormModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-settings-form__alert {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.skill-settings-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-settings-form__category-field,
|
||||
.skill-settings-form__switch {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.skill-settings-form__category-field :deep(.el-select) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skill-settings-form__switch {
|
||||
color: hsl(var(--text-muted));
|
||||
}
|
||||
</style>
|
||||
@@ -1,49 +1,114 @@
|
||||
import type {
|
||||
RequestResult,
|
||||
SkillCapabilityBinding,
|
||||
SkillCapabilityCandidate,
|
||||
SkillCapabilityReplaceResult,
|
||||
SkillCapabilityTools,
|
||||
SkillCapabilityType,
|
||||
SkillCategory,
|
||||
SkillCategoryDraft,
|
||||
SkillExportFormat,
|
||||
SkillFileContent,
|
||||
SkillFileNode,
|
||||
SkillImportPreview,
|
||||
SkillInfo,
|
||||
SkillValidationResult,
|
||||
} from './types';
|
||||
|
||||
import {api} from '#/api/request';
|
||||
import { api } from '#/api/request';
|
||||
|
||||
import { buildCapabilityBindingsPayload } from './skill-capability';
|
||||
import { buildSkillCategoryPayload } from './skill-category';
|
||||
import { buildSkillDraftPayload } from './skill-draft';
|
||||
|
||||
export function getSkillDetail(id: number | string) {
|
||||
return api.get<RequestResult<SkillInfo>>('/api/v1/skill/getDetail', {
|
||||
return api.get<RequestResult<SkillInfo>>('/api/v1/skill/detail', {
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export function saveSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/save', skill);
|
||||
return api.post<RequestResult<SkillInfo>>(
|
||||
'/api/v1/skill/save',
|
||||
buildSkillDraftPayload(skill, false),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSkill(skill: SkillInfo) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/update', skill);
|
||||
return api.post<RequestResult<SkillInfo>>(
|
||||
'/api/v1/skill/update',
|
||||
buildSkillDraftPayload(skill, true),
|
||||
);
|
||||
}
|
||||
|
||||
export function copySkill(payload: {
|
||||
categoryId?: number | string;
|
||||
displayName: string;
|
||||
name: string;
|
||||
sourceId: number | string;
|
||||
}) {
|
||||
return api.post<RequestResult<SkillInfo>>('/api/v1/skill/copy', payload);
|
||||
}
|
||||
|
||||
export function validateSkillForPublish(id: number | string) {
|
||||
return api.post<RequestResult<SkillValidationResult>>(
|
||||
'/api/v1/skill/validatePublish',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCategories() {
|
||||
return api.get<RequestResult<any[]>>('/api/v1/skillCategory/visibleList', {
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
});
|
||||
return api.get<RequestResult<SkillCategory[]>>(
|
||||
'/api/v1/skill/category/visibleList',
|
||||
{
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCategoryTree() {
|
||||
return api.get<RequestResult<SkillCategory[]>>(
|
||||
'/api/v1/skill/category/tree',
|
||||
{
|
||||
params: { sortKey: 'sortNo', sortType: 'asc' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveSkillCategory(category: SkillCategoryDraft) {
|
||||
return api.post<RequestResult<SkillCategory>>(
|
||||
'/api/v1/skill/category/save',
|
||||
buildSkillCategoryPayload(category, false),
|
||||
);
|
||||
}
|
||||
|
||||
export function updateSkillCategory(category: SkillCategoryDraft) {
|
||||
return api.post<RequestResult<SkillCategory>>(
|
||||
'/api/v1/skill/category/update',
|
||||
buildSkillCategoryPayload(category, true),
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSkillCategory(id: number | string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/category/remove', { id });
|
||||
}
|
||||
|
||||
export function submitSkillPublishApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitPublishApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function submitSkillOfflineApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitOfflineApproval',
|
||||
{ id },
|
||||
);
|
||||
}
|
||||
|
||||
export function submitSkillDeleteApproval(id: number | string) {
|
||||
return api.post<RequestResult<number | string>>(
|
||||
return api.post<RequestResult<null | number | string>>(
|
||||
'/api/v1/skill/submitDeleteApproval',
|
||||
{ id },
|
||||
);
|
||||
@@ -56,23 +121,146 @@ export function getSkillFileTree(skillId: number | string) {
|
||||
}
|
||||
|
||||
export function getSkillFileContent(skillId: number | string, path: string) {
|
||||
return api.get<RequestResult<SkillFileContent>>('/api/v1/skill/file/content', {
|
||||
params: { skillId, path },
|
||||
});
|
||||
return api.get<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/content',
|
||||
{
|
||||
params: { path, skillId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function saveSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
content: string,
|
||||
expectedContentHash?: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>('/api/v1/skill/file/save', {
|
||||
skillId,
|
||||
path,
|
||||
content,
|
||||
expectedContentHash,
|
||||
path,
|
||||
skillId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
content = '',
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/create',
|
||||
{
|
||||
content,
|
||||
path,
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function renameSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
newPath: string,
|
||||
expectedContentHash: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/rename',
|
||||
{
|
||||
expectedContentHash,
|
||||
newPath,
|
||||
path,
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
expectedContentHash: string,
|
||||
) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/file/delete', {
|
||||
expectedContentHash,
|
||||
path,
|
||||
skillId,
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadSkillFile(
|
||||
skillId: number | string,
|
||||
path: string,
|
||||
file: File,
|
||||
expectedContentHash?: string,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('skillId', String(skillId));
|
||||
formData.append('path', path);
|
||||
if (expectedContentHash) {
|
||||
formData.append('expectedContentHash', expectedContentHash);
|
||||
}
|
||||
formData.append('file', file);
|
||||
return api.postFile<RequestResult<SkillFileContent>>(
|
||||
'/api/v1/skill/file/upload',
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadSkillFile(skillId: number | string, path: string) {
|
||||
return api.download<Blob>('/api/v1/skill/file/download', {
|
||||
params: { path, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export function previewSkillFile(skillId: number | string, path: string) {
|
||||
return api.download<Blob>('/api/v1/skill/file/preview', {
|
||||
params: { path, skillId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getSkillCapabilityBindings(skillId: number | string) {
|
||||
return api.get<RequestResult<SkillCapabilityBinding[]>>(
|
||||
'/api/v1/skill/capability/list',
|
||||
{
|
||||
params: { skillId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCapabilityCandidates(
|
||||
type: SkillCapabilityType,
|
||||
keyword = '',
|
||||
) {
|
||||
return api.get<RequestResult<SkillCapabilityCandidate[]>>(
|
||||
'/api/v1/skill/capability/candidates',
|
||||
{ params: { keyword, type } },
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillCapabilityTools(targetId: number | string) {
|
||||
return api.get<RequestResult<SkillCapabilityTools>>(
|
||||
'/api/v1/skill/capability/tools',
|
||||
{
|
||||
params: { targetId },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceSkillCapabilityBindings(
|
||||
skillId: number | string,
|
||||
bindings: SkillCapabilityBinding[],
|
||||
expectedCapabilityHash: string,
|
||||
) {
|
||||
return api.post<RequestResult<SkillCapabilityReplaceResult>>(
|
||||
'/api/v1/skill/capability/replace',
|
||||
{
|
||||
bindings: buildCapabilityBindingsPayload(bindings),
|
||||
expectedCapabilityHash,
|
||||
skillId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillPreview(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@@ -82,25 +270,35 @@ export function importSkillPreview(file: File) {
|
||||
);
|
||||
}
|
||||
|
||||
export function importSkillConfirm(
|
||||
file: File,
|
||||
categoryId?: number | string,
|
||||
overwriteDraft = false,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (categoryId) {
|
||||
formData.append('categoryId', String(categoryId));
|
||||
}
|
||||
formData.append('overwriteDraft', String(overwriteDraft));
|
||||
return api.postFile<RequestResult<SkillInfo[]>>(
|
||||
export function importSkillConfirm(payload: {
|
||||
capabilityMappings?: Array<{
|
||||
bindingKey: string;
|
||||
disabled?: boolean;
|
||||
targetId?: number | string;
|
||||
}>;
|
||||
categoryId?: number | string;
|
||||
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
importToken: string;
|
||||
renames?: Record<string, string>;
|
||||
}) {
|
||||
return api.post<RequestResult<SkillInfo[]>>(
|
||||
'/api/v1/skill/import/confirm',
|
||||
formData,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSkillAssetUrl(skillId: number | string, path: string) {
|
||||
return `/api/v1/skill/file/asset?skillId=${encodeURIComponent(
|
||||
String(skillId),
|
||||
)}&path=${encodeURIComponent(path)}`;
|
||||
export function cancelSkillImport(importToken: string) {
|
||||
return api.post<RequestResult<void>>('/api/v1/skill/import/cancel', {
|
||||
importToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function exportSkills(
|
||||
ids: Array<number | string>,
|
||||
format: SkillExportFormat,
|
||||
) {
|
||||
return api.download<Blob>('/api/v1/skill/export', {
|
||||
data: { format, ids },
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isSkillAccessDeniedError,
|
||||
isSkillCapabilityConflictError,
|
||||
isSkillFileConflictError,
|
||||
resolveSkillApiErrorMessage,
|
||||
} from './skill-api-error';
|
||||
|
||||
describe('skill API conflict errors', () => {
|
||||
it('recognizes response bodies thrown directly by the request client', () => {
|
||||
expect(
|
||||
isSkillFileConflictError({
|
||||
errorCode: 4091,
|
||||
message: '文件已被其他操作更新',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSkillCapabilityConflictError({
|
||||
errorCode: 4093,
|
||||
message: '能力配置已被其他操作更新',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('also recognizes Axios-shaped conflicts and ignores ordinary errors', () => {
|
||||
expect(
|
||||
isSkillFileConflictError({ response: { data: {}, status: 409 } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSkillCapabilityConflictError({
|
||||
response: { data: { errorCode: 4093 }, status: 400 },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isSkillFileConflictError({ errorCode: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes direct and Axios-shaped access denials', () => {
|
||||
expect(isSkillAccessDeniedError({ errorCode: 403 })).toBe(true);
|
||||
expect(
|
||||
isSkillAccessDeniedError({ response: { data: {}, status: 401 } }),
|
||||
).toBe(true);
|
||||
expect(isSkillAccessDeniedError({ errorCode: 500 })).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps backend business messages for recoverable category actions', () => {
|
||||
expect(
|
||||
resolveSkillApiErrorMessage(
|
||||
{ response: { data: { message: '请先迁移该分类下的 Skill' } } },
|
||||
'删除失败',
|
||||
),
|
||||
).toBe('请先迁移该分类下的 Skill');
|
||||
expect(resolveSkillApiErrorMessage(undefined, '删除失败')).toBe('删除失败');
|
||||
});
|
||||
});
|
||||
65
easyflow-ui-admin/app/src/views/ai/skill/skill-api-error.ts
Normal file
65
easyflow-ui-admin/app/src/views/ai/skill/skill-api-error.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
interface SkillApiErrorShape {
|
||||
code?: number | string;
|
||||
error?: string;
|
||||
errorCode?: number | string;
|
||||
httpStatus?: number | string;
|
||||
message?: string;
|
||||
response?: {
|
||||
data?: unknown;
|
||||
status?: number | string;
|
||||
};
|
||||
status?: number | string;
|
||||
}
|
||||
|
||||
function errorSnapshot(error: unknown) {
|
||||
const source = (error || {}) as SkillApiErrorShape;
|
||||
const body =
|
||||
source.response?.data && typeof source.response.data === 'object'
|
||||
? (source.response.data as SkillApiErrorShape)
|
||||
: source;
|
||||
return {
|
||||
code: body.code,
|
||||
errorCode: Number(body.errorCode),
|
||||
message: String(body.message || body.error || ''),
|
||||
status: Number(
|
||||
source.response?.status ?? body.httpStatus ?? body.status ?? 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回 Skill 请求中可直接展示的错误信息。 */
|
||||
export function resolveSkillApiErrorMessage(error: unknown, fallback: string) {
|
||||
return errorSnapshot(error).message || fallback;
|
||||
}
|
||||
|
||||
/** 判断文件请求是否因乐观锁冲突失败。 */
|
||||
export function isSkillFileConflictError(error: unknown) {
|
||||
const snapshot = errorSnapshot(error);
|
||||
return (
|
||||
snapshot.status === 409 ||
|
||||
snapshot.errorCode === 4091 ||
|
||||
snapshot.code === 'SKILL_FILE_CONFLICT' ||
|
||||
snapshot.message.includes('已被其他操作更新')
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断能力配置请求是否因版本冲突失败。 */
|
||||
export function isSkillCapabilityConflictError(error: unknown) {
|
||||
const snapshot = errorSnapshot(error);
|
||||
return (
|
||||
snapshot.status === 409 ||
|
||||
snapshot.errorCode === 4093 ||
|
||||
snapshot.message.includes('能力配置已被其他操作更新')
|
||||
);
|
||||
}
|
||||
|
||||
/** 判断 Skill 请求是否被服务端以未登录或无权限拒绝。 */
|
||||
export function isSkillAccessDeniedError(error: unknown) {
|
||||
const snapshot = errorSnapshot(error);
|
||||
return (
|
||||
snapshot.status === 401 ||
|
||||
snapshot.status === 403 ||
|
||||
snapshot.errorCode === 401 ||
|
||||
snapshot.errorCode === 403
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCapabilityBindingPayload,
|
||||
resolveCapabilityIssueIndex,
|
||||
sanitizeCapabilityOptions,
|
||||
sanitizeHitlConfig,
|
||||
shouldLoadMcpTools,
|
||||
} from './skill-capability';
|
||||
|
||||
describe('skill capability payload', () => {
|
||||
it('only sends writable binding fields', () => {
|
||||
expect(
|
||||
buildCapabilityBindingPayload({
|
||||
capabilityType: 'MCP',
|
||||
enabled: true,
|
||||
executionMode: 'ASYNC',
|
||||
hitlConfigJson: {
|
||||
prompt: ' Continue? ',
|
||||
token: 'must-not-leak',
|
||||
},
|
||||
id: 9,
|
||||
resolvedToolNames: ['server-only'],
|
||||
runtimeName: 'search',
|
||||
selectedToolNamesJson: ['query'],
|
||||
selectionMode: 'SELECTED',
|
||||
sortNo: 0,
|
||||
targetId: 12,
|
||||
targetLogicalRef: 'mcp:search',
|
||||
targetName: 'Search MCP',
|
||||
targetStatus: 'AVAILABLE',
|
||||
optionsJson: {
|
||||
headers: { Authorization: 'secret' },
|
||||
readOnly: true,
|
||||
retryCount: 2,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
capabilityType: 'MCP',
|
||||
enabled: true,
|
||||
executionMode: undefined,
|
||||
hitlConfigJson: { prompt: 'Continue?' },
|
||||
hitlEnabled: undefined,
|
||||
optionsJson: { readOnly: true, retryCount: 2 },
|
||||
runtimeName: 'search',
|
||||
selectedToolNamesJson: ['query'],
|
||||
selectionMode: 'SELECTED',
|
||||
sortNo: 0,
|
||||
targetId: 12,
|
||||
targetLogicalRef: 'mcp:search',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes non-MCP execution mode and drops MCP-only fields', () => {
|
||||
expect(
|
||||
buildCapabilityBindingPayload({
|
||||
capabilityType: 'WORKFLOW',
|
||||
enabled: true,
|
||||
runtimeName: 'review',
|
||||
selectedToolNamesJson: ['ignored'],
|
||||
selectionMode: 'SELECTED',
|
||||
targetId: 1,
|
||||
}),
|
||||
).toMatchObject({
|
||||
executionMode: 'SYNC',
|
||||
selectedToolNamesJson: undefined,
|
||||
selectionMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('strictly whitelists scalar HITL and execution options', () => {
|
||||
expect(
|
||||
sanitizeHitlConfig({
|
||||
cancelLabel: 'Cancel',
|
||||
nested: { prompt: 'unsafe' },
|
||||
prompt: 'Approve this call?',
|
||||
secret: 'hidden',
|
||||
}),
|
||||
).toEqual({ cancelLabel: 'Cancel', prompt: 'Approve this call?' });
|
||||
expect(
|
||||
sanitizeCapabilityOptions({
|
||||
async: false,
|
||||
credential: 'hidden',
|
||||
readOnly: true,
|
||||
retryCount: 1.9,
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
).toEqual({
|
||||
async: false,
|
||||
readOnly: true,
|
||||
retryCount: 1,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
it('locates a binding from backend validation paths', () => {
|
||||
expect(resolveCapabilityIssueIndex('capabilities[2].runtimeName')).toBe(2);
|
||||
expect(
|
||||
resolveCapabilityIssueIndex('skills[demo].capabilities[1].targetId'),
|
||||
).toBe(1);
|
||||
expect(resolveCapabilityIssueIndex('SKILL.md')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('loads MCP tools only for a selected scope with a concrete target', () => {
|
||||
expect(shouldLoadMcpTools('MCP', 'ALL', 12)).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED')).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED', '')).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED', ' ')).toBe(false);
|
||||
expect(shouldLoadMcpTools('WORKFLOW', 'SELECTED', 12)).toBe(false);
|
||||
expect(shouldLoadMcpTools('PLUGIN_ITEM', 'SELECTED', 12)).toBe(false);
|
||||
expect(shouldLoadMcpTools('MCP', 'SELECTED', 12)).toBe(true);
|
||||
});
|
||||
});
|
||||
113
easyflow-ui-admin/app/src/views/ai/skill/skill-capability.ts
Normal file
113
easyflow-ui-admin/app/src/views/ai/skill/skill-capability.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { SkillCapabilityBinding } from './types';
|
||||
|
||||
export interface SkillCapabilityBindingPayload {
|
||||
capabilityType: SkillCapabilityBinding['capabilityType'];
|
||||
enabled: boolean;
|
||||
executionMode?: SkillCapabilityBinding['executionMode'];
|
||||
hitlConfigJson?: Record<string, unknown>;
|
||||
hitlEnabled?: boolean;
|
||||
optionsJson?: Record<string, unknown>;
|
||||
runtimeName?: string;
|
||||
selectedToolNamesJson?: string[];
|
||||
selectionMode?: SkillCapabilityBinding['selectionMode'];
|
||||
sortNo?: number;
|
||||
targetId?: number | string;
|
||||
targetLogicalRef?: string;
|
||||
}
|
||||
|
||||
/** Build the narrow capability DTO accepted by replace and validation APIs. */
|
||||
export function buildCapabilityBindingPayload(
|
||||
binding: SkillCapabilityBinding,
|
||||
): SkillCapabilityBindingPayload {
|
||||
const isMcp = binding.capabilityType === 'MCP';
|
||||
return {
|
||||
capabilityType: binding.capabilityType,
|
||||
enabled: binding.enabled,
|
||||
executionMode: isMcp
|
||||
? undefined
|
||||
: normalizeExecutionMode(binding.executionMode),
|
||||
hitlConfigJson: sanitizeHitlConfig(binding.hitlConfigJson),
|
||||
hitlEnabled: binding.hitlEnabled,
|
||||
optionsJson: sanitizeCapabilityOptions(binding.optionsJson),
|
||||
runtimeName: binding.runtimeName,
|
||||
selectedToolNamesJson: isMcp ? binding.selectedToolNamesJson : undefined,
|
||||
selectionMode: isMcp ? binding.selectionMode : undefined,
|
||||
sortNo: binding.sortNo,
|
||||
targetId: binding.targetId,
|
||||
targetLogicalRef: binding.targetLogicalRef,
|
||||
};
|
||||
}
|
||||
|
||||
/** Keeps only the non-sensitive HITL strings supported by the backend contract. */
|
||||
export function sanitizeHitlConfig(source?: Record<string, unknown>) {
|
||||
return compactRecord({
|
||||
cancelLabel: readString(source?.cancelLabel, 32),
|
||||
confirmLabel: readString(source?.confirmLabel, 32),
|
||||
description: readString(source?.description, 500),
|
||||
prompt: readString(source?.prompt, 1000),
|
||||
title: readString(source?.title, 128),
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps only scalar, non-sensitive execution options supported by the contract. */
|
||||
export function sanitizeCapabilityOptions(source?: Record<string, unknown>) {
|
||||
return compactRecord({
|
||||
async: readBoolean(source?.async),
|
||||
readOnly: readBoolean(source?.readOnly),
|
||||
retryCount: readInteger(source?.retryCount),
|
||||
timeoutMs: readInteger(source?.timeoutMs),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeExecutionMode(value?: 'ASYNC' | 'SYNC') {
|
||||
return value === 'ASYNC' ? 'ASYNC' : 'SYNC';
|
||||
}
|
||||
|
||||
function readString(value: unknown, maxLength: number) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().slice(0, maxLength);
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown) {
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
function readInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function compactRecord(source: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(source).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCapabilityBindingsPayload(
|
||||
bindings?: SkillCapabilityBinding[],
|
||||
) {
|
||||
return bindings?.map(buildCapabilityBindingPayload);
|
||||
}
|
||||
|
||||
/** MCP tools are fetched only when a concrete target uses an explicit allowlist. */
|
||||
export function shouldLoadMcpTools(
|
||||
capabilityType: SkillCapabilityBinding['capabilityType'],
|
||||
selectionMode: SkillCapabilityBinding['selectionMode'],
|
||||
targetId?: number | string,
|
||||
): targetId is number | string {
|
||||
return (
|
||||
capabilityType === 'MCP' &&
|
||||
selectionMode === 'SELECTED' &&
|
||||
targetId !== undefined &&
|
||||
targetId !== null &&
|
||||
String(targetId).trim().length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** 从结构化校验路径中解析能力绑定序号。 */
|
||||
export function resolveCapabilityIssueIndex(path?: string) {
|
||||
const match = path?.match(/(?:^|\.)capabilities\[(\d+)\](?:\.|$)/);
|
||||
return match?.[1] === undefined ? undefined : Number(match[1]);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SkillCategory } from './types';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildSkillCategoryPayload,
|
||||
canCreateSkillSubcategory,
|
||||
flattenSkillCategories,
|
||||
getSkillCategoryParentOptions,
|
||||
} from './skill-category';
|
||||
|
||||
const categories: SkillCategory[] = [
|
||||
{
|
||||
categoryName: '研发',
|
||||
id: 1,
|
||||
levelNo: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '平台',
|
||||
id: 2,
|
||||
levelNo: 2,
|
||||
parentId: 1,
|
||||
children: [
|
||||
{
|
||||
categoryName: '运行时',
|
||||
id: 3,
|
||||
levelNo: 3,
|
||||
parentId: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ categoryName: '运营', id: 4, levelNo: 1 },
|
||||
];
|
||||
|
||||
describe('skill category helpers', () => {
|
||||
it('flattens categories with stable display depth', () => {
|
||||
expect(
|
||||
flattenSkillCategories(categories).map(({ depth, id }) => ({
|
||||
depth,
|
||||
id,
|
||||
})),
|
||||
).toEqual([
|
||||
{ depth: 0, id: 1 },
|
||||
{ depth: 1, id: 2 },
|
||||
{ depth: 2, id: 3 },
|
||||
{ depth: 0, id: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('only sends writable category fields', () => {
|
||||
expect(
|
||||
buildSkillCategoryPayload(
|
||||
{
|
||||
categoryName: ' 产品 ',
|
||||
id: 9,
|
||||
parentId: '',
|
||||
sortNo: 3.8,
|
||||
status: 0,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
categoryName: '产品',
|
||||
id: 9,
|
||||
parentId: undefined,
|
||||
sortNo: 3,
|
||||
status: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents cycles and moves that would exceed three levels', () => {
|
||||
expect(
|
||||
getSkillCategoryParentOptions(categories, 1).map(({ id }) => id),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
getSkillCategoryParentOptions(categories, 2).map(({ id }) => id),
|
||||
).toEqual([1, 4]);
|
||||
expect(
|
||||
getSkillCategoryParentOptions(categories).map(({ id }) => id),
|
||||
).toEqual([1, 2, 4]);
|
||||
});
|
||||
|
||||
it('allows adding children only below level three', () => {
|
||||
const flattened = flattenSkillCategories(categories);
|
||||
expect(flattened[1] && canCreateSkillSubcategory(flattened[1])).toBe(true);
|
||||
expect(flattened[2] && canCreateSkillSubcategory(flattened[2])).toBe(false);
|
||||
});
|
||||
});
|
||||
98
easyflow-ui-admin/app/src/views/ai/skill/skill-category.ts
Normal file
98
easyflow-ui-admin/app/src/views/ai/skill/skill-category.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { SkillCategory, SkillCategoryDraft } from './types';
|
||||
|
||||
export interface FlatSkillCategory extends SkillCategory {
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a category tree while retaining its display depth.
|
||||
*/
|
||||
export function flattenSkillCategories(
|
||||
categories: SkillCategory[],
|
||||
depth = 0,
|
||||
): FlatSkillCategory[] {
|
||||
return categories.flatMap((category) => [
|
||||
{ ...category, depth },
|
||||
...flattenSkillCategories(category.children || [], depth + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the writable category DTO without leaking server-managed fields.
|
||||
*/
|
||||
export function buildSkillCategoryPayload(
|
||||
category: SkillCategoryDraft,
|
||||
includeId: boolean,
|
||||
): SkillCategoryDraft {
|
||||
const parentId =
|
||||
category.parentId === '' || category.parentId === null
|
||||
? undefined
|
||||
: category.parentId;
|
||||
const payload: SkillCategoryDraft = {
|
||||
categoryName: category.categoryName.trim(),
|
||||
parentId,
|
||||
sortNo:
|
||||
typeof category.sortNo === 'number' && Number.isFinite(category.sortNo)
|
||||
? Math.trunc(category.sortNo)
|
||||
: 0,
|
||||
status: category.status === 0 ? 0 : 1,
|
||||
};
|
||||
if (includeId) payload.id = category.id;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a category can accept another level within the three-level limit.
|
||||
*/
|
||||
export function canCreateSkillSubcategory(category: FlatSkillCategory) {
|
||||
return (category.levelNo ?? category.depth + 1) < 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns legal parent choices, excluding cycles and moves that exceed three levels.
|
||||
*/
|
||||
export function getSkillCategoryParentOptions(
|
||||
categories: SkillCategory[],
|
||||
editingId?: number | string,
|
||||
): FlatSkillCategory[] {
|
||||
const editing = editingId
|
||||
? findSkillCategory(categories, editingId)
|
||||
: undefined;
|
||||
const excludedIds = editing ? collectCategoryIds(editing) : new Set<string>();
|
||||
const subtreeHeight = editing ? getSubtreeHeight(editing) : 1;
|
||||
|
||||
return flattenSkillCategories(categories).filter((category) => {
|
||||
if (excludedIds.has(String(category.id))) return false;
|
||||
const parentLevel = category.levelNo ?? category.depth + 1;
|
||||
return parentLevel + subtreeHeight <= 3;
|
||||
});
|
||||
}
|
||||
|
||||
function findSkillCategory(
|
||||
categories: SkillCategory[],
|
||||
id: number | string,
|
||||
): SkillCategory | undefined {
|
||||
for (const category of categories) {
|
||||
if (String(category.id) === String(id)) return category;
|
||||
const child = findSkillCategory(category.children || [], id);
|
||||
if (child) return child;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function collectCategoryIds(
|
||||
category: SkillCategory,
|
||||
result = new Set<string>(),
|
||||
) {
|
||||
result.add(String(category.id));
|
||||
for (const child of category.children || [])
|
||||
collectCategoryIds(child, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSubtreeHeight(category: SkillCategory): number {
|
||||
if (!category.children?.length) return 1;
|
||||
return (
|
||||
1 + Math.max(...category.children.map((child) => getSubtreeHeight(child)))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildInitialSkillDraft,
|
||||
createSkillCanonicalName,
|
||||
} from './skill-create';
|
||||
import { readFrontmatterScalar, splitSkillMarkdown } from './skill-markdown';
|
||||
|
||||
describe('skill creation draft', () => {
|
||||
it('creates a stable canonical name without exposing it to the form', () => {
|
||||
expect(createSkillCanonicalName('Research Assistant', 'abc123')).toBe(
|
||||
'research-assistant-abc123',
|
||||
);
|
||||
expect(createSkillCanonicalName('研究助手', 'abc123')).toBe('skill-abc123');
|
||||
});
|
||||
|
||||
it('builds a valid initial SKILL.md package', () => {
|
||||
const skill = buildInitialSkillDraft({
|
||||
categoryId: 8,
|
||||
displayName: '研究助手',
|
||||
visibilityScope: 'DEPT',
|
||||
});
|
||||
const parts = splitSkillMarkdown(String(skill.skillContent));
|
||||
|
||||
expect(skill).toMatchObject({
|
||||
categoryId: 8,
|
||||
description: '研究助手',
|
||||
displayName: '研究助手',
|
||||
enabled: true,
|
||||
publishStatus: 'DRAFT',
|
||||
visibilityScope: 'DEPT',
|
||||
});
|
||||
expect(skill.name).toMatch(/^skill-[a-z0-9]{10}$/);
|
||||
expect(readFrontmatterScalar(parts.frontmatter, 'name')).toBe(skill.name);
|
||||
expect(readFrontmatterScalar(parts.frontmatter, 'description')).toBe(
|
||||
'研究助手',
|
||||
);
|
||||
expect(parts.body).toContain('# Instructions');
|
||||
});
|
||||
});
|
||||
52
easyflow-ui-admin/app/src/views/ai/skill/skill-create.ts
Normal file
52
easyflow-ui-admin/app/src/views/ai/skill/skill-create.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { SkillInfo } from './types';
|
||||
|
||||
import { syncSkillMarkdownFrontmatter } from './skill-markdown';
|
||||
|
||||
export type SkillCreateIntent = 'DRAFT' | 'PUBLISH';
|
||||
|
||||
/** Build a hidden, standards-compliant canonical name for a new Skill. */
|
||||
export function createSkillCanonicalName(
|
||||
displayName: string,
|
||||
suffix = createRandomSuffix(),
|
||||
): string {
|
||||
const slug = displayName
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, '-')
|
||||
.replaceAll(/^-+|-+$/g, '')
|
||||
.slice(0, 48)
|
||||
.replaceAll(/-+$/g, '');
|
||||
const prefix = slug || 'skill';
|
||||
return `${prefix}-${suffix}`.slice(0, 64).replaceAll(/-+$/g, '');
|
||||
}
|
||||
|
||||
/** Create the minimal valid package accepted by the Skill draft endpoint. */
|
||||
export function buildInitialSkillDraft(input: {
|
||||
categoryId?: number | string;
|
||||
displayName: string;
|
||||
visibilityScope: string;
|
||||
}): SkillInfo {
|
||||
const displayName = input.displayName.trim();
|
||||
const name = createSkillCanonicalName(displayName);
|
||||
const content = syncSkillMarkdownFrontmatter(
|
||||
'# Instructions\n\n',
|
||||
name,
|
||||
displayName,
|
||||
);
|
||||
return {
|
||||
categoryId: input.categoryId || undefined,
|
||||
description: displayName,
|
||||
displayName,
|
||||
enabled: true,
|
||||
name,
|
||||
publishStatus: 'DRAFT',
|
||||
skillContent: content,
|
||||
visibilityScope: input.visibilityScope,
|
||||
};
|
||||
}
|
||||
|
||||
function createRandomSuffix() {
|
||||
const uuid = globalThis.crypto?.randomUUID?.().replaceAll('-', '');
|
||||
if (uuid) return uuid.slice(0, 10);
|
||||
return Math.random().toString(36).slice(2, 12).padEnd(10, '0');
|
||||
}
|
||||
96
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.test.ts
Normal file
96
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSkillDraftPayload, validateSkillBasic } from './skill-draft';
|
||||
|
||||
describe('skill draft payload', () => {
|
||||
it('only sends editable draft fields and omits the id for create', () => {
|
||||
const payload = buildSkillDraftPayload(
|
||||
{
|
||||
approvalPending: true,
|
||||
categoryId: 3,
|
||||
description: 'description',
|
||||
displayName: 'Display name',
|
||||
enabled: true,
|
||||
id: 9,
|
||||
name: 'standard-name',
|
||||
packageHash: 'must-not-leak',
|
||||
resourceCount: 7,
|
||||
resources: [{ contentRef: 'must-not-leak', path: 'secret.md' }],
|
||||
skillContent: 'content',
|
||||
visibilityScope: 'PRIVATE',
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
categoryId: 3,
|
||||
displayName: 'Display name',
|
||||
enabled: true,
|
||||
skillContent: 'content',
|
||||
visibilityScope: 'PRIVATE',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the id and excludes stale content for update', () => {
|
||||
expect(
|
||||
buildSkillDraftPayload({ id: 9, skillContent: 'stale content' }, true),
|
||||
).toEqual({
|
||||
categoryId: undefined,
|
||||
displayName: undefined,
|
||||
enabled: undefined,
|
||||
id: 9,
|
||||
visibilityScope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill basic validation', () => {
|
||||
it('requires canonical standard metadata when creating a Skill', () => {
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{ description: '', displayName: '', name: 'Invalid_Name' },
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
description: '请输入标准描述',
|
||||
displayName: '请输入显示名称',
|
||||
name: '仅支持小写字母、数字和连字符',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not revalidate frontmatter-only fields for an existing Skill', () => {
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{ description: '', displayName: 'Display', name: '' },
|
||||
false,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it('uses the same basic-field limits as the backend Skill contract', () => {
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{
|
||||
description: 'd'.repeat(1025),
|
||||
displayName: 'x'.repeat(129),
|
||||
name: 'a'.repeat(65),
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
description: '标准描述不能超过 1024 个字符',
|
||||
displayName: '显示名称不能超过 128 个字符',
|
||||
name: '标准名称不能超过 64 个字符',
|
||||
});
|
||||
expect(
|
||||
validateSkillBasic(
|
||||
{
|
||||
description: 'd'.repeat(1024),
|
||||
displayName: 'x'.repeat(128),
|
||||
name: 'a'.repeat(64),
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
65
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.ts
Normal file
65
easyflow-ui-admin/app/src/views/ai/skill/skill-draft.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SkillInfo } from './types';
|
||||
|
||||
export interface SkillDraftPayload {
|
||||
categoryId?: number | string;
|
||||
displayName?: string;
|
||||
enabled?: boolean;
|
||||
id?: number | string;
|
||||
skillContent?: string;
|
||||
visibilityScope?: string;
|
||||
}
|
||||
|
||||
export interface SkillBasicValidationErrors {
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const CANONICAL_SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/** Build the narrow DTO accepted by the Skill draft endpoints. */
|
||||
export function buildSkillDraftPayload(
|
||||
skill: SkillInfo,
|
||||
includeId: boolean,
|
||||
): SkillDraftPayload {
|
||||
const payload: SkillDraftPayload = {
|
||||
categoryId: skill.categoryId,
|
||||
displayName: skill.displayName,
|
||||
enabled: skill.enabled,
|
||||
visibilityScope: skill.visibilityScope,
|
||||
};
|
||||
if (includeId) payload.id = skill.id;
|
||||
else payload.skillContent = skill.skillContent;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Validate fields that are editable in the basic-information section. */
|
||||
export function validateSkillBasic(
|
||||
skill: SkillInfo,
|
||||
isNew: boolean,
|
||||
): SkillBasicValidationErrors {
|
||||
const errors: SkillBasicValidationErrors = {};
|
||||
const displayName = String(skill.displayName || '').trim();
|
||||
if (!displayName) {
|
||||
errors.displayName = '请输入显示名称';
|
||||
} else if (displayName.length > 128) {
|
||||
errors.displayName = '显示名称不能超过 128 个字符';
|
||||
}
|
||||
if (!isNew) return errors;
|
||||
|
||||
const name = String(skill.name || '').trim();
|
||||
if (!name) {
|
||||
errors.name = '请输入标准名称';
|
||||
} else if (name.length > 64) {
|
||||
errors.name = '标准名称不能超过 64 个字符';
|
||||
} else if (!CANONICAL_SKILL_NAME.test(name)) {
|
||||
errors.name = '仅支持小写字母、数字和连字符';
|
||||
}
|
||||
const description = String(skill.description || '').trim();
|
||||
if (!description) {
|
||||
errors.description = '请输入标准描述';
|
||||
} else if (description.length > 1024) {
|
||||
errors.description = '标准描述不能超过 1024 个字符';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSkillTextDownload } from './skill-file-download';
|
||||
|
||||
describe('skill file download', () => {
|
||||
it('downloads the current text buffer including unsaved edits', async () => {
|
||||
const blob = buildSkillTextDownload(
|
||||
{
|
||||
content: 'saved',
|
||||
isText: true,
|
||||
path: 'scripts/run.py',
|
||||
type: 'SCRIPT',
|
||||
},
|
||||
'unsaved edit',
|
||||
);
|
||||
|
||||
expect(await blob?.text()).toBe('unsaved edit');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ASSET', 'assets/archive.bin'],
|
||||
['EXAMPLE', 'examples/archive.bin'],
|
||||
['OTHER', 'resources/archive.bin'],
|
||||
['REFERENCE', 'references/manual.pdf'],
|
||||
['SCRIPT', 'scripts/tool.bin'],
|
||||
])(
|
||||
'keeps binary %s resources on the authenticated download path',
|
||||
(type, path) => {
|
||||
expect(
|
||||
buildSkillTextDownload({ isText: false, path, type }, ''),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SkillFileContent } from './types';
|
||||
|
||||
/** Build a download from the active text buffer so unsaved edits are included. */
|
||||
export function buildSkillTextDownload(
|
||||
file: SkillFileContent,
|
||||
content: string,
|
||||
): Blob | undefined {
|
||||
if (file.isText === false) return undefined;
|
||||
const textFile =
|
||||
file.isText === true ||
|
||||
['REFERENCE', 'SCRIPT', 'SKILL'].includes(file.type);
|
||||
if (!textFile) return undefined;
|
||||
return new Blob([content], {
|
||||
type: file.mediaType || 'text/plain;charset=utf-8',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isSkillPathText } from './skill-file-representation';
|
||||
|
||||
describe('skill file representation', () => {
|
||||
it('keeps scripts textual and assets binary regardless of extension', () => {
|
||||
expect(isSkillPathText('scripts/run.bin')).toBe(true);
|
||||
expect(isSkillPathText('assets/readme.txt')).toBe(false);
|
||||
});
|
||||
|
||||
it('classifies other resources by their known text extension', () => {
|
||||
expect(isSkillPathText('references/data.json')).toBe(true);
|
||||
expect(isSkillPathText('references/page.htm')).toBe(true);
|
||||
expect(isSkillPathText('examples/archive.bin')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
const TEXT_EXTENSIONS = [
|
||||
'.bash',
|
||||
'.c',
|
||||
'.cjs',
|
||||
'.cpp',
|
||||
'.css',
|
||||
'.csv',
|
||||
'.go',
|
||||
'.gradle',
|
||||
'.groovy',
|
||||
'.h',
|
||||
'.hpp',
|
||||
'.htm',
|
||||
'.html',
|
||||
'.ini',
|
||||
'.java',
|
||||
'.js',
|
||||
'.json',
|
||||
'.jsx',
|
||||
'.kt',
|
||||
'.kts',
|
||||
'.markdown',
|
||||
'.md',
|
||||
'.mjs',
|
||||
'.properties',
|
||||
'.py',
|
||||
'.rb',
|
||||
'.rs',
|
||||
'.sh',
|
||||
'.sql',
|
||||
'.toml',
|
||||
'.ts',
|
||||
'.tsv',
|
||||
'.tsx',
|
||||
'.txt',
|
||||
'.xml',
|
||||
'.yaml',
|
||||
'.yml',
|
||||
'.zsh',
|
||||
] as const;
|
||||
|
||||
/** 按 M18 规范判断目标路径的持久化表示。 */
|
||||
export function isSkillPathText(path: string) {
|
||||
const normalized = path.toLowerCase();
|
||||
const topDirectory = normalized.split('/', 1)[0];
|
||||
if (topDirectory === 'scripts') return true;
|
||||
if (topDirectory === 'assets') return false;
|
||||
return TEXT_EXTENSIONS.some((extension) => normalized.endsWith(extension));
|
||||
}
|
||||
116
easyflow-ui-admin/app/src/views/ai/skill/skill-import.test.ts
Normal file
116
easyflow-ui-admin/app/src/views/ai/skill/skill-import.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildSkillImportConfirmPayload,
|
||||
countBlockingImportIssues,
|
||||
invalidateConsumedImportPreview,
|
||||
resolveSkillImportConflictReasonLabel,
|
||||
resolveSkillImportStep,
|
||||
} from './skill-import';
|
||||
|
||||
describe('skill import confirmation', () => {
|
||||
it('confirms with the preview token and never requires the uploaded file again', () => {
|
||||
const payload = buildSkillImportConfirmPayload(
|
||||
{
|
||||
format: 'EASYFLOW',
|
||||
importToken: 'one-time-token',
|
||||
skills: [{ conflict: false, name: 'research', packageId: 'pkg-1' }],
|
||||
},
|
||||
{
|
||||
categoryId: 12,
|
||||
conflictStrategy: 'RENAME',
|
||||
renames: { 'pkg-1': 'research-copy' },
|
||||
},
|
||||
[
|
||||
{
|
||||
bindingKey: 'binding-1',
|
||||
capabilityType: 'WORKFLOW',
|
||||
disabled: false,
|
||||
status: 'RESOLVED',
|
||||
targetId: 42,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(payload.importToken).toBe('one-time-token');
|
||||
expect(payload).not.toHaveProperty('file');
|
||||
expect(payload.capabilityMappings).toEqual([
|
||||
{ bindingKey: 'binding-1', disabled: false, targetId: 42 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks package and item-level validation errors', () => {
|
||||
expect(
|
||||
countBlockingImportIssues({
|
||||
format: 'STANDARD',
|
||||
importToken: 'token',
|
||||
issues: [{ message: 'unsafe zip', severity: 'ERROR' }],
|
||||
skills: [
|
||||
{
|
||||
conflict: false,
|
||||
name: 'research',
|
||||
packageId: 'pkg-1',
|
||||
validationIssues: [{ message: 'missing name', severity: 'ERROR' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it('removes an auto-resolved target when the mapping is explicitly disabled', () => {
|
||||
const payload = buildSkillImportConfirmPayload(
|
||||
{
|
||||
format: 'EASYFLOW',
|
||||
importToken: 'one-time-token',
|
||||
skills: [{ conflict: false, name: 'research', packageId: 'pkg-1' }],
|
||||
},
|
||||
{ conflictStrategy: 'REJECT' },
|
||||
[
|
||||
{
|
||||
bindingKey: 'binding-1',
|
||||
capabilityType: 'WORKFLOW',
|
||||
disabled: true,
|
||||
status: 'RESOLVED',
|
||||
targetId: 42,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(payload.capabilityMappings).toEqual([
|
||||
{ bindingKey: 'binding-1', disabled: true, targetId: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('invalidates a consumed preview token after any confirm failure', () => {
|
||||
const preview = {
|
||||
format: 'STANDARD' as const,
|
||||
importToken: 'one-time-token',
|
||||
skills: [{ conflict: false, name: 'research', packageId: 'pkg-1' }],
|
||||
};
|
||||
|
||||
expect(invalidateConsumedImportPreview(preview)).toEqual({
|
||||
...preview,
|
||||
importToken: '',
|
||||
});
|
||||
expect(preview.importToken).toBe('one-time-token');
|
||||
});
|
||||
|
||||
it('redacts inaccessible and unknown conflict reasons as name unavailable', () => {
|
||||
expect(resolveSkillImportConflictReasonLabel('NAME_UNAVAILABLE')).toBe(
|
||||
'名称不可用',
|
||||
);
|
||||
expect(resolveSkillImportConflictReasonLabel('NO_PERMISSION')).toBe(
|
||||
'名称不可用',
|
||||
);
|
||||
expect(resolveSkillImportConflictReasonLabel('NOT_DRAFT')).toBe(
|
||||
'当前状态不可覆盖',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the empty capability step for a standard package', () => {
|
||||
expect(resolveSkillImportStep(0, 'next', false)).toBe(2);
|
||||
expect(resolveSkillImportStep(2, 'visible', false)).toBe(1);
|
||||
expect(resolveSkillImportStep(2, 'previous', false)).toBe(0);
|
||||
expect(resolveSkillImportStep(0, 'next', true)).toBe(1);
|
||||
});
|
||||
});
|
||||
59
easyflow-ui-admin/app/src/views/ai/skill/skill-import.ts
Normal file
59
easyflow-ui-admin/app/src/views/ai/skill/skill-import.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { SkillCapabilityMapping, SkillImportPreview } from './types';
|
||||
|
||||
export interface SkillImportConfirmState {
|
||||
categoryId?: number | string;
|
||||
conflictStrategy: 'OVERWRITE' | 'REJECT' | 'RENAME';
|
||||
renames?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function buildSkillImportConfirmPayload(
|
||||
preview: SkillImportPreview,
|
||||
state: SkillImportConfirmState,
|
||||
mappings: SkillCapabilityMapping[],
|
||||
) {
|
||||
return {
|
||||
capabilityMappings: mappings.map((item) => ({
|
||||
bindingKey: item.bindingKey,
|
||||
disabled: item.disabled,
|
||||
targetId: item.disabled ? undefined : item.targetId,
|
||||
})),
|
||||
categoryId: state.categoryId || undefined,
|
||||
conflictStrategy: state.conflictStrategy,
|
||||
importToken: preview.importToken,
|
||||
renames: state.conflictStrategy === 'RENAME' ? state.renames : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function countBlockingImportIssues(preview?: SkillImportPreview) {
|
||||
if (!preview) return 0;
|
||||
return [
|
||||
...(preview.issues || []),
|
||||
...preview.skills.flatMap((item) => item.validationIssues || []),
|
||||
].filter((issue) => issue.severity === 'ERROR').length;
|
||||
}
|
||||
|
||||
export function invalidateConsumedImportPreview(
|
||||
preview: SkillImportPreview,
|
||||
): SkillImportPreview {
|
||||
return { ...preview, importToken: '' };
|
||||
}
|
||||
|
||||
export function resolveSkillImportConflictReasonLabel(reason?: string) {
|
||||
return reason === 'NOT_DRAFT' ? '当前状态不可覆盖' : '名称不可用';
|
||||
}
|
||||
|
||||
export function resolveSkillImportStep(
|
||||
current: number,
|
||||
direction: 'next' | 'previous' | 'visible',
|
||||
hasCapabilityMappings: boolean,
|
||||
) {
|
||||
if (direction === 'visible') {
|
||||
return hasCapabilityMappings || current < 2 ? current : 1;
|
||||
}
|
||||
if (direction === 'next') {
|
||||
return current === 0 && !hasCapabilityMappings
|
||||
? 2
|
||||
: Math.min(current + 1, 2);
|
||||
}
|
||||
return current === 2 && !hasCapabilityMappings ? 0 : Math.max(current - 1, 0);
|
||||
}
|
||||
162
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.test.ts
Normal file
162
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
joinSkillMarkdown,
|
||||
readFrontmatterScalar,
|
||||
resolveSkillRelativePath,
|
||||
splitSkillMarkdown,
|
||||
syncSkillMarkdownFrontmatter,
|
||||
updateFrontmatterScalar,
|
||||
} from './skill-markdown';
|
||||
|
||||
describe('skill markdown round-trip', () => {
|
||||
it('preserves nested and unknown frontmatter while editing the body', () => {
|
||||
const source = [
|
||||
'---',
|
||||
'name: research-skill',
|
||||
'description: Research helper',
|
||||
'metadata:',
|
||||
' owner: team-a',
|
||||
' flags:',
|
||||
' - safe',
|
||||
'x-extra: keep-me',
|
||||
'---',
|
||||
'',
|
||||
'# Instructions',
|
||||
'',
|
||||
'- Read `references/policy.md`.',
|
||||
].join('\n');
|
||||
const parts = splitSkillMarkdown(source);
|
||||
parts.body = `${parts.body}\n- Return a concise answer.`;
|
||||
const next = joinSkillMarkdown(parts);
|
||||
|
||||
expect(next).toContain('metadata:\n owner: team-a\n flags:\n - safe');
|
||||
expect(next).toContain('x-extra: keep-me');
|
||||
expect(next).toContain('Return a concise answer.');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'---\nname: exact\n---',
|
||||
'---\nname: exact\n---\n',
|
||||
'---\nname: exact\n---\n\n# Title\n',
|
||||
'---\r\nname: exact\r\n---\r\n\r\n# Title',
|
||||
])('round-trips delimiters and whitespace exactly', (source) => {
|
||||
expect(joinSkillMarkdown(splitSkillMarkdown(source))).toBe(source);
|
||||
});
|
||||
|
||||
it('does not treat a non-delimiter line as closing frontmatter', () => {
|
||||
const source = '---\nname: exact\n---foo\n# title';
|
||||
expect(splitSkillMarkdown(source).hasFrontmatter).toBe(false);
|
||||
});
|
||||
|
||||
it('only changes the requested top-level scalar', () => {
|
||||
const frontmatter =
|
||||
'name: old-name\ncustom:\n name: nested-name\nx-extra: true';
|
||||
const updated = updateFrontmatterScalar(frontmatter, 'name', 'new-name');
|
||||
expect(readFrontmatterScalar(updated, 'name')).toBe('new-name');
|
||||
expect(updated).toContain(' name: nested-name');
|
||||
expect(updated).toContain('x-extra: true');
|
||||
});
|
||||
|
||||
it('syncs new Skill metadata without losing the body or unknown frontmatter', () => {
|
||||
const source =
|
||||
'---\nname: old-name\ndescription: old description\nx-owner: team-a\n---\n\n# Instructions\n\nKeep this body.';
|
||||
const updated = syncSkillMarkdownFrontmatter(
|
||||
source,
|
||||
'new-name',
|
||||
'New description',
|
||||
);
|
||||
|
||||
expect(updated).toContain('name: "new-name"');
|
||||
expect(updated).toContain('description: "New description"');
|
||||
expect(updated).toContain('x-owner: team-a');
|
||||
expect(updated).toContain('# Instructions\n\nKeep this body.');
|
||||
});
|
||||
|
||||
it('adds required frontmatter to a Markdown document that has none', () => {
|
||||
const updated = syncSkillMarkdownFrontmatter(
|
||||
'# Instructions\n\nKeep this body.',
|
||||
'new-skill',
|
||||
'New description',
|
||||
);
|
||||
|
||||
expect(updated).toContain('name: "new-skill"');
|
||||
expect(updated).toContain('description: "New description"');
|
||||
expect(updated).toContain('# Instructions\n\nKeep this body.');
|
||||
expect(splitSkillMarkdown(updated).hasFrontmatter).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses structured edits for multiline YAML scalars', () => {
|
||||
const frontmatter =
|
||||
'name: exact\ndescription: |\n line one\n line two\nx-extra: true';
|
||||
expect(() =>
|
||||
updateFrontmatterScalar(frontmatter, 'description', 'next'),
|
||||
).toThrow('源码模式');
|
||||
expect(frontmatter).toContain('line two');
|
||||
});
|
||||
|
||||
it('reads block scalars and preserves them when metadata is unchanged', () => {
|
||||
const source =
|
||||
'---\nname: exact\ndescription: |\n line one\n line two\nx-extra: true\n---\n\n# Instructions\n';
|
||||
const parts = splitSkillMarkdown(source);
|
||||
|
||||
expect(readFrontmatterScalar(parts.frontmatter, 'description')).toBe(
|
||||
'line one\nline two\n',
|
||||
);
|
||||
expect(
|
||||
syncSkillMarkdownFrontmatter(source, 'exact', 'line one\nline two'),
|
||||
).toBe(source);
|
||||
});
|
||||
|
||||
it('returns an empty scalar for malformed or non-string frontmatter', () => {
|
||||
expect(readFrontmatterScalar('name: [broken', 'name')).toBe('');
|
||||
expect(readFrontmatterScalar('name:\n nested: value', 'name')).toBe('');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'description: |2\n text',
|
||||
'description: >-\n text',
|
||||
'description: |2- # comment\n text',
|
||||
'description: first line\n continuation',
|
||||
'description: plain # keep this comment',
|
||||
])('routes unsafe scalar edits to source mode', (frontmatter) => {
|
||||
expect(() =>
|
||||
updateFrontmatterScalar(frontmatter, 'description', 'next'),
|
||||
).toThrow('源码模式');
|
||||
});
|
||||
|
||||
it('resolves links from the current markdown file directory', () => {
|
||||
expect(
|
||||
resolveSkillRelativePath(
|
||||
'references/guides/start.md',
|
||||
'../policy.md#scope',
|
||||
),
|
||||
).toBe('references/policy.md');
|
||||
expect(resolveSkillRelativePath('SKILL.md', 'examples/sample.md')).toBe(
|
||||
'examples/sample.md',
|
||||
);
|
||||
expect(
|
||||
resolveSkillRelativePath('SKILL.md', '../outside.md'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'file:///etc/passwd',
|
||||
'blob:https://example.com/foreign',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'//example.com/path',
|
||||
'references/a.md\u0000.png',
|
||||
])(
|
||||
'rejects URI schemes and control characters from relative links',
|
||||
(href) => {
|
||||
expect(resolveSkillRelativePath('SKILL.md', href)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves raw HTML as source text for the text-only Milkdown HTML node', () => {
|
||||
const source =
|
||||
'---\nname: safe\ndescription: safe\n---\n\n<img src=x onerror="window.pwned=true">';
|
||||
expect(joinSkillMarkdown(splitSkillMarkdown(source))).toBe(source);
|
||||
});
|
||||
});
|
||||
165
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.ts
Normal file
165
easyflow-ui-admin/app/src/views/ai/skill/skill-markdown.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { parseDocument } from 'yaml';
|
||||
|
||||
export interface SkillMarkdownParts {
|
||||
body: string;
|
||||
bodyPrefix: string;
|
||||
frontmatter: string;
|
||||
hasFrontmatter: boolean;
|
||||
lineEnding: '\n' | '\r\n';
|
||||
}
|
||||
|
||||
// The closing marker is accepted only when `---` occupies the complete line.
|
||||
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
|
||||
|
||||
export function splitSkillMarkdown(source: string): SkillMarkdownParts {
|
||||
const lineEnding = source.includes('\r\n') ? '\r\n' : '\n';
|
||||
const match = FRONTMATTER_PATTERN.exec(source);
|
||||
if (!match) {
|
||||
return {
|
||||
body: source,
|
||||
bodyPrefix: '',
|
||||
frontmatter: '',
|
||||
hasFrontmatter: false,
|
||||
lineEnding,
|
||||
};
|
||||
}
|
||||
return {
|
||||
body: source.slice(match[0].length),
|
||||
bodyPrefix: match[2] || '',
|
||||
frontmatter: match[1] || '',
|
||||
hasFrontmatter: true,
|
||||
lineEnding,
|
||||
};
|
||||
}
|
||||
|
||||
export function joinSkillMarkdown(parts: SkillMarkdownParts): string {
|
||||
if (!parts.hasFrontmatter) return parts.body;
|
||||
return `---${parts.lineEnding}${parts.frontmatter}${parts.lineEnding}---${parts.bodyPrefix}${parts.body}`;
|
||||
}
|
||||
|
||||
export function updateFrontmatterScalar(
|
||||
frontmatter: string,
|
||||
key: 'description' | 'name',
|
||||
value: string,
|
||||
lineEnding: '\n' | '\r\n' = '\n',
|
||||
): string {
|
||||
const serialized = JSON.stringify(value.trim());
|
||||
const fieldPattern = new RegExp(`^${key}\\s*:[^\\r\\n]*$`, 'm');
|
||||
const match = fieldPattern.exec(frontmatter);
|
||||
const current = match?.[0];
|
||||
if (current) {
|
||||
const raw = current.slice(current.indexOf(':') + 1).trim();
|
||||
const following = frontmatter.slice((match?.index || 0) + current.length);
|
||||
const hasIndentedContinuation = /^\r?\n[ \t]+\S/.test(following);
|
||||
const unsafeScalar =
|
||||
!raw ||
|
||||
/^[>|]/.test(raw) ||
|
||||
/\s+#/.test(raw) ||
|
||||
/^[{[&*!]/.test(raw) ||
|
||||
/^(?:null|true|false|[-+]?\d+(?:\.\d+)?)$/i.test(raw) ||
|
||||
hasIndentedContinuation;
|
||||
if (unsafeScalar) {
|
||||
throw new Error(`${key} 使用复杂 YAML,请在源码模式中编辑`);
|
||||
}
|
||||
return frontmatter.replace(fieldPattern, `${key}: ${serialized}`);
|
||||
}
|
||||
const suffix =
|
||||
frontmatter && !frontmatter.endsWith(lineEnding) ? lineEnding : '';
|
||||
return `${frontmatter}${suffix}${key}: ${serialized}`;
|
||||
}
|
||||
|
||||
export function syncSkillMarkdownFrontmatter(
|
||||
source: string,
|
||||
name: string,
|
||||
description: string,
|
||||
): string {
|
||||
const parts = splitSkillMarkdown(source);
|
||||
if (!parts.hasFrontmatter) {
|
||||
parts.hasFrontmatter = true;
|
||||
parts.bodyPrefix = `${parts.lineEnding}${parts.lineEnding}`;
|
||||
}
|
||||
if (readFrontmatterScalar(parts.frontmatter, 'name').trim() !== name.trim()) {
|
||||
parts.frontmatter = updateFrontmatterScalar(
|
||||
parts.frontmatter,
|
||||
'name',
|
||||
name,
|
||||
parts.lineEnding,
|
||||
);
|
||||
}
|
||||
if (
|
||||
readFrontmatterScalar(parts.frontmatter, 'description').trim() !==
|
||||
description.trim()
|
||||
) {
|
||||
parts.frontmatter = updateFrontmatterScalar(
|
||||
parts.frontmatter,
|
||||
'description',
|
||||
description,
|
||||
parts.lineEnding,
|
||||
);
|
||||
}
|
||||
return joinSkillMarkdown(parts);
|
||||
}
|
||||
|
||||
export function readFrontmatterScalar(
|
||||
frontmatter: string,
|
||||
key: 'description' | 'name',
|
||||
): string {
|
||||
if (!frontmatter.trim()) return '';
|
||||
try {
|
||||
const document = parseDocument(frontmatter, {
|
||||
prettyErrors: false,
|
||||
schema: 'failsafe',
|
||||
strict: true,
|
||||
uniqueKeys: true,
|
||||
});
|
||||
if (document.errors.length > 0) return '';
|
||||
const value = document.get(key);
|
||||
return typeof value === 'string' ? value : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSkillRelativeLink(href: string): string {
|
||||
if (hasUnsafeUriForm(href)) return '';
|
||||
return href.split('#', 1)[0]?.replace(/^\.\//, '') || '';
|
||||
}
|
||||
|
||||
export function resolveSkillRelativePath(
|
||||
currentPath: string,
|
||||
href: string,
|
||||
): string | undefined {
|
||||
const cleanHref = href.split(/[?#]/, 1)[0] || '';
|
||||
if (
|
||||
!cleanHref ||
|
||||
hasUnsafeUriForm(href) ||
|
||||
cleanHref.startsWith('/') ||
|
||||
cleanHref.includes('\\')
|
||||
)
|
||||
return undefined;
|
||||
const segments = currentPath.split('/');
|
||||
segments.pop();
|
||||
for (const segment of cleanHref.split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
if (segments.length === 0) return undefined;
|
||||
segments.pop();
|
||||
} else {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function hasUnsafeUriForm(value: string) {
|
||||
const href = value.trim();
|
||||
return (
|
||||
!href ||
|
||||
/^[a-z][a-z\d+.-]*:/i.test(href) ||
|
||||
href.startsWith('//') ||
|
||||
[...href].some((character) => {
|
||||
const code = character.codePointAt(0) || 0;
|
||||
return code < 32 || code === 127;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,99 +1,213 @@
|
||||
export interface RequestResult<T = any> {
|
||||
export interface RequestResult<T = unknown> {
|
||||
data: T;
|
||||
errorCode: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type SkillCapabilityType = 'MCP' | 'PLUGIN_ITEM' | 'WORKFLOW';
|
||||
export type SkillExportFormat = 'EASYFLOW' | 'STANDARD';
|
||||
export type SkillIssueSeverity = 'ERROR' | 'INFO' | 'WARNING';
|
||||
|
||||
export interface SkillInfo {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
categoryId?: number | string;
|
||||
metadataJson?: Record<string, any>;
|
||||
skillContent?: string;
|
||||
enabled?: boolean;
|
||||
visibilityScope?: string;
|
||||
sourceType?: string;
|
||||
referenceCount?: number;
|
||||
scriptCount?: number;
|
||||
assetCount?: number;
|
||||
publishStatus?: string;
|
||||
displayPublishStatus?: string;
|
||||
approvalPending?: boolean;
|
||||
currentApprovalActionType?: string;
|
||||
currentApprovalInstanceId?: number | string;
|
||||
assetCount?: number;
|
||||
bindings?: SkillCapabilityBinding[];
|
||||
capabilityCount?: number;
|
||||
capabilityHash?: string;
|
||||
categoryId?: number | string;
|
||||
categoryName?: string;
|
||||
created?: string;
|
||||
createdByName?: string;
|
||||
references?: SkillReference[];
|
||||
scripts?: SkillScript[];
|
||||
assets?: SkillAsset[];
|
||||
[key: string]: any;
|
||||
currentApprovalActionType?: string;
|
||||
currentApprovalInstanceId?: number | string;
|
||||
description?: string;
|
||||
displayName?: string;
|
||||
displayPublishStatus?: string;
|
||||
enabled?: boolean;
|
||||
id?: number | string;
|
||||
manageable?: boolean;
|
||||
metadataJson?: Record<string, unknown>;
|
||||
modified?: string;
|
||||
name?: string;
|
||||
packageHash?: string;
|
||||
publishStatus?: string;
|
||||
referenceCount?: number;
|
||||
resourceCount?: number;
|
||||
resources?: SkillResource[];
|
||||
scriptCount?: number;
|
||||
skillContent?: string;
|
||||
sourceType?: string;
|
||||
visibilityScope?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SkillReference {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
name?: string;
|
||||
export interface SkillResource {
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillScript {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
language?: string;
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillAsset {
|
||||
id?: number | string;
|
||||
path: string;
|
||||
name?: string;
|
||||
mediaType?: string;
|
||||
contentRef?: string;
|
||||
contentHash?: string;
|
||||
id?: number | string;
|
||||
isText?: boolean;
|
||||
kind?: string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
metadataJson?: Record<string, unknown>;
|
||||
path: string;
|
||||
size?: number;
|
||||
metadataJson?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
children?: SkillFileNode[];
|
||||
contentHash?: string;
|
||||
isText?: boolean;
|
||||
key: string;
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'ASSET' | 'DIRECTORY' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
name: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
children?: SkillFileNode[];
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SkillFileContent {
|
||||
path: string;
|
||||
type: 'ASSET' | 'REFERENCE' | 'SCRIPT' | 'SKILL' | string;
|
||||
content?: string;
|
||||
contentHash?: string;
|
||||
isText?: boolean;
|
||||
language?: string;
|
||||
mediaType?: string;
|
||||
path: string;
|
||||
size?: number;
|
||||
downloadUrl?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface SkillValidationIssue {
|
||||
code?: string;
|
||||
column?: number;
|
||||
line?: number;
|
||||
message: string;
|
||||
path?: string;
|
||||
severity: SkillIssueSeverity;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface SkillValidationResult {
|
||||
issues: SkillValidationIssue[];
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityBinding {
|
||||
bindingKey?: string;
|
||||
capabilityType: SkillCapabilityType;
|
||||
enabled: boolean;
|
||||
executionMode?: 'ASYNC' | 'SYNC';
|
||||
hitlConfigJson?: Record<string, unknown>;
|
||||
hitlEnabled?: boolean;
|
||||
id?: number | string;
|
||||
optionsJson?: Record<string, unknown>;
|
||||
runtimeName?: string;
|
||||
resolvedToolNames?: string[];
|
||||
selectedToolNamesJson?: string[];
|
||||
selectionMode?: 'ALL' | 'SELECTED';
|
||||
sortNo?: number;
|
||||
targetLogicalRef?: string;
|
||||
targetStatus?: 'AVAILABLE' | 'NO_PERMISSION' | 'UNAVAILABLE' | 'UNRESOLVED';
|
||||
targetId?: number | string;
|
||||
targetName?: string;
|
||||
targetSummary?: string;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityCandidate {
|
||||
capabilityType: SkillCapabilityType;
|
||||
description?: string;
|
||||
logicalRef?: string;
|
||||
name: string;
|
||||
revision?: string;
|
||||
status?: string;
|
||||
targetId: number | string;
|
||||
toolNames?: string[];
|
||||
}
|
||||
|
||||
export interface SkillCapabilityTools {
|
||||
status: string;
|
||||
targetId: number | string;
|
||||
toolNames: string[];
|
||||
}
|
||||
|
||||
export interface SkillCapabilityReplaceResult {
|
||||
bindings: SkillCapabilityBinding[];
|
||||
capabilityHash: string;
|
||||
}
|
||||
|
||||
export interface SkillImportPreviewItem {
|
||||
packageId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
referenceCount: number;
|
||||
scriptCount: number;
|
||||
assetCount: number;
|
||||
assetCount?: number;
|
||||
conflict: boolean;
|
||||
conflictReason?: 'NAME_UNAVAILABLE' | 'NOT_DRAFT';
|
||||
description?: string;
|
||||
files?: SkillImportPreviewFile[];
|
||||
name: string;
|
||||
packageHash?: string;
|
||||
packageId: string;
|
||||
packageRoot?: string;
|
||||
overwriteAllowed?: boolean;
|
||||
referenceCount?: number;
|
||||
resourceCount?: number;
|
||||
scriptCount?: number;
|
||||
suggestedName?: string;
|
||||
validationIssues?: SkillValidationIssue[];
|
||||
}
|
||||
|
||||
export interface SkillImportPreviewFile {
|
||||
kind: 'ASSET' | 'EXAMPLE' | 'OTHER' | 'REFERENCE' | 'SCRIPT' | 'SKILL';
|
||||
mediaType?: string;
|
||||
path: string;
|
||||
size: number;
|
||||
text: boolean;
|
||||
}
|
||||
|
||||
export interface SkillCapabilityMapping {
|
||||
bindingKey: string;
|
||||
capabilityType: SkillCapabilityType;
|
||||
disabled?: boolean;
|
||||
packageRoot?: string;
|
||||
runtimeName?: string;
|
||||
status: 'RESOLVED' | 'UNRESOLVED';
|
||||
targetId?: number | string;
|
||||
targetLogicalRef?: string;
|
||||
targetName?: string;
|
||||
}
|
||||
|
||||
export interface SkillImportPreview {
|
||||
capabilityMappings?: SkillCapabilityMapping[];
|
||||
expiresAt?: string;
|
||||
format: SkillExportFormat;
|
||||
importToken: string;
|
||||
issues?: SkillValidationIssue[];
|
||||
skills: SkillImportPreviewItem[];
|
||||
}
|
||||
|
||||
export interface SkillFileBuffer {
|
||||
content: string;
|
||||
conflict?: boolean;
|
||||
file: SkillFileContent;
|
||||
original: string;
|
||||
pendingContent?: string;
|
||||
revision: number;
|
||||
saveState: 'dirty' | 'error' | 'saved' | 'saving';
|
||||
}
|
||||
|
||||
export interface SkillCategory {
|
||||
categoryName: string;
|
||||
children?: SkillCategory[];
|
||||
id: number | string;
|
||||
levelNo?: number;
|
||||
parentId?: number | string;
|
||||
sortNo?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface SkillCategoryDraft {
|
||||
categoryName: string;
|
||||
id?: number | string;
|
||||
parentId?: '' | null | number | string;
|
||||
sortNo?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { useSkillFileBuffers } from './use-skill-file-buffers';
|
||||
|
||||
describe('skill file buffers', () => {
|
||||
it('keeps independent unsaved content when switching files', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({ content: '# Skill', path: 'SKILL.md', type: 'SKILL' });
|
||||
store.ensureBuffer({
|
||||
content: '# Policy',
|
||||
path: 'references/policy.md',
|
||||
type: 'REFERENCE',
|
||||
});
|
||||
|
||||
store.updateContent('SKILL.md', '# Updated Skill');
|
||||
store.updateContent('references/policy.md', '# Updated Policy');
|
||||
|
||||
expect(store.buffers.get('SKILL.md')?.content).toBe('# Updated Skill');
|
||||
expect(store.buffers.get('references/policy.md')?.content).toBe(
|
||||
'# Updated Policy',
|
||||
);
|
||||
expect([...store.dirtyPaths.value]).toEqual([
|
||||
'SKILL.md',
|
||||
'references/policy.md',
|
||||
]);
|
||||
});
|
||||
|
||||
it('only clears dirty state after the matching file is saved', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({
|
||||
content: 'one',
|
||||
path: 'scripts/run.py',
|
||||
type: 'SCRIPT',
|
||||
});
|
||||
store.updateContent('scripts/run.py', 'two');
|
||||
expect(store.dirtyPaths.value.size).toBe(1);
|
||||
|
||||
store.markSaved('scripts/run.py');
|
||||
expect(store.dirtyPaths.value.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps edits made while a save request is in flight dirty', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({
|
||||
content: 'one',
|
||||
path: 'scripts/run.py',
|
||||
type: 'SCRIPT',
|
||||
});
|
||||
store.updateContent('scripts/run.py', 'two');
|
||||
const sentContent = store.markSaving('scripts/run.py');
|
||||
store.updateContent('scripts/run.py', 'three');
|
||||
|
||||
store.markSaved(
|
||||
'scripts/run.py',
|
||||
{ content: 'two', path: 'scripts/run.py', type: 'SCRIPT' },
|
||||
sentContent,
|
||||
);
|
||||
|
||||
expect(store.buffers.get('scripts/run.py')?.original).toBe('two');
|
||||
expect(store.buffers.get('scripts/run.py')?.content).toBe('three');
|
||||
expect(store.buffers.get('scripts/run.py')?.saveState).toBe('dirty');
|
||||
});
|
||||
|
||||
it('preserves dirty content and marks a conflict when the server refresh changes', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({
|
||||
content: 'one',
|
||||
contentHash: 'old-hash',
|
||||
path: 'SKILL.md',
|
||||
type: 'SKILL',
|
||||
});
|
||||
store.updateContent('SKILL.md', 'local');
|
||||
store.ensureBuffer({
|
||||
content: 'remote',
|
||||
contentHash: 'remote-hash',
|
||||
path: 'SKILL.md',
|
||||
type: 'SKILL',
|
||||
});
|
||||
|
||||
expect(store.buffers.get('SKILL.md')?.content).toBe('local');
|
||||
expect(store.buffers.get('SKILL.md')?.conflict).toBe(true);
|
||||
expect(store.buffers.get('SKILL.md')?.file.contentHash).toBe('old-hash');
|
||||
});
|
||||
|
||||
it('rejects renaming over an existing buffer', () => {
|
||||
const store = useSkillFileBuffers();
|
||||
store.ensureBuffer({ content: 'one', path: 'a.md', type: 'REFERENCE' });
|
||||
store.ensureBuffer({ content: 'two', path: 'b.md', type: 'REFERENCE' });
|
||||
expect(() => store.renameBuffer('a.md', 'b.md')).toThrow('目标路径已存在');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { SkillFileBuffer, SkillFileContent } from './types';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
export function useSkillFileBuffers() {
|
||||
const buffers = reactive(new Map<string, SkillFileBuffer>());
|
||||
|
||||
const dirtyPaths = computed(
|
||||
() =>
|
||||
new Set(
|
||||
[...buffers.values()]
|
||||
.filter((buffer) => buffer.content !== buffer.original)
|
||||
.map((buffer) => buffer.file.path),
|
||||
),
|
||||
);
|
||||
|
||||
function ensureBuffer(
|
||||
file: SkillFileContent,
|
||||
refreshStrategy: 'preserve-dirty' | 'replace' = 'preserve-dirty',
|
||||
) {
|
||||
const existing = buffers.get(file.path);
|
||||
if (existing) {
|
||||
const serverContent = file.content || '';
|
||||
const dirty = existing.content !== existing.original;
|
||||
if (refreshStrategy === 'replace' || !dirty) {
|
||||
Object.assign(existing, {
|
||||
conflict: false,
|
||||
content: serverContent,
|
||||
file,
|
||||
original: serverContent,
|
||||
pendingContent: undefined,
|
||||
saveState: 'saved',
|
||||
});
|
||||
} else {
|
||||
existing.conflict = serverContent !== existing.original;
|
||||
if (!existing.conflict) existing.file = file;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
const buffer: SkillFileBuffer = {
|
||||
content: file.content || '',
|
||||
file,
|
||||
original: file.content || '',
|
||||
revision: 0,
|
||||
saveState: 'saved',
|
||||
};
|
||||
buffers.set(file.path, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function updateContent(path: string, content: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer || buffer.content === content) return;
|
||||
buffer.content = content;
|
||||
buffer.revision += 1;
|
||||
buffer.saveState = content === buffer.original ? 'saved' : 'dirty';
|
||||
}
|
||||
|
||||
function markSaving(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return undefined;
|
||||
buffer.pendingContent = buffer.content;
|
||||
buffer.saveState = 'saving';
|
||||
return buffer.pendingContent;
|
||||
}
|
||||
|
||||
function markSaved(
|
||||
path: string,
|
||||
file?: SkillFileContent,
|
||||
sentContent?: string,
|
||||
) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return;
|
||||
const sent = sentContent ?? buffer.pendingContent ?? buffer.content;
|
||||
const serverContent = file?.content ?? sent;
|
||||
if (file) buffer.file = file;
|
||||
buffer.original = serverContent;
|
||||
buffer.pendingContent = undefined;
|
||||
buffer.conflict = false;
|
||||
if (buffer.content === sent) {
|
||||
buffer.content = serverContent;
|
||||
buffer.saveState = 'saved';
|
||||
} else {
|
||||
buffer.saveState = 'dirty';
|
||||
}
|
||||
}
|
||||
|
||||
function markError(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (buffer) buffer.saveState = 'error';
|
||||
}
|
||||
|
||||
function markConflict(path: string) {
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return;
|
||||
buffer.conflict = true;
|
||||
buffer.pendingContent = undefined;
|
||||
buffer.saveState = 'error';
|
||||
}
|
||||
|
||||
function removeBuffer(path: string) {
|
||||
buffers.delete(path);
|
||||
}
|
||||
|
||||
function renameBuffer(path: string, nextPath: string) {
|
||||
if (path !== nextPath && buffers.has(nextPath)) {
|
||||
throw new Error(`目标路径已存在:${nextPath}`);
|
||||
}
|
||||
const buffer = buffers.get(path);
|
||||
if (!buffer) return;
|
||||
buffers.delete(path);
|
||||
buffer.file = { ...buffer.file, path: nextPath };
|
||||
buffers.set(nextPath, buffer);
|
||||
}
|
||||
|
||||
function clearBuffers() {
|
||||
buffers.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
buffers,
|
||||
clearBuffers,
|
||||
dirtyPaths,
|
||||
ensureBuffer,
|
||||
markConflict,
|
||||
markError,
|
||||
markSaved,
|
||||
markSaving,
|
||||
removeBuffer,
|
||||
renameBuffer,
|
||||
updateContent,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import BotApprovalSnapshotPreview from '#/views/system/approval/components/BotAp
|
||||
import KnowledgeApprovalSnapshotPreview from '#/views/system/approval/components/KnowledgeApprovalSnapshotPreview.vue';
|
||||
import WorkflowApprovalSnapshotPreview from '#/views/system/approval/components/WorkflowApprovalSnapshotPreview.vue';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
@@ -171,23 +173,6 @@ function formatPayload(payload: Record<string, any>) {
|
||||
return JSON.stringify(payload || {}, null, 2);
|
||||
}
|
||||
|
||||
function formatAccountDisplay(
|
||||
name?: string,
|
||||
account?: null | string,
|
||||
fallbackId?: null | number | string,
|
||||
) {
|
||||
if (name && account) {
|
||||
return `${name}(${account})`;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (account) {
|
||||
return account;
|
||||
}
|
||||
return fallbackId || '-';
|
||||
}
|
||||
|
||||
function formatOperatorId(
|
||||
account?: null | string,
|
||||
fallbackId?: null | number | string,
|
||||
@@ -334,7 +319,7 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem :label="$t('approval.fields.applicant')">
|
||||
{{
|
||||
formatAccountDisplay(
|
||||
formatApprovalAccount(
|
||||
detail.applicantName,
|
||||
detail.applicantAccount,
|
||||
detail.applicantId,
|
||||
@@ -390,7 +375,7 @@ function formatEventInfo(row: Record<string, any>) {
|
||||
</ElTableColumn>
|
||||
<ElTableColumn :label="$t('approval.fields.actedBy')" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatAccountDisplay(row.actedByName, row.actedBy) }}
|
||||
{{ formatApprovalAccount(row.actedByName, row.actedBy) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
|
||||
@@ -29,6 +29,7 @@ import PageData from '#/components/page/PageData.vue';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
import ApprovalFlowModal from './ApprovalFlowModal.vue';
|
||||
|
||||
type ApprovalTabName = 'flow' | 'initiated' | 'pending' | 'processed';
|
||||
@@ -785,6 +786,20 @@ function formatApplicationReason(value?: null | string) {
|
||||
{{ formatApplicationReason(row.applicationReason) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.applicant')"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
formatApprovalAccount(
|
||||
row.applicantName,
|
||||
row.applicantAccount,
|
||||
row.applicantId,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.resourceType')"
|
||||
width="120"
|
||||
@@ -969,6 +984,20 @@ function formatApplicationReason(value?: null | string) {
|
||||
{{ formatApplicationReason(row.applicationReason) }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.applicant')"
|
||||
min-width="180"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{
|
||||
formatApprovalAccount(
|
||||
row.applicantName,
|
||||
row.applicantAccount,
|
||||
row.applicantId,
|
||||
)
|
||||
}}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn
|
||||
:label="$t('approval.fields.resourceType')"
|
||||
width="120"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatApprovalAccount } from './approval-format';
|
||||
|
||||
describe('formatApprovalAccount', () => {
|
||||
it('formats the applicant consistently with approval detail', () => {
|
||||
expect(formatApprovalAccount('陈子默', 'czm', '7')).toBe('陈子默(czm)');
|
||||
});
|
||||
|
||||
it('falls back when applicant account information is unavailable', () => {
|
||||
expect(formatApprovalAccount(null, null, '7')).toBe('7');
|
||||
expect(formatApprovalAccount()).toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 格式化审批账号信息,与审批详情保持“姓名(账号)”展示规则。
|
||||
*
|
||||
* @param name 展示名称
|
||||
* @param account 登录账号
|
||||
* @param fallbackId 无账号信息时使用的 ID
|
||||
* @returns 可直接展示的账号文本
|
||||
*/
|
||||
export function formatApprovalAccount(
|
||||
name?: null | string,
|
||||
account?: null | string,
|
||||
fallbackId?: null | number | string,
|
||||
) {
|
||||
if (name && account) {
|
||||
return `${name}(${account})`;
|
||||
}
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (account) {
|
||||
return account;
|
||||
}
|
||||
return fallbackId || '-';
|
||||
}
|
||||
Reference in New Issue
Block a user