发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
78 changed files with 16413 additions and 799 deletions
Showing only changes of commit 0dc5c3ca55 - Show all commits

View File

@@ -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",

View File

@@ -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)"
>

View 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('数据加载失败,请重试');
});
});

View File

@@ -3,7 +3,7 @@ import { onMounted, reactive, ref, watch } from 'vue';
import { preferences } from '@easyflow/preferences';
import { ElEmpty, ElPagination } from 'element-plus';
import { ElButton, ElEmpty, ElPagination } from 'element-plus';
import { api } from '#/api/request';
import { getEmptyStateImageUrl } from '#/utils/assets';
@@ -28,7 +28,9 @@ const props = withDefaults(defineProps<PageDataProps>(), {
// 响应式数据
const pageList = ref<PageDataRow[]>([]);
const loading = ref(false);
const loadError = ref<unknown>();
const queryParams = ref<Record<string, any>>({});
let pageRequest = 0;
const pageInfo = reactive({
pageNumber: 1,
@@ -38,8 +40,6 @@ const pageInfo = reactive({
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
const doGet = async (params: Record<string, any>) => {
loading.value = true;
try {
// 这里替换为你的实际 API 调用
// 例如return await api.get(props.pageUrl, { params })
const response = await props.requestClient.get(`${props.pageUrl}`, {
@@ -47,13 +47,13 @@ const doGet = async (params: Record<string, any>) => {
});
const data = await response.data;
return { data };
} finally {
loading.value = false;
}
};
// 获取页面数据
const getPageList = async () => {
const request = ++pageRequest;
loading.value = true;
loadError.value = undefined;
try {
const res = await doGet({
pageNumber: pageInfo.pageNumber,
@@ -61,13 +61,19 @@ const getPageList = async () => {
...props.extraQueryParams,
...queryParams.value,
});
if (request === pageRequest) {
pageList.value = res.data?.records || [];
pageInfo.total = res.data?.totalRow || 0;
}
} catch (error) {
console.error('get data error:', error);
if (request === pageRequest) {
loadError.value = error;
pageList.value = [];
pageInfo.total = 0;
}
} finally {
if (request === pageRequest) loading.value = false;
}
};
// 分页事件处理
@@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record<string, any>) => {
pageInfo.pageNumber = 1;
pageInfo.pageSize = props.pageSize;
queryParams.value = newQueryParams;
getPageList();
};
// 暴露方法给父组件
@@ -116,7 +121,7 @@ defineExpose({
// 监听器
watch(
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
[() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value],
() => {
getPageList();
},
@@ -150,6 +155,16 @@ onMounted(() => {
/>
</div>
</template>
<slot
v-else-if="loadError"
name="error"
:error="loadError"
:retry="getPageList"
>
<ElEmpty description="数据加载失败,请重试">
<ElButton type="primary" @click="getPageList">重新加载</ElButton>
</ElEmpty>
</slot>
<slot v-else name="empty">
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
</slot>

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

View File

@@ -1,10 +1,9 @@
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
import type { Component } from 'vue';
import { ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { preferences } from '@easyflow/preferences';
import { cn } from '@easyflow/utils';
import { MoreFilled } from '@element-plus/icons-vue';
import {
@@ -14,119 +13,256 @@ import {
ElDropdownMenu,
ElEmpty,
ElIcon,
ElTree,
} from 'element-plus';
import { getEmptyStateImageUrl } from '#/utils/assets';
interface Props {
title?: string;
menus: T[];
labelKey: string;
valueKey: string;
iconSize?: number;
controlBtns?: {
interface ControlButton<Item> {
disabled?: ((item: Item) => boolean) | boolean;
icon?: any;
label: string;
onClick: (_: T) => void;
onClick: (item: Item) => void;
type?: any;
}[];
visible?: (item: Item) => boolean;
}
interface Props {
childrenKey?: string;
controlBtns?: ControlButton<T>[];
defaultExpandAll?: boolean;
defaultSelected?: number | string;
footerButton?: {
disabled?: boolean;
icon?: any;
label: string;
loading?: boolean;
onClick: () => void;
};
defaultSelected?: string;
iconSize?: number;
labelKey: string;
menus: T[];
title?: string;
valueKey: string;
}
const props = withDefaults(defineProps<Props>(), {
title: '',
iconSize: 16,
childrenKey: '',
controlBtns: () => [],
footerButton: undefined,
defaultExpandAll: true,
defaultSelected: '',
footerButton: undefined,
iconSize: 16,
title: '',
});
const emits = defineEmits<{
(e: 'change', item: T): void;
}>();
const panelWidth = ref(225);
const selected = ref<string>(props.defaultSelected ?? '');
const hoverId = ref<string>();
const handleChange = (item: T) => {
selected.value = item[props.valueKey];
emits('change', item);
};
// 监听 defaultSelected 的变化
watch(
() => props.defaultSelected,
(newVal) => {
if (newVal) {
selected.value = newVal;
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
if (item) {
emits('change', item);
const panelWidth = 225;
const selected = ref<number | string>(props.defaultSelected ?? '');
const treeRef = ref<InstanceType<typeof ElTree>>();
const lastEmittedDefault = ref('');
const treeProps = computed(() => ({
children: props.childrenKey,
label: props.labelKey,
}));
function menuValue(item: T) {
return item[props.valueKey] as number | string;
}
function normalizedValue(value: unknown) {
return String(value ?? '');
}
function findMenu(items: T[], value: number | string): T | undefined {
for (const item of items) {
if (normalizedValue(menuValue(item)) === normalizedValue(value)) {
return item;
}
if (props.childrenKey) {
const children = item[props.childrenKey] as T[] | undefined;
const matched = children?.length ? findMenu(children, value) : undefined;
if (matched) return matched;
}
}
return undefined;
}
function syncTreeSelection() {
if (!props.childrenKey) return;
void nextTick(() => treeRef.value?.setCurrentKey(selected.value));
}
function handleChange(item: T) {
selected.value = menuValue(item);
lastEmittedDefault.value = normalizedValue(selected.value);
emits('change', item);
syncTreeSelection();
}
function visibleActions(item: T) {
return props.controlBtns.filter(
(button) => !button.visible || button.visible(item),
);
}
function hasActions(item: T) {
const value = normalizedValue(menuValue(item));
return !['', '0'].includes(value) && visibleActions(item).length > 0;
}
function actionDisabled(button: ControlButton<T>, item: T) {
return typeof button.disabled === 'function'
? button.disabled(item)
: Boolean(button.disabled);
}
function runAction(button: ControlButton<T>, item: T) {
if (!actionDisabled(button, item)) button.onClick(item);
}
function isComponent(icon: any) {
return typeof icon !== 'string';
}
function isSvgString(icon: any) {
if (typeof icon !== 'string') return false;
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
}
watch(
[() => props.defaultSelected, () => props.menus],
([defaultSelected]) => {
selected.value = defaultSelected ?? '';
syncTreeSelection();
if (defaultSelected === '' || defaultSelected === undefined) return;
const matched = findMenu(props.menus, defaultSelected);
const normalized = normalizedValue(defaultSelected);
if (matched && lastEmittedDefault.value !== normalized) {
lastEmittedDefault.value = normalized;
emits('change', matched);
}
},
{ immediate: true },
{ deep: true, immediate: true },
);
const handleMouseEvent = (id?: string) => {
if (id === undefined) {
setTimeout(() => {
hoverId.value = id;
}, 200);
} else {
hoverId.value = id;
}
};
const isComponent = (icon: any) => {
return typeof icon !== 'string';
};
const isSvgString = (icon: any) => {
if (typeof icon !== 'string') return false;
// 简单判断:是否包含 SVG 根标签
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
};
</script>
<template>
<div
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
:style="{ width: `${panelWidth}px` }"
>
<div class="flex flex-1 flex-col gap-5 overflow-hidden">
<h3 v-if="title && title.length > 0" class="text-base font-medium">
{{ title }}
</h3>
<div class="page-side" :style="{ width: `${panelWidth}px` }">
<div class="page-side__main">
<h3 v-if="title" class="page-side__title">{{ title }}</h3>
<div class="flex-1 overflow-auto">
<div class="page-side__scroll">
<ElTree
v-if="childrenKey"
ref="treeRef"
class="page-side__tree"
:data="menus"
:node-key="valueKey"
:props="treeProps"
:current-node-key="selected"
:default-expand-all="defaultExpandAll"
:expand-on-click-node="false"
highlight-current
@node-click="handleChange"
>
<template #default="{ data }">
<div class="page-side__tree-node">
<div class="page-side__label">
<div v-if="data.icon" class="page-side__icon">
<!-- eslint-disable vue/no-v-html -->
<div
v-if="isSvgString(data.icon)"
v-html="data.icon"
class="page-side__svg"
:style="{
width: `${iconSize}px`,
height: `${iconSize}px`,
}"
></div>
<!-- eslint-enable vue/no-v-html -->
<img
v-else-if="
typeof data.icon === 'string' && !isComponent(data.icon)
"
:src="data.icon"
:style="{
width: `${iconSize}px`,
height: `${iconSize}px`,
}"
alt=""
/>
<ElIcon v-else>
<component :is="data.icon as Component" />
</ElIcon>
</div>
<slot name="label" :item="data">
<span class="page-side__label-text">
{{ data[labelKey] }}
</span>
</slot>
</div>
<ElDropdown v-if="hasActions(data)" trigger="click" @click.stop>
<ElButton
class="page-side__more"
:aria-label="`管理${data[labelKey]}`"
:icon="MoreFilled"
text
@click.stop
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="button in visibleActions(data)"
:key="button.label"
:disabled="actionDisabled(button, data)"
@click="runAction(button, data)"
>
<ElButton
:type="button.type"
:icon="button.icon"
:disabled="actionDisabled(button, data)"
link
>
{{ button.label }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElTree>
<template v-else>
<div
v-for="item in menus"
:key="item[valueKey]"
class="group list-item"
:key="menuValue(item)"
class="page-side__item"
:class="{
selected: selected === item[valueKey],
'is-selected':
normalizedValue(selected) === normalizedValue(menuValue(item)),
}"
role="button"
tabindex="0"
@click="handleChange(item)"
@keydown.enter.prevent="handleChange(item)"
@keydown.space.prevent="handleChange(item)"
>
<div class="flex items-center gap-1">
<div
v-if="item.icon"
class="ml-[-3px] flex items-center justify-center"
>
<div class="page-side__label">
<div v-if="item.icon" class="page-side__icon">
<!-- eslint-disable vue/no-v-html -->
<div
v-if="isSvgString(item.icon)"
v-html="item.icon"
class="page-side__svg"
:style="{
width: `${iconSize}px`,
height: `${iconSize}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}"
class="svg-container"
></div>
<!-- eslint-enable vue/no-v-html -->
<img
@@ -137,55 +273,53 @@ const isSvgString = (icon: any) => {
:style="{
width: `${iconSize}px`,
height: `${iconSize}px`,
objectFit: 'contain',
}"
alt=""
/>
<ElIcon v-else>
<component :is="item.icon as Component" v-bind="$attrs" />
<component :is="item.icon as Component" />
</ElIcon>
</div>
<div>
<slot name="label" :item="item">
<span class="page-side__label-text">
{{ item[labelKey] }}
</span>
</slot>
</div>
</div>
<ElDropdown
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
<ElDropdown v-if="hasActions(item)" trigger="click" @click.stop>
<ElButton
class="page-side__more"
:aria-label="`管理${item[labelKey]}`"
:icon="MoreFilled"
text
@click.stop
>
<div
:class="
cn(
'group-hover:!inline-flex',
(!hoverId || item.id !== hoverId) && '!hidden',
)
"
>
<ElIcon>
<MoreFilled />
</ElIcon>
</div>
/>
<template #dropdown>
<div
@mouseenter="handleMouseEvent(item.id)"
@mouseleave="handleMouseEvent()"
>
<ElDropdownMenu>
<ElDropdownItem
v-for="btn in controlBtns"
:key="btn.label"
@click="btn.onClick(item)"
v-for="button in visibleActions(item)"
:key="button.label"
:disabled="actionDisabled(button, item)"
@click="runAction(button, item)"
>
<ElButton :type="btn.type" :icon="btn.icon" link>
{{ btn.label }}
<ElButton
:type="button.type"
:icon="button.icon"
:disabled="actionDisabled(button, item)"
link
>
{{ button.label }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</div>
</template>
</ElDropdown>
</div>
</template>
<ElEmpty
v-if="menus.length <= 0"
v-if="menus.length === 0"
:image="getEmptyStateImageUrl(preferences.theme.mode)"
/>
</div>
@@ -193,9 +327,12 @@ const isSvgString = (icon: any) => {
<ElButton
v-if="footerButton"
@click="footerButton.onClick"
class="page-side__footer"
:disabled="footerButton.disabled"
:icon="footerButton.icon"
:loading="footerButton.loading"
plain
@click="footerButton.onClick"
>
{{ footerButton.label }}
</ElButton>
@@ -203,34 +340,164 @@ const isSvgString = (icon: any) => {
</template>
<style scoped>
.list-item {
.page-side {
display: flex;
gap: 10px;
flex: none;
flex-direction: column;
height: 100%;
min-height: 0;
padding: var(--space-2);
overflow: hidden;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-panel);
}
.page-side__main {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--space-5);
min-height: 0;
overflow: hidden;
}
.page-side__title {
margin: 0;
font-size: 16px;
font-weight: 500;
}
.page-side__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.page-side__item,
.page-side__tree-node {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
padding: 10px;
margin-bottom: 4px;
min-width: 0;
}
.page-side__item {
min-height: 40px;
padding: 0 var(--space-3);
margin-bottom: var(--space-1);
font-size: 14px;
color: hsl(var(--nav-item-muted-foreground));
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
border-radius: var(--radius-control);
outline: none;
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.list-item:hover {
background-color: hsl(var(--accent));
.page-side__item:hover {
color: hsl(var(--foreground));
background: hsl(var(--accent));
}
.list-item.selected {
color: hsl(var(--primary));
background-color: hsl(var(--primary) / 15%);
.page-side__item.is-selected {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.list-item.selected:where(.dark, .dark *) {
color: hsl(var(--accent-foreground));
background-color: hsl(var(--accent));
.page-side__item:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: -2px;
}
.svg-container :deep(svg) {
.page-side__label {
display: flex;
flex: 1;
gap: var(--space-1);
align-items: center;
min-width: 0;
}
.page-side__label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-side__icon {
display: flex;
flex: none;
align-items: center;
justify-content: center;
margin-left: -3px;
}
.page-side__icon img {
object-fit: contain;
}
.page-side__svg {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.page-side__more {
flex: none;
opacity: 0;
pointer-events: none;
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
}
.page-side__item:hover .page-side__more,
.page-side__item:focus-within .page-side__more,
.page-side__tree-node:hover .page-side__more,
.page-side__tree-node:focus-within .page-side__more {
opacity: 1;
pointer-events: auto;
}
.page-side__footer {
flex: none;
width: 100%;
margin-top: var(--space-2);
}
.page-side__tree {
color: hsl(var(--nav-item-muted-foreground));
background: transparent;
}
.page-side__tree :deep(.el-tree-node__content) {
height: 40px;
margin-bottom: var(--space-1);
font-size: 14px;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.page-side__tree :deep(.el-tree-node__content:hover) {
color: hsl(var(--foreground));
background: hsl(var(--accent));
}
.page-side__tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.page-side__tree-node {
flex: 1;
height: 100%;
padding-right: var(--space-1);
}
.page-side__svg :deep(svg) {
width: 100%;
max-width: 100%;
height: 100%;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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>

View File

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

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

View 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

View 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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

@@ -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('删除失败');
});
});

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

View File

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

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

View File

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

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

View File

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

View 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');
}

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

View 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;
}

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -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('目标路径已存在');
});
});

View File

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

View File

@@ -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

View File

@@ -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"

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
"esno",
"etag",
"execa",
"hitl",
"iconify",
"iconoir",
"intlify",

View File

@@ -85,6 +85,27 @@
/* Border radius for card, input and buttons */
--radius: 0.5rem;
/* Shared workspace and editor rhythm */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--radius-control: 10px;
--radius-toolbar: 12px;
--radius-panel: 16px;
--radius-float: 20px;
--radius-pill: 999px;
--motion-duration-fast: 120ms;
--motion-duration-base: 160ms;
--motion-duration-medium: 220ms;
--motion-duration-slow: 280ms;
--motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--motion-ease-decelerate: cubic-bezier(0.16, 1, 0.3, 1);
--motion-ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
/* ============= custom ============= */
/* 遮罩颜色 */

View File

@@ -28,6 +28,12 @@ export {
ExternalLink,
Eye,
EyeOff,
File,
FileCode2,
FileImage,
FileText,
FolderClosed,
FolderOpen,
FoldHorizontal,
Fullscreen,
Github,
@@ -43,10 +49,10 @@ export {
LogOut,
MailCheck,
Maximize,
MessageSquare,
ArrowRightFromLine as MdiMenuClose,
ArrowLeftFromLine as MdiMenuOpen,
Menu,
MessageSquare,
Minimize,
Minimize2,
MoonStar,
@@ -67,6 +73,7 @@ export {
Sun,
SunMoon,
SwatchBook,
Upload,
UserRoundPen,
X,
} from 'lucide-vue-next';

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts', '!**/*.test.ts', '!**/*.spec.ts'],
},
],
});

View File

@@ -0,0 +1,58 @@
{
"name": "@easyflow-core/editor-ui",
"version": "1.0.0",
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css",
"**/*.vue"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./markdown-live-editor": {
"types": "./src/MarkdownLiveEditor.vue",
"development": "./src/MarkdownLiveEditor.vue",
"default": "./dist/MarkdownLiveEditor.vue"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./dist/index.mjs"
},
"./markdown-live-editor": {
"default": "./dist/MarkdownLiveEditor.vue"
}
}
},
"dependencies": {
"@codemirror/commands": "^6.10.2",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.12.2",
"@codemirror/legacy-modes": "^6.5.1",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.15",
"@milkdown/crepe": "^7.21.2",
"@milkdown/kit": "^7.21.2",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"unified": "^11.0.5",
"vue": "catalog:"
}
}

View File

@@ -0,0 +1,85 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, expect, it, vi } from 'vitest';
import CodeEditor from './CodeEditor.vue';
const languageMocks = vi.hoisted(() => {
let releasePython = () => undefined;
const pythonReady = new Promise<void>((resolve) => {
releasePython = resolve;
});
return {
javascript: vi.fn(() => []),
python: vi.fn(() => []),
pythonReady,
releasePython: () => releasePython(),
};
});
vi.mock('@codemirror/lang-python', async () => {
await languageMocks.pythonReady;
return { python: languageMocks.python };
});
vi.mock('@codemirror/lang-javascript', () => ({
javascript: languageMocks.javascript,
}));
beforeEach(() => {
languageMocks.javascript.mockReset().mockReturnValue([]);
languageMocks.python.mockReset().mockReturnValue([]);
});
it('mounts the latest language when it changes during initial loading', async () => {
const wrapper = mount(CodeEditor, {
attachTo: document.body,
props: { language: 'python', modelValue: 'const value = 1;' },
});
await Promise.resolve();
expect(wrapper.attributes('aria-busy')).toBe('true');
expect(wrapper.text()).toContain('正在加载编辑器');
await wrapper.setProps({ language: 'javascript' });
await flushPromises();
expect(languageMocks.javascript).toHaveBeenCalledOnce();
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
languageMocks.releasePython();
await flushPromises();
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
wrapper.unmount();
});
it('shows a recoverable state when a language extension fails', async () => {
let releaseRetry = () => undefined;
const retryReady = new Promise<never[]>((resolve) => {
releaseRetry = () => resolve([]);
});
languageMocks.javascript.mockImplementationOnce(() => {
throw new Error('load failed');
});
languageMocks.javascript.mockImplementationOnce(() => retryReady as never);
const wrapper = mount(CodeEditor, {
attachTo: document.body,
props: { language: 'javascript', modelValue: 'const value = 1;' },
});
await flushPromises();
expect(wrapper.get('[role="alert"]').text()).toContain('编辑器加载失败');
expect(wrapper.find('.cm-editor').exists()).toBe(false);
await wrapper.get('[role="alert"] button').trigger('click');
expect(wrapper.attributes('aria-busy')).toBe('true');
expect(wrapper.text()).toContain('正在加载编辑器');
releaseRetry();
await flushPromises();
expect(wrapper.find('[role="alert"]').exists()).toBe(false);
expect(wrapper.findAll('.cm-editor')).toHaveLength(1);
wrapper.unmount();
});

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
import type { Extension } from '@codemirror/state';
import type { EditorLanguage } from './types';
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from '@codemirror/commands';
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import {
closeSearchPanel,
highlightSelectionMatches,
openSearchPanel,
searchKeymap,
} from '@codemirror/search';
import { Compartment, EditorState } from '@codemirror/state';
import {
drawSelection,
dropCursor,
placeholder as editorPlaceholder,
EditorView,
highlightActiveLine,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
} from '@codemirror/view';
const props = withDefaults(
defineProps<{
autofocus?: boolean;
disabled?: boolean;
language?: EditorLanguage;
lineWrapping?: boolean;
loading?: boolean;
modelValue: string;
placeholder?: string;
readonly?: boolean;
}>(),
{
autofocus: false,
disabled: false,
language: 'text',
lineWrapping: true,
loading: false,
placeholder: '',
readonly: false,
},
);
const emit = defineEmits<{
cursorChange: [{ column: number; line: number }];
save: [];
'update:modelValue': [value: string];
}>();
const hostRef = ref<HTMLElement>();
const viewRef = shallowRef<EditorView>();
const languageError = ref('');
const languageLoading = ref(false);
const languageSlot = new Compartment();
const readonlySlot = new Compartment();
const wrappingSlot = new Compartment();
let languageRequest = 0;
let syncingFromOutside = false;
async function loadLanguage(language: EditorLanguage): Promise<Extension> {
if (language === 'python') {
const { python } = await import('@codemirror/lang-python');
return python();
}
if (language === 'javascript' || language === 'json') {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: language === 'javascript', typescript: true });
}
if (language === 'markdown') {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
if (language === 'shell') {
const [{ StreamLanguage }, { shell }] = await Promise.all([
import('@codemirror/language'),
import('@codemirror/legacy-modes/mode/shell'),
]);
return StreamLanguage.define(shell);
}
return [];
}
function readonlyExtensions(readonly: boolean): Extension[] {
return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
}
function isReadonly() {
return props.readonly || props.disabled || props.loading;
}
function wrappingExtension(enabled: boolean): Extension {
return enabled ? EditorView.lineWrapping : [];
}
function createState(doc: string, language: Extension) {
return EditorState.create({
doc,
extensions: [
lineNumbers(),
foldGutter(),
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
indentOnInput(),
bracketMatching(),
highlightActiveLine(),
highlightActiveLineGutter(),
highlightSelectionMatches(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
editorPlaceholder(props.placeholder),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => {
if (isReadonly()) return true;
emit('save');
return true;
},
},
{
key: 'Escape',
run: (view) => {
if (closeSearchPanel(view)) return true;
view.contentDOM.blur();
hostRef.value?.focus();
return true;
},
},
indentWithTab,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...foldKeymap,
]),
languageSlot.of(language),
readonlySlot.of(readonlyExtensions(isReadonly())),
wrappingSlot.of(wrappingExtension(props.lineWrapping)),
EditorView.updateListener.of((update) => {
if (update.docChanged && !syncingFromOutside) {
emit('update:modelValue', update.state.doc.toString());
}
if (update.selectionSet || update.docChanged) {
const head = update.state.selection.main.head;
const line = update.state.doc.lineAt(head);
emit('cursorChange', {
column: head - line.from + 1,
line: line.number,
});
}
}),
EditorView.theme({
'&': {
backgroundColor: 'hsl(var(--surface-panel))',
color: 'hsl(var(--foreground))',
fontSize: '13px',
height: '100%',
},
'&.cm-focused': { outline: 'none' },
'.cm-content': { caretColor: 'hsl(var(--primary))', padding: '16px 0' },
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'hsl(var(--primary))',
},
'.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: 'hsl(var(--primary) / 0.16)',
},
'.cm-gutters': {
backgroundColor: 'hsl(var(--surface-subtle))',
borderRight: '1px solid hsl(var(--line-subtle))',
color: 'hsl(var(--text-muted))',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: 'hsl(var(--nav-item-hover) / 0.62)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'hsl(var(--surface-contrast-soft))',
border: '0',
color: 'hsl(var(--text-muted))',
},
'.cm-search': {
backgroundColor: 'hsl(var(--surface-elevated))',
borderBottom: '1px solid hsl(var(--line-subtle))',
color: 'hsl(var(--foreground))',
padding: '8px 12px',
},
}),
],
});
}
async function mountEditor() {
if (!hostRef.value) return;
const request = ++languageRequest;
languageError.value = '';
languageLoading.value = true;
try {
const language = await loadLanguage(props.language);
if (!hostRef.value || request !== languageRequest) return;
viewRef.value?.destroy();
viewRef.value = new EditorView({
parent: hostRef.value,
state: createState(props.modelValue || '', language),
});
if (props.autofocus) viewRef.value.focus();
} catch {
if (request === languageRequest) languageError.value = '编辑器加载失败';
} finally {
if (request === languageRequest) languageLoading.value = false;
}
}
async function updateLanguage(language: EditorLanguage) {
const view = viewRef.value;
if (!view) return;
const request = ++languageRequest;
languageError.value = '';
languageLoading.value = true;
try {
const extension = await loadLanguage(language);
if (!viewRef.value || request !== languageRequest) return;
viewRef.value.dispatch({ effects: languageSlot.reconfigure(extension) });
} catch {
if (request === languageRequest) languageError.value = '语法高亮加载失败';
} finally {
if (request === languageRequest) languageLoading.value = false;
}
}
function handleLanguageChange(language: EditorLanguage) {
if (viewRef.value) {
void updateLanguage(language);
return;
}
// 首次语言扩展仍在加载时重新发起挂载generation 会使旧请求失效。
void mountEditor();
}
function retryLanguage() {
if (viewRef.value) void updateLanguage(props.language);
else void mountEditor();
}
function focus() {
viewRef.value?.focus();
}
function openSearch() {
if (viewRef.value) openSearchPanel(viewRef.value);
}
function scrollToLine(lineNumber: number, column = 1) {
const view = viewRef.value;
if (!view) return;
const safeLine = Math.min(Math.max(lineNumber, 1), view.state.doc.lines);
const line = view.state.doc.line(safeLine);
const position = Math.min(line.from + Math.max(column - 1, 0), line.to);
view.dispatch({
effects: EditorView.scrollIntoView(position, { y: 'center' }),
selection: { anchor: position },
});
view.focus();
}
watch(
() => props.modelValue,
(value = '') => {
const view = viewRef.value;
if (!view) return;
const nextValue = value;
const currentValue = view.state.doc.toString();
if (nextValue === currentValue) return;
syncingFromOutside = true;
view.dispatch({
changes: { from: 0, insert: nextValue, to: currentValue.length },
});
syncingFromOutside = false;
},
);
watch(() => props.language, handleLanguageChange);
watch(
() => [props.readonly, props.disabled, props.loading],
() => {
viewRef.value?.dispatch({
effects: readonlySlot.reconfigure(readonlyExtensions(isReadonly())),
});
},
);
watch(
() => props.lineWrapping,
(value) => {
viewRef.value?.dispatch({
effects: wrappingSlot.reconfigure(wrappingExtension(value)),
});
},
);
onMounted(mountEditor);
onBeforeUnmount(() => {
languageRequest++;
viewRef.value?.destroy();
});
defineExpose({ focus, openSearch, scrollToLine });
</script>
<template>
<div
ref="hostRef"
class="easyflow-code-editor"
:class="{
'is-disabled': disabled,
'is-loading': loading || languageLoading,
}"
:aria-busy="loading || languageLoading"
:aria-disabled="disabled || loading"
role="region"
tabindex="-1"
aria-label="代码编辑器 Esc 退出编辑区域"
>
<div
v-if="loading || languageLoading"
class="easyflow-code-editor__loading"
aria-live="polite"
>
正在加载编辑器
</div>
<div
v-else-if="languageError"
class="easyflow-code-editor__error"
role="alert"
>
<span>{{ languageError }}</span>
<button type="button" @click="retryLanguage">重试</button>
</div>
</div>
</template>
<style scoped>
.easyflow-code-editor {
position: relative;
min-height: 0;
overflow: hidden;
background: hsl(var(--surface-panel));
border: 1px solid transparent;
border-radius: var(--radius-control);
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
box-shadow var(--motion-duration-fast) var(--motion-ease-standard);
}
.easyflow-code-editor:focus-within {
border-color: hsl(var(--primary) / 72%);
box-shadow: 0 0 0 2px hsl(var(--primary) / 12%);
}
.easyflow-code-editor.is-disabled {
cursor: not-allowed;
opacity: 0.62;
}
.easyflow-code-editor__loading,
.easyflow-code-editor__error {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
color: hsl(var(--text-muted));
pointer-events: auto;
background: hsl(var(--surface-panel) / 90%);
}
.easyflow-code-editor__error {
gap: var(--space-2);
color: hsl(var(--destructive));
}
.easyflow-code-editor__error button {
min-height: 32px;
padding: 0 var(--space-3);
color: hsl(var(--nav-item-active-foreground));
cursor: pointer;
background: hsl(var(--nav-item-active));
border: 0;
border-radius: var(--radius-control);
}
.easyflow-code-editor__error button:hover {
background: hsl(var(--nav-tool-hover));
}
.easyflow-code-editor__error button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,118 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import CodeViewer from './CodeViewer.vue';
const originalClipboard = navigator.clipboard;
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: originalCreateObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: originalRevokeObjectURL,
});
});
describe('code viewer', () => {
it('copies visible content and emits the shared download action', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const wrapper = mount(CodeViewer, {
global: { stubs: { CodeEditor: true } },
props: {
content: '{"ready":true}',
downloadMode: 'emit',
filename: 'references/data.json',
language: 'json',
},
});
const copyButton = wrapper
.findAll('button')
.find((button) => button.text() === '复制');
const downloadButton = wrapper
.findAll('button')
.find((button) => button.text() === '下载');
if (!copyButton || !downloadButton) throw new Error('缺少查看器操作按钮');
await copyButton.trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith('{"ready":true}');
expect(copyButton.text()).toBe('已复制');
await downloadButton.trigger('click');
expect(wrapper.emitted('download')).toHaveLength(1);
wrapper.unmount();
});
it('downloads an empty local text file and always releases its object URL', async () => {
const createObjectURL = vi.fn(() => 'blob:empty-source');
const revokeObjectURL = vi.fn();
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectURL,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectURL,
});
const click = vi
.spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => undefined);
const wrapper = mount(CodeViewer, {
global: { stubs: { CodeEditor: true } },
props: { content: '', filename: 'empty.txt' },
});
const downloadButton = wrapper
.findAll('button')
.find((button) => button.text() === '下载');
await downloadButton?.trigger('click');
expect(createObjectURL).toHaveBeenCalledOnce();
expect(createObjectURL.mock.calls[0]?.[0]).toBeInstanceOf(Blob);
expect(click).toHaveBeenCalledOnce();
expect(revokeObjectURL).toHaveBeenCalledWith('blob:empty-source');
wrapper.unmount();
});
it('leaves fullscreen mode with Escape', async () => {
const wrapper = mount(CodeViewer, {
global: { stubs: { CodeEditor: true } },
props: { content: 'echo ready', filename: 'scripts/check.sh' },
});
const fullscreenButton = wrapper
.findAll('button')
.find((button) => button.text() === '全屏');
await fullscreenButton?.trigger('click');
expect(wrapper.classes()).toContain('is-fullscreen');
expect(
wrapper
.findAll('button')
.find((button) => button.text() === '退出全屏')
?.attributes('aria-pressed'),
).toBe('true');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await wrapper.vm.$nextTick();
expect(wrapper.classes()).not.toContain('is-fullscreen');
wrapper.unmount();
});
});

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import type { EditorLanguage } from './types';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import CodeEditor from './CodeEditor.vue';
const props = withDefaults(
defineProps<{
content: string;
downloadMode?: 'emit' | 'local';
filename?: string;
hash?: string;
highlight?: boolean;
language?: EditorLanguage;
size?: number;
}>(),
{
filename: 'source.txt',
downloadMode: 'local',
hash: '',
highlight: true,
language: 'text',
size: undefined,
},
);
const emit = defineEmits<{
download: [];
error: [error: Error];
}>();
const editorRef = ref<InstanceType<typeof CodeEditor>>();
const fullscreen = ref(false);
const copied = ref(false);
let copiedTimer: number | undefined;
const sizeLabel = computed(() => {
const bytes = props.size ?? new TextEncoder().encode(props.content).length;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
});
async function copyContent() {
try {
await navigator.clipboard.writeText(props.content);
copied.value = true;
window.clearTimeout(copiedTimer);
copiedTimer = window.setTimeout(() => (copied.value = false), 1600);
} catch (error_) {
copied.value = false;
emit('error', error_ instanceof Error ? error_ : new Error('复制失败'));
}
}
function downloadContent() {
if (props.downloadMode === 'emit') {
emit('download');
return;
}
const url = URL.createObjectURL(
new Blob([props.content], { type: 'text/plain;charset=utf-8' }),
);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = props.filename;
try {
anchor.click();
} finally {
URL.revokeObjectURL(url);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && fullscreen.value) {
event.preventDefault();
fullscreen.value = false;
}
}
onMounted(() => window.addEventListener('keydown', handleKeydown));
onBeforeUnmount(() => {
window.clearTimeout(copiedTimer);
window.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<section
class="easyflow-code-viewer"
:class="{ 'is-fullscreen': fullscreen }"
:aria-label="`${filename} 只读内容`"
>
<header class="easyflow-code-viewer__toolbar">
<div class="easyflow-code-viewer__identity">
<strong>{{ filename }}</strong>
<span>{{ language }}</span>
<span>{{ sizeLabel }}</span>
<span v-if="hash" :title="hash">{{ hash.slice(0, 10) }}</span>
</div>
<div class="easyflow-code-viewer__actions">
<button type="button" @click="editorRef?.openSearch()">搜索</button>
<button type="button" @click="copyContent">
<span aria-live="polite">{{ copied ? '已复制' : '复制' }}</span>
</button>
<button
type="button"
:aria-pressed="fullscreen"
@click="fullscreen = !fullscreen"
>
{{ fullscreen ? '退出全屏' : '全屏' }}
</button>
<button type="button" @click="downloadContent">下载</button>
</div>
</header>
<CodeEditor
ref="editorRef"
class="easyflow-code-viewer__editor"
:language="highlight ? language : 'text'"
:model-value="content"
readonly
/>
</section>
</template>
<style scoped>
.easyflow-code-viewer {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
background: hsl(var(--surface-panel));
}
.easyflow-code-viewer.is-fullscreen {
position: fixed;
inset: var(--space-4);
z-index: var(--popup-z-index);
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-panel);
box-shadow: var(--shadow-float);
}
.easyflow-code-viewer__toolbar {
display: flex;
gap: var(--space-4);
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 var(--space-4);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.easyflow-code-viewer__identity,
.easyflow-code-viewer__actions {
display: flex;
gap: var(--space-2);
align-items: center;
}
.easyflow-code-viewer__identity span {
font-size: 12px;
color: hsl(var(--text-muted));
}
.easyflow-code-viewer__actions button {
padding: 5px 9px;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
}
.easyflow-code-viewer__actions button:hover {
color: hsl(var(--foreground));
background: hsl(var(--nav-item-hover));
}
.easyflow-code-viewer__actions button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.easyflow-code-viewer__editor {
flex: 1;
}
</style>

View File

@@ -0,0 +1,123 @@
import { flushPromises, mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
describe('markdown live editor DOM safety', () => {
it('renders raw HTML as text and removes unsafe link navigation', async () => {
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: {
modelValue: [
'<img src="x" onerror="window.__skillXss = true">',
'',
'[unsafe](javascript:alert(1))',
].join('\n'),
},
});
for (let index = 0; index < 6; index++) await flushPromises();
expect(
wrapper.element.querySelector('img[onerror], script, iframe'),
).toBeNull();
expect(wrapper.text()).toContain('<img src="x"');
expect(wrapper.element.querySelector('a[href^="javascript:"]')).toBeNull();
wrapper.unmount();
});
it('keeps an ordinary tight list in the live editor without fidelity loss', async () => {
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: '- first\n- second' },
});
for (let index = 0; index < 6; index++) await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
expect(wrapper.text()).toContain('first');
expect(wrapper.text()).toContain('second');
wrapper.unmount();
});
it('does not rewrite a loaded Skill instruction document', async () => {
const source = [
'# Instructions',
'',
'Use this skill to verify the complete Skill management workflow.',
'',
'## Expected result',
'',
'- Markdown renders while editing.',
'- Scripts are displayed and edited without execution.',
'- Assets can be previewed safely.',
].join('\n');
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: source },
});
for (let index = 0; index < 8; index++) await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('renders a YAML frontmatter block as a read-only atom and preserves it on round-trip', async () => {
const source = [
'---',
'name: research-skill',
'description: Research helper for the team.',
'---',
'',
'# Instructions',
'',
'Use this skill to look up references.',
].join('\n');
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: source },
});
for (let index = 0; index < 8; index++) await flushPromises();
const frontmatterNode = wrapper.element.querySelector(
'.milkdown-frontmatter',
);
expect(frontmatterNode).not.toBeNull();
expect(wrapper.text()).toContain('Skill 元数据');
expect(wrapper.text()).toContain('name: research-skill');
expect(wrapper.text()).toContain('Use this skill');
expect(frontmatterNode?.getAttribute('contenteditable')).toBe('false');
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
wrapper.unmount();
});
it('emits frontmatterActivate when the frontmatter block is clicked', async () => {
const source = ['---', 'name: research-skill', '---', '', '# Body'].join(
'\n',
);
const wrapper = mount(MarkdownLiveEditor, {
attachTo: document.body,
props: { modelValue: source },
});
for (let index = 0; index < 8; index++) await flushPromises();
const frontmatterNode = wrapper.element.querySelector<HTMLElement>(
'.milkdown-frontmatter',
);
expect(frontmatterNode).not.toBeNull();
frontmatterNode?.click();
expect(wrapper.emitted('frontmatterActivate')).toHaveLength(1);
wrapper.unmount();
});
});

View File

@@ -0,0 +1,410 @@
import { flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MarkdownLiveEditor from './MarkdownLiveEditor.vue';
const mockState = vi.hoisted(() => ({
dispatch: vi.fn(),
destroy: vi.fn(async () => undefined),
listSpreadNodes: [] as Array<{
attrs: Record<string, unknown>;
marks: unknown[];
position: number;
type: { name: string };
}>,
createPromise: undefined as Promise<void> | undefined,
markdown: '# Initial',
markdownUpdated: undefined as
| ((ctx: unknown, value: string) => void)
| undefined,
replaceAll: vi.fn(),
setNodeMarkup: vi.fn(),
setReadonly: vi.fn(),
setTransactionMeta: vi.fn(),
}));
vi.mock('@milkdown/kit/utils', () => ({
$nodeSchema: () => [{ id: 'nodeSchema' }, { id: 'node' }],
$remark: () => [{ id: 'remark' }, { id: 'plugin' }],
replaceAll: (value: string, flush: boolean) => {
mockState.replaceAll(value, flush);
return () => {
mockState.markdown = value;
};
},
}));
vi.mock('@milkdown/crepe', () => {
class MockCrepe {
static Feature = {
AI: 'ai',
ImageBlock: 'image-block',
Latex: 'latex',
Placeholder: 'placeholder',
TopBar: 'top-bar',
};
create = vi.fn(async () => mockState.createPromise);
destroy = mockState.destroy;
editor = {
action: (action: (ctx: unknown) => void) =>
action({
get: (slice: { name?: string }) => {
if (slice.name === 'editorState') {
return {
doc: {
descendants: (
callback: (
node: (typeof mockState.listSpreadNodes)[number],
position: number,
) => void,
) =>
mockState.listSpreadNodes.forEach((node) =>
callback(node, node.position),
),
},
tr: {
setMeta: mockState.setTransactionMeta,
setNodeMarkup: mockState.setNodeMarkup,
},
};
}
if (slice.name === 'editorView') {
return { dispatch: mockState.dispatch };
}
return undefined;
},
}),
config: () => undefined,
use: () => undefined,
};
setReadonly = mockState.setReadonly;
constructor(config: { defaultValue?: string }) {
mockState.markdown = config.defaultValue || '';
}
getMarkdown = () => mockState.markdown;
on = (
register: (listener: {
markdownUpdated: (callback: typeof mockState.markdownUpdated) => void;
}) => void,
) => {
register({
markdownUpdated: (callback) => (mockState.markdownUpdated = callback),
});
};
}
return { Crepe: MockCrepe };
});
describe('markdownLiveEditor', () => {
beforeEach(() => {
mockState.destroy.mockClear();
mockState.createPromise = undefined;
mockState.dispatch.mockClear();
mockState.replaceAll.mockClear();
mockState.setReadonly.mockClear();
mockState.markdown = '# Initial';
mockState.markdownUpdated = undefined;
mockState.listSpreadNodes.length = 0;
mockState.setNodeMarkup.mockClear();
mockState.setTransactionMeta.mockClear();
});
afterEach(() => vi.useRealTimers());
it('uses a flush replace for external values without emitting a delayed model loop', async () => {
vi.useFakeTimers();
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
await wrapper.setProps({ modelValue: '# External' });
expect(mockState.replaceAll).toHaveBeenCalledWith('# External', true);
vi.advanceTimersByTime(250);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
mockState.markdownUpdated?.({}, '# User edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# User edit',
]);
});
it('does not mark the document changed while Crepe normalizes its initial value', async () => {
let resolveCreate: (() => void) | undefined;
mockState.createPromise = new Promise<void>((resolve) => {
resolveCreate = resolve;
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
mockState.markdownUpdated?.({}, '# Normalized during create');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
resolveCreate?.();
await flushPromises();
await wrapper
.get('.easyflow-markdown-live-editor__host')
.trigger('beforeinput');
mockState.markdownUpdated?.({}, '# User edit');
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
'# User edit',
]);
});
it('ignores lossless normalization emitted after the editor is ready', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
mockState.markdownUpdated?.({}, '* first\n* second\n');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
it('locks the live editor when initial markdown cannot be preserved', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '* normalized';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- original' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
mockState.markdownUpdated?.({}, '* normalized');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
mockState.markdownUpdated?.({}, '* user edit');
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
});
it('accepts line-ending and final-newline normalization as lossless', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '# Heading\n';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Heading\r\n' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
});
it('accepts common Crepe list and delimiter normalization as lossless', async () => {
const source = [
'# Review',
'',
'- first',
'- second',
'',
'---',
'',
'| A | B |',
'| --- | :---: |',
'| 1 | 2 |',
].join('\n');
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = [
'# Review',
'',
'* first',
'* second',
'',
'***',
'',
'| A | B |',
'| - | :-: |',
'| 1 | 2 |',
'',
].join('\n');
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: source },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toBeUndefined();
expect(mockState.setReadonly).toHaveBeenLastCalledWith(false);
});
it('keeps internal list spread repairs out of undo history', async () => {
mockState.listSpreadNodes.push({
attrs: { spread: 'false' },
marks: [],
position: 4,
type: { name: 'bullet_list' },
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
4,
undefined,
{ spread: false },
[],
);
expect(mockState.setTransactionMeta).toHaveBeenCalledWith(
'addToHistory',
false,
);
expect(mockState.dispatch).toHaveBeenCalledOnce();
wrapper.unmount();
});
it('repairs delayed list spread normalization before updating the model', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
mockState.listSpreadNodes.push({
attrs: { spread: 'false' },
marks: [],
position: 4,
type: { name: 'bullet_list' },
});
mockState.markdown = '* first\n\n* second';
mockState.dispatch.mockImplementationOnce(() => {
mockState.markdown = '* first\n* second';
});
mockState.markdownUpdated?.({}, '* first\n\n* second');
expect(mockState.setNodeMarkup).toHaveBeenCalledWith(
4,
undefined,
{ spread: false },
[],
);
expect(wrapper.emitted('update:modelValue')).toBeUndefined();
wrapper.unmount();
});
it('does not mask changes inside fenced code blocks', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = ['```text', '* changed', '```'].join('\n');
});
const wrapper = mount(MarkdownLiveEditor, {
props: {
modelValue: ['```text', '- changed', '```'].join('\n'),
},
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
it('does not mask changes inside indented code blocks', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = ' * changed';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: ' - changed' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
it('does not treat a setext heading underline as a thematic break', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = ['Heading', '***'].join('\n');
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: ['Heading', '---'].join('\n') },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
it('does not merge lists separated by different bullet markers', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '* first\n* second';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n* second' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
});
it('does not collapse a tight list into a loose list', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = '* first\n\n* second';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '- first\n- second' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
});
it('does not normalize invalid table-like text as a GFM table', async () => {
mockState.createPromise = Promise.resolve().then(() => {
mockState.markdown = 'A | B | C\n- | -';
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: 'A | B | C\n--- | ---' },
});
await flushPromises();
expect(wrapper.emitted('fidelityLoss')).toHaveLength(1);
});
it('awaits editor destruction when unmounted', async () => {
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await flushPromises();
wrapper.unmount();
await flushPromises();
expect(mockState.destroy).toHaveBeenCalledOnce();
});
it('applies the latest model value when it changes during creation', async () => {
let resolveCreate: (() => void) | undefined;
mockState.createPromise = new Promise<void>((resolve) => {
resolveCreate = resolve;
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial' },
});
await wrapper.setProps({ modelValue: '# Latest' });
resolveCreate?.();
await flushPromises();
expect(mockState.replaceAll).toHaveBeenCalledWith('# Latest', true);
});
it('applies the latest readonly state when it changes during creation', async () => {
let resolveCreate: (() => void) | undefined;
mockState.createPromise = new Promise<void>((resolve) => {
resolveCreate = resolve;
});
const wrapper = mount(MarkdownLiveEditor, {
props: { modelValue: '# Initial', readonly: false },
});
await wrapper.setProps({ readonly: true });
resolveCreate?.();
await flushPromises();
expect(mockState.setReadonly).toHaveBeenLastCalledWith(true);
});
});

View File

@@ -0,0 +1,630 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { Crepe } from '@milkdown/crepe';
import { editorStateCtx, editorViewCtx } from '@milkdown/kit/core';
import { replaceAll } from '@milkdown/kit/utils';
import {
hasEquivalentMarkdownSemantics,
normalizeLiveMarkdownUpdate,
} from './markdown-fidelity';
import {
isSafeMarkdownLinkHref,
resolveSafeExternalMarkdownImageUrl,
} from './markdown-security';
import { installFrontmatter } from './milkdown-frontmatter';
import '@milkdown/crepe/theme/common/style.css';
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
readonly?: boolean;
resolveImageUrl?: (url: string) => Promise<string> | string;
uploadImage?: (file: File) => Promise<string>;
}>(),
{
placeholder: '输入 Skill 指令…',
readonly: false,
resolveImageUrl: undefined,
uploadImage: undefined,
},
);
const emit = defineEmits<{
error: [error: Error];
fidelityLoss: [];
frontmatterActivate: [];
linkClick: [href: string];
ready: [];
save: [];
'update:modelValue': [value: string];
}>();
const hostRef = ref<HTMLElement>();
const crepeRef = shallowRef<Crepe>();
const loading = ref(true);
const failed = ref(false);
const fidelityLoss = ref(false);
let destroyed = false;
let hasUserEdited = false;
let ready = false;
let syncingFromOutside = false;
let synchronizedMarkdown = '';
let domObserver: MutationObserver | undefined;
async function createEditor() {
if (!hostRef.value) return;
const initialModelValue = props.modelValue || '';
loading.value = true;
failed.value = false;
fidelityLoss.value = false;
const crepe = new Crepe({
defaultValue: initialModelValue,
featureConfigs: {
[Crepe.Feature.ImageBlock]: {
onUpload: async (file) => {
if (props.uploadImage) return props.uploadImage(file);
const error = new Error(
'当前上下文不支持直接上传图片,请从资源文件树上传',
);
emit('error', error);
throw error;
},
proxyDomURL: (url) =>
props.resolveImageUrl
? props.resolveImageUrl(url)
: resolveSafeExternalMarkdownImageUrl(url),
},
[Crepe.Feature.Placeholder]: {
mode: 'block',
text: props.placeholder,
},
},
features: {
[Crepe.Feature.AI]: false,
[Crepe.Feature.ImageBlock]: true,
[Crepe.Feature.Latex]: false,
[Crepe.Feature.TopBar]: false,
},
root: hostRef.value,
});
installFrontmatter(crepe.editor);
crepe.setReadonly(props.readonly);
crepe.on((listener) => {
listener.markdownUpdated((_ctx, markdown) => {
const repairedListSpread = normalizeListSpreadAttributes(crepe);
const editorMarkdown = repairedListSpread
? crepe.getMarkdown()
: markdown;
const nextMarkdown = normalizeLiveMarkdownUpdate(
props.modelValue || '',
editorMarkdown,
);
if (
!ready ||
fidelityLoss.value ||
syncingFromOutside ||
!hasUserEdited ||
nextMarkdown === props.modelValue ||
nextMarkdown === synchronizedMarkdown ||
!hasMeaningfulMarkdownDifference(props.modelValue || '', nextMarkdown)
) {
synchronizedMarkdown = nextMarkdown;
return;
}
synchronizedMarkdown = nextMarkdown;
emit('update:modelValue', nextMarkdown);
});
});
try {
await crepe.create();
if (destroyed) {
await crepe.destroy();
return;
}
const latestModelValue = props.modelValue || '';
if (latestModelValue !== initialModelValue) {
syncingFromOutside = true;
try {
crepe.editor.action(replaceAll(latestModelValue, true));
} finally {
syncingFromOutside = false;
}
}
normalizeListSpreadAttributes(crepe);
synchronizedMarkdown = normalizeLiveMarkdownUpdate(
latestModelValue,
crepe.getMarkdown(),
);
if (
hasMeaningfulMarkdownDifference(latestModelValue, synchronizedMarkdown)
) {
reportFidelityLoss(crepe);
}
crepe.setReadonly(props.readonly || fidelityLoss.value);
crepeRef.value = crepe;
ready = true;
emit('ready');
} catch (error_) {
failed.value = true;
const error =
error_ instanceof Error ? error_ : new Error('实时编辑器加载失败');
emit('error', error);
try {
await crepe.destroy();
} catch {
// Best-effort cleanup after a partial editor initialization.
}
} finally {
loading.value = false;
}
}
watch(
() => props.modelValue,
(value) => {
const crepe = crepeRef.value;
if (!crepe) return;
const editorMarkdown = crepe.getMarkdown();
const comparableMarkdown = normalizeLiveMarkdownUpdate(
value || '',
editorMarkdown,
);
if (comparableMarkdown === (value || '')) {
synchronizedMarkdown = comparableMarkdown;
return;
}
syncingFromOutside = true;
hasUserEdited = false;
try {
crepe.editor.action(replaceAll(value || '', true));
normalizeListSpreadAttributes(crepe);
synchronizedMarkdown = crepe.getMarkdown();
if (hasMeaningfulMarkdownDifference(value || '', synchronizedMarkdown)) {
reportFidelityLoss(crepe);
}
} finally {
syncingFromOutside = false;
}
},
);
watch(
() => props.readonly,
(value) => crepeRef.value?.setReadonly(value || fidelityLoss.value),
);
onMounted(createEditor);
onMounted(() => {
hostRef.value?.addEventListener('click', handleLinkClick);
hostRef.value?.addEventListener('click', handleFrontmatterActivate);
hostRef.value?.addEventListener('beforeinput', markUserEdited);
hostRef.value?.addEventListener('drop', markUserEdited);
hostRef.value?.addEventListener('keydown', handleKeydown);
hostRef.value?.addEventListener('paste', markUserEdited);
if (hostRef.value) {
domObserver = new MutationObserver(sanitizeRenderedLinks);
domObserver.observe(hostRef.value, {
attributeFilter: ['href'],
attributes: true,
childList: true,
subtree: true,
});
sanitizeRenderedLinks();
}
});
onBeforeUnmount(async () => {
destroyed = true;
ready = false;
hostRef.value?.removeEventListener('click', handleLinkClick);
hostRef.value?.removeEventListener('click', handleFrontmatterActivate);
hostRef.value?.removeEventListener('beforeinput', markUserEdited);
hostRef.value?.removeEventListener('drop', markUserEdited);
hostRef.value?.removeEventListener('keydown', handleKeydown);
hostRef.value?.removeEventListener('paste', markUserEdited);
domObserver?.disconnect();
domObserver = undefined;
await crepeRef.value?.destroy();
crepeRef.value = undefined;
});
function handleLinkClick(event: MouseEvent) {
const target = event.target as HTMLElement | null;
const anchor = target?.closest<HTMLAnchorElement>('a[href]');
if (!anchor) return;
const href = anchor.getAttribute('href') || '';
if (!href || !isSafeMarkdownLinkHref(href)) {
event.preventDefault();
emit('error', new Error('该链接协议不受支持'));
return;
}
if (/^https?:\/\//i.test(href)) {
anchor.rel = 'noopener noreferrer';
anchor.target = '_blank';
return;
}
if (/^(?:mailto:|#)/i.test(href)) return;
event.preventDefault();
emit('linkClick', href);
}
function handleFrontmatterActivate(event: Event) {
const target = event.target as HTMLElement | null;
if (!target) return;
const frontmatter = target.closest<HTMLElement>('.milkdown-frontmatter');
if (!frontmatter) return;
event.preventDefault();
emit('frontmatterActivate');
}
function markUserEdited() {
hasUserEdited = true;
}
function sanitizeRenderedLinks() {
hostRef.value
?.querySelectorAll<HTMLAnchorElement>('a[href]')
.forEach((anchor) => {
const href = anchor.getAttribute('href') || '';
if (!isSafeMarkdownLinkHref(href)) {
anchor.removeAttribute('href');
anchor.setAttribute('aria-disabled', 'true');
return;
}
if (/^https?:\/\//i.test(href)) {
anchor.rel = 'noopener noreferrer';
anchor.target = '_blank';
}
});
}
function handleKeydown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
if (!props.readonly) emit('save');
}
}
function reportFidelityLoss(crepe: Crepe) {
if (fidelityLoss.value) return;
fidelityLoss.value = true;
crepe.setReadonly(true);
emit('fidelityLoss');
}
function hasMeaningfulMarkdownDifference(source: string, normalized: string) {
return !hasEquivalentMarkdownSemantics(source, normalized);
}
function normalizeListSpreadAttributes(crepe: Crepe) {
let repaired = false;
crepe.editor.action((ctx) => {
const state = ctx.get(editorStateCtx);
const transaction = state.tr;
let changed = false;
state.doc.descendants((node, position) => {
if (
!['bullet_list', 'list_item', 'ordered_list'].includes(node.type.name)
) {
return;
}
const spread = node.attrs.spread;
if (spread !== 'true' && spread !== 'false') return;
transaction.setNodeMarkup(
position,
undefined,
{ ...node.attrs, spread: spread === 'true' },
node.marks,
);
changed = true;
});
if (changed) {
repaired = true;
transaction.setMeta('addToHistory', false);
ctx.get(editorViewCtx).dispatch(transaction);
}
});
return repaired;
}
</script>
<template>
<div class="easyflow-markdown-live-editor">
<div
ref="hostRef"
class="easyflow-markdown-live-editor__host"
role="region"
aria-label="Markdown 实时编辑器"
></div>
<div
v-if="loading"
class="easyflow-markdown-live-editor__state"
aria-live="polite"
>
正在加载编辑器
</div>
<div
v-else-if="failed"
class="easyflow-markdown-live-editor__state is-error"
role="alert"
>
实时编辑器加载失败请切换到源码模式继续编辑
</div>
<div
v-else-if="fidelityLoss"
class="easyflow-markdown-live-editor__state is-error"
role="alert"
>
当前内容包含实时编辑器无法保真处理的语法请切换到源码模式
</div>
</div>
</template>
<style scoped>
.easyflow-markdown-live-editor {
position: relative;
display: flex;
flex: 1;
min-height: 0;
overflow: auto;
background: hsl(var(--surface-panel));
}
.easyflow-markdown-live-editor__host {
flex: 1;
width: 100%;
min-height: 100%;
}
.easyflow-markdown-live-editor__state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: hsl(var(--text-muted));
background: hsl(var(--surface-panel) / 92%);
}
.easyflow-markdown-live-editor__state.is-error {
color: hsl(var(--destructive));
}
.easyflow-markdown-live-editor :deep(.milkdown) {
--crepe-color-background: hsl(var(--surface-panel));
--crepe-color-on-background: hsl(var(--foreground));
--crepe-color-surface: hsl(var(--surface-subtle));
--crepe-color-surface-low: hsl(var(--surface-contrast-soft));
--crepe-color-on-surface: hsl(var(--foreground));
--crepe-color-on-surface-variant: hsl(var(--text-muted));
--crepe-color-outline: hsl(var(--line-subtle));
--crepe-color-primary: hsl(var(--primary));
--crepe-color-secondary: hsl(var(--nav-item-active));
--crepe-color-on-secondary: hsl(var(--nav-item-active-foreground));
--crepe-color-inverse: hsl(var(--foreground));
--crepe-color-on-inverse: hsl(var(--surface-panel));
--crepe-color-inline-code: hsl(var(--destructive));
--crepe-color-error: hsl(var(--destructive));
--crepe-color-hover: hsl(var(--nav-item-hover));
--crepe-color-selected: hsl(var(--nav-item-active));
--crepe-color-inline-area: hsl(var(--surface-contrast-soft));
--crepe-font-default: var(--font-family);
--crepe-font-title: var(--font-family);
--crepe-font-code:
ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
--crepe-shadow-1: var(--shadow-subtle);
--crepe-shadow-2: var(--shadow-toolbar);
min-height: 100%;
color: hsl(var(--foreground));
background: transparent;
}
.easyflow-markdown-live-editor :deep(.ProseMirror) {
box-sizing: border-box;
width: min(100%, 960px);
min-height: 100%;
padding: var(--space-8) clamp(var(--space-4), 5vw, 72px) 64px;
margin: 0 auto;
font-size: 15px;
line-height: 1.76;
color: hsl(var(--text-strong));
background: transparent;
box-shadow: none;
}
.easyflow-markdown-live-editor :deep(.ProseMirror:focus-visible) {
outline: none;
box-shadow: inset 3px 0 hsl(var(--primary) / 28%);
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter) {
box-sizing: border-box;
margin-block: var(--space-4);
padding: var(--space-3) var(--space-4);
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 0;
border-left: 3px solid hsl(var(--primary) / 36%);
border-radius: 0;
transition:
border-color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:hover),
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter:focus-visible) {
background: hsl(var(--surface-contrast-soft));
border-left-color: hsl(var(--primary));
outline: none;
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__title) {
margin-bottom: var(--space-1);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: hsl(var(--text-muted));
}
.easyflow-markdown-live-editor :deep(.milkdown-frontmatter__code) {
margin: 0;
padding: 0;
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
color: hsl(var(--text-strong));
white-space: pre-wrap;
word-break: break-word;
background: transparent;
border: 0;
}
.easyflow-markdown-live-editor :deep(a) {
color: hsl(var(--primary));
text-underline-offset: 3px;
}
.easyflow-markdown-live-editor :deep(img) {
max-width: 100%;
border-radius: var(--radius);
}
.easyflow-markdown-live-editor :deep(h1),
.easyflow-markdown-live-editor :deep(h2),
.easyflow-markdown-live-editor :deep(h3),
.easyflow-markdown-live-editor :deep(h4) {
color: hsl(var(--text-strong));
letter-spacing: -0.012em;
}
.easyflow-markdown-live-editor :deep(h1) {
padding-bottom: var(--space-3);
margin-top: 0;
font-size: 28px;
border-bottom: 1px solid hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(h2) {
margin-top: var(--space-8);
font-size: 21px;
}
.easyflow-markdown-live-editor :deep(h3) {
margin-top: var(--space-6);
font-size: 17px;
}
.easyflow-markdown-live-editor :deep(p),
.easyflow-markdown-live-editor :deep(ul),
.easyflow-markdown-live-editor :deep(ol),
.easyflow-markdown-live-editor :deep(blockquote),
.easyflow-markdown-live-editor :deep(pre),
.easyflow-markdown-live-editor :deep(table) {
margin-block: var(--space-4);
}
.easyflow-markdown-live-editor :deep(blockquote) {
padding: var(--space-2) var(--space-4);
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle));
border-left: 3px solid hsl(var(--primary) / 48%);
}
.easyflow-markdown-live-editor :deep(pre) {
padding: var(--space-4);
overflow: auto;
background: hsl(var(--surface-contrast-soft));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-control);
}
.easyflow-markdown-live-editor :deep(code) {
font-family: ui-monospace, sfmono-regular, menlo, monaco, consolas, monospace;
font-size: 0.9em;
}
.easyflow-markdown-live-editor :deep(table) {
width: 100%;
border-collapse: collapse;
}
.easyflow-markdown-live-editor :deep(th),
.easyflow-markdown-live-editor :deep(td) {
padding: var(--space-2) var(--space-3);
text-align: left;
border: 1px solid hsl(var(--line-subtle));
}
.easyflow-markdown-live-editor :deep(th) {
font-weight: 600;
background: hsl(var(--surface-subtle));
}
@supports not (color: color-mix(in srgb, red, blue)) {
.easyflow-markdown-live-editor :deep(.ProseMirror code),
.easyflow-markdown-live-editor :deep(.ProseMirror pre) {
background: var(--crepe-color-inline-area);
}
.easyflow-markdown-live-editor :deep(.ProseMirror hr),
.easyflow-markdown-live-editor :deep(.milkdown-toolbar .divider),
.easyflow-markdown-live-editor :deep(.milkdown-top-bar .top-bar-divider) {
background-color: var(--crepe-color-outline);
}
.easyflow-markdown-live-editor
:deep(.ProseMirror hr.ProseMirror-selectednode) {
background-color: var(--crepe-color-selected);
}
.easyflow-markdown-live-editor :deep(.milkdown-slash-menu .tab-group),
.easyflow-markdown-live-editor :deep(.milkdown-table-block td),
.easyflow-markdown-live-editor :deep(.milkdown-table-block th),
.easyflow-markdown-live-editor :deep(.milkdown-top-bar) {
border-color: var(--crepe-color-outline);
}
.easyflow-markdown-live-editor
:deep(.milkdown-slash-menu .menu-groups .menu-group h6),
.easyflow-markdown-live-editor
:deep(.milkdown-code-block .preview-panel .preview-label) {
color: var(--crepe-color-on-surface);
}
.easyflow-markdown-live-editor :deep(.crepe-placeholder::before),
.easyflow-markdown-live-editor
:deep(.milkdown-image-block .image-edit .link-importer .placeholder),
.easyflow-markdown-live-editor
:deep(.milkdown-image-inline .link-importer .placeholder) {
color: var(--crepe-color-on-background);
}
.easyflow-markdown-live-editor
:deep(.milkdown-image-block.selected > .image-edit::before),
.easyflow-markdown-live-editor
:deep(.milkdown-image-block.selected > .image-wrapper::before) {
background: var(--crepe-color-selected);
}
.easyflow-markdown-live-editor :deep(.crepe-drop-cursor) {
background-color: var(--crepe-color-outline);
}
}
@media (max-width: 720px) {
.easyflow-markdown-live-editor :deep(.ProseMirror) {
padding: var(--space-6) var(--space-4) var(--space-8);
box-shadow: none;
}
}
@media (prefers-reduced-motion: reduce) {
.easyflow-markdown-live-editor :deep(*) {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue';
import CodeEditor from './CodeEditor.vue';
withDefaults(
defineProps<{
highlight?: boolean;
lineWrapping?: boolean;
modelValue: string;
readonly?: boolean;
}>(),
{ highlight: true, lineWrapping: true, readonly: false },
);
const emit = defineEmits<{
cursorChange: [{ column: number; line: number }];
save: [];
'update:modelValue': [value: string];
}>();
const editorRef = ref<InstanceType<typeof CodeEditor>>();
defineExpose({
focus: () => editorRef.value?.focus(),
openSearch: () => editorRef.value?.openSearch(),
scrollToLine: (line: number, column = 1) =>
editorRef.value?.scrollToLine(line, column),
});
</script>
<template>
<CodeEditor
ref="editorRef"
:model-value="modelValue"
:language="highlight ? 'markdown' : 'text'"
:line-wrapping="lineWrapping"
:readonly="readonly"
@cursor-change="emit('cursorChange', $event)"
@save="emit('save')"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>

View File

@@ -0,0 +1,6 @@
export { default as CodeEditor } from './CodeEditor.vue';
export { default as CodeViewer } from './CodeViewer.vue';
export * from './markdown-security';
export { default as MarkdownLiveEditor } from './MarkdownLiveEditor.vue';
export { default as MarkdownSourceEditor } from './MarkdownSourceEditor.vue';
export * from './types';

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
hasEquivalentMarkdownSemantics,
normalizeLiveMarkdownUpdate,
} from './markdown-fidelity';
describe('markdown semantic fidelity', () => {
it('accepts equivalent list markers, rules, and table delimiter widths', () => {
const source = [
'- first',
'- second',
'',
'---',
'',
'| A | B |',
'| --- | :---: |',
'| 1 | 2 |',
].join('\n');
const normalized = [
'* first',
'* second',
'',
'***',
'',
'| A | B |',
'| - | :-: |',
'| 1 | 2 |',
'',
].join('\n');
expect(hasEquivalentMarkdownSemantics(source, normalized)).toBe(true);
});
it('preserves a valid single-column table', () => {
expect(
hasEquivalentMarkdownSemantics('| A |\n| --- |', '| A |\n| - |'),
).toBe(true);
});
it.each([
['mixed list boundaries', '- first\n* second', '* first\n* second'],
['tight and loose lists', '- first\n- second', '* first\n\n* second'],
['setext and thematic breaks', 'Heading\n---', 'Heading\n***'],
['fenced code content', '```\n- code\n```', '```\n* code\n```'],
['indented code content', ' - code', ' * code'],
['invalid table-like text', 'A | B | C\n--- | ---', 'A | B | C\n- | -'],
])('rejects %s changes', (_name, source, normalized) => {
expect(hasEquivalentMarkdownSemantics(source, normalized)).toBe(false);
});
});
describe('live markdown normalization', () => {
it('removes empty paragraph markers introduced by the live editor', () => {
expect(
normalizeLiveMarkdownUpdate(
'# Title\n\nFirst\n\nSecond\n',
'# Title\n\n<br />\n\nFirst\n\n<br />\n\nSecond\n',
),
).toBe('# Title\n\nFirst\n\nSecond\n');
});
it('preserves explicit standalone HTML breaks from source mode', () => {
const source = '# Title\n\n<br />\n\nText\n';
expect(normalizeLiveMarkdownUpdate(source, source)).toBe(source);
});
});

View File

@@ -0,0 +1,43 @@
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
const markdownParser = unified().use(remarkParse).use(remarkGfm);
const STANDALONE_BREAK_LINE = /^[\t ]*<br\s*\/?>[\t ]*$/im;
/**
* Compares Markdown by parsed GFM structure so harmless marker formatting is
* accepted while list, table, heading, code, and raw HTML changes are rejected.
*/
export function hasEquivalentMarkdownSemantics(
source: string,
normalized: string,
) {
try {
return semanticTree(source) === semanticTree(normalized);
} catch {
return false;
}
}
/**
* Removes blank-paragraph markers generated by the live editor without
* changing an explicit HTML break that already exists in the source.
*/
export function normalizeLiveMarkdownUpdate(source: string, markdown: string) {
if (STANDALONE_BREAK_LINE.test(source)) return markdown;
return markdown
.replace(/^[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '')
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*\n{2,}/gi, '\n\n')
.replace(/\n{2,}[\t ]*<br\s*\/?>[\t ]*$/gi, '\n');
}
function semanticTree(markdown: string) {
const normalizedLineEndings = markdown
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n');
return JSON.stringify(
markdownParser.parse(normalizedLineEndings),
(key, value) => (key === 'position' ? undefined : value),
);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import {
EMPTY_IMAGE_DATA_URL,
isBlockedMarkdownImageUrl,
isSafeMarkdownLinkHref,
resolveSafeExternalMarkdownImageUrl,
} from './markdown-security';
describe('markdown image URL safety', () => {
it.each([
'javascript:alert(1)',
'file:///etc/passwd',
'blob:https://example.com/foreign',
'data:image/svg+xml,<svg onload=alert(1)>',
'http://example.com/insecure.png',
'//example.com/image.png',
'../assets/image.png',
])('blocks URLs that require an application resolver: %s', (url) => {
expect(isBlockedMarkdownImageUrl(url)).toBe(true);
expect(resolveSafeExternalMarkdownImageUrl(url)).toBe(EMPTY_IMAGE_DATA_URL);
});
it('allows credential-free HTTPS images', () => {
expect(resolveSafeExternalMarkdownImageUrl('https://cdn.test/a.png')).toBe(
'https://cdn.test/a.png',
);
expect(
isBlockedMarkdownImageUrl('https://user:secret@cdn.test/a.png'),
).toBe(true);
});
it.each([
'javascript:alert(1)',
'file:///etc/passwd',
'blob:https://example.com/foreign',
'data:text/html,<script>alert(1)</script>',
'//example.com/path',
'https://user:secret@example.com/path',
])('blocks unsafe rendered links: %s', (href) => {
expect(isSafeMarkdownLinkHref(href)).toBe(false);
});
it.each([
'#section',
'mailto:team@example.com',
'https://example.com/path',
'http://example.com/path',
'../references/guide.md',
])('allows supported rendered links: %s', (href) => {
expect(isSafeMarkdownLinkHref(href)).toBe(true);
});
});

View File

@@ -0,0 +1,47 @@
const EMPTY_IMAGE_DATA_URL =
'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
/** Return a safe URL for an externally hosted Markdown image. */
export function resolveSafeExternalMarkdownImageUrl(url: string) {
const value = url.trim();
try {
const parsed = new URL(value);
if (parsed.protocol === 'https:' && !parsed.username && !parsed.password) {
return parsed.href;
}
} catch {
// Relative and malformed values require an application-level resolver.
}
return EMPTY_IMAGE_DATA_URL;
}
export function isBlockedMarkdownImageUrl(url: string) {
return resolveSafeExternalMarkdownImageUrl(url) === EMPTY_IMAGE_DATA_URL;
}
/** Keep only navigable web/mail/anchor links and application-resolved relative paths. */
export function isSafeMarkdownLinkHref(href: string) {
const value = href.trim();
if (!value || hasControlCharacter(value) || value.startsWith('//')) {
return false;
}
if (value.startsWith('#') || /^mailto:/i.test(value)) return true;
if (/^https?:\/\//i.test(value)) {
try {
const parsed = new URL(value);
return !parsed.username && !parsed.password;
} catch {
return false;
}
}
return !/^[a-z][a-z\d+.-]*:/i.test(value);
}
function hasControlCharacter(value: string) {
return [...value].some((character) => {
const code = character.codePointAt(0) || 0;
return code < 32 || code === 127;
});
}
export { EMPTY_IMAGE_DATA_URL };

View File

@@ -0,0 +1,132 @@
import type { Editor } from '@milkdown/kit/core';
import { remarkStringifyOptionsCtx } from '@milkdown/kit/core';
import { $nodeSchema, $remark } from '@milkdown/kit/utils';
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
interface MdastNode {
children?: unknown[];
position?: {
end?: { offset?: number };
start?: { offset?: number };
};
type?: string;
value?: unknown;
}
interface MdastTree {
children?: MdastNode[];
}
interface MdastFile {
value?: unknown;
}
function getStringValue(node: unknown): string {
if (typeof node !== 'object' || node === null || !('value' in node)) {
return '';
}
return typeof node.value === 'string' ? node.value : '';
}
/**
* 自实现的 frontmatter remark 插件:在 mdast 阶段把文档开头的
* `---\n...\n---` 包装成 `yaml` 节点,避免默认 remark 把它解析成
* thematic break + 段落文本。
*
* 注意:`$remark` 工厂需要三层函数:`(ctx) => (processor) => (tree, file) => void`。
*/
export const remarkFrontmatterMilkdown = $remark(
'remarkFrontmatter',
() => () => (tree: MdastTree, file: MdastFile) => {
const value = String(file.value ?? '');
const match = FRONTMATTER_PATTERN.exec(value);
if (!match) return;
const endOffset = match[0].length;
const yamlNode = {
type: 'yaml',
value: match[1] || '',
};
const children = Array.isArray(tree.children) ? tree.children : [];
const filtered = children.filter((child) => {
const start = child.position?.start?.offset ?? Number.POSITIVE_INFINITY;
return start >= endOffset;
});
tree.children = [yamlNode as MdastNode, ...filtered];
},
);
/**
* 把 mdast `yaml` 节点映射为 ProseMirror 块节点,作为只读原子块渲染,
* 序列化时再写回 mdast `yaml` 节点。这样 SKILL.md 的 frontmatter 在实时
* 编辑模式下既能可视化呈现,又不会被 Milkdown 改写破坏结构。
*/
export const frontmatterSchema = $nodeSchema('frontmatter', () => ({
group: 'block',
atom: true,
isolating: true,
defining: true,
draggable: false,
allowGapCursor: false,
content: '',
attrs: {
value: { default: '' },
},
parseDOM: [
{
tag: 'div[data-frontmatter]',
getAttrs: (dom) => ({
value: (dom as HTMLElement).dataset.value || '',
}),
},
],
toDOM: (node) => [
'div',
{
'data-frontmatter': '',
'data-value': node.attrs.value,
contenteditable: 'false',
class: 'milkdown-frontmatter',
role: 'button',
tabindex: '0',
title: '点击进入源码模式编辑',
'aria-label': 'Skill 元数据,点击进入源码模式编辑',
},
['div', { class: 'milkdown-frontmatter__title' }, 'Skill 元数据'],
['pre', { class: 'milkdown-frontmatter__code' }, node.attrs.value],
],
parseMarkdown: {
match: (node) => node.type === 'yaml',
runner: (state, node, type) => {
state.addNode(type, { value: getStringValue(node) });
},
},
toMarkdown: {
match: (node) => node.type.name === 'frontmatter',
runner: (state, node) => {
state.addNode('yaml', undefined, node.attrs.value as string);
},
},
}));
/**
* 在 Crepe 创建前安装 frontmatter 支持。
*
* 在 {@link import('@milkdown/crepe').Crepe} 实例化后、`create()` 之前调用:
* `crepe.addFeature(installFrontmatter)`,或直接 `crepe.editor.use(...)`。
*/
export function installFrontmatter(editor: Editor) {
editor.config((ctx) => {
ctx.update(remarkStringifyOptionsCtx, (prev) => ({
...prev,
handlers: {
...(prev as { handlers?: Record<string, unknown> }).handlers,
yaml: (node: { value?: unknown }) =>
`---\n${typeof node.value === 'string' ? node.value : ''}\n---\n\n`,
},
}));
});
editor.use(remarkFrontmatterMilkdown);
editor.use(frontmatterSchema);
}

View File

@@ -0,0 +1,20 @@
export type EditorLanguage =
| 'javascript'
| 'json'
| 'markdown'
| 'python'
| 'shell'
| 'text';
export interface EditorCursorPosition {
column: number;
line: number;
}
export type EditorSaveState = 'dirty' | 'error' | 'saved' | 'saving';
/** Rich Markdown is disabled above this size to keep editing responsive. */
export const MAX_LIVE_MARKDOWN_CHARACTERS = 300_000;
/** Syntax parsing is disabled above this size while plain-text editing remains available. */
export const MAX_HIGHLIGHTED_TEXT_CHARACTERS = 800_000;

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@easyflow/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -21,6 +21,7 @@
}
},
"dependencies": {
"@easyflow-core/editor-ui": "workspace:*",
"@easyflow-core/form-ui": "workspace:*",
"@easyflow-core/popup-ui": "workspace:*",
"@easyflow-core/preferences": "workspace:*",

View File

@@ -0,0 +1,358 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { onKeyStroke } from '@vueuse/core';
const props = withDefaults(
defineProps<{
inspectorOpen?: boolean;
treeLabel?: string;
}>(),
{
inspectorOpen: false,
treeLabel: '文件',
},
);
const emit = defineEmits<{
closeInspector: [];
}>();
const mobileTreeOpen = ref(false);
const mobileTriggerRef = ref<HTMLButtonElement>();
const mobileCloseRef = ref<HTMLButtonElement>();
const inspectorCloseRef = ref<HTMLButtonElement>();
let inspectorReturnFocus: HTMLElement | undefined;
watch(mobileTreeOpen, async (open) => {
await nextTick();
if (open) mobileCloseRef.value?.focus();
else mobileTriggerRef.value?.focus();
});
watch(
() => props.inspectorOpen,
async (open) => {
if (open) {
inspectorReturnFocus = document.activeElement as HTMLElement;
await nextTick();
inspectorCloseRef.value?.focus();
} else {
inspectorReturnFocus?.focus();
inspectorReturnFocus = undefined;
}
},
);
onKeyStroke('Escape', () => {
if (mobileTreeOpen.value) {
mobileTreeOpen.value = false;
} else if (props.inspectorOpen) {
emit('closeInspector');
}
});
function trapMobileTreeFocus(event: KeyboardEvent) {
if (!mobileTreeOpen.value || event.key !== 'Tab') return;
const panel = mobileCloseRef.value?.closest<HTMLElement>(
'.document-editor-workbench__tree',
);
if (!panel) return;
const focusable = [
...panel.querySelectorAll<HTMLElement>(
'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])',
),
].filter((item) => item.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
onMounted(() => document.addEventListener('keydown', trapMobileTreeFocus));
onBeforeUnmount(() =>
document.removeEventListener('keydown', trapMobileTreeFocus),
);
</script>
<template>
<section class="document-editor-workbench">
<button
ref="mobileTriggerRef"
class="document-editor-workbench__mobile-trigger"
type="button"
@click="mobileTreeOpen = true"
>
{{ treeLabel }}
</button>
<aside
class="document-editor-workbench__tree"
:class="{ 'is-mobile-open': mobileTreeOpen }"
:aria-label="treeLabel"
:aria-modal="mobileTreeOpen || undefined"
:role="mobileTreeOpen ? 'dialog' : 'region'"
>
<div class="document-editor-workbench__tree-mobile-head">
<strong>{{ treeLabel }}</strong>
<button
ref="mobileCloseRef"
type="button"
aria-label="关闭文件列表"
@click="mobileTreeOpen = false"
>
关闭
</button>
</div>
<slot name="tree" :close="() => (mobileTreeOpen = false)"></slot>
</aside>
<button
v-if="mobileTreeOpen"
class="document-editor-workbench__scrim"
type="button"
aria-label="关闭文件列表"
@click="mobileTreeOpen = false"
></button>
<main
class="document-editor-workbench__main"
:aria-hidden="mobileTreeOpen || undefined"
:inert="mobileTreeOpen || undefined"
>
<div v-if="$slots.toolbar" class="document-editor-workbench__toolbar">
<slot name="toolbar"></slot>
</div>
<div class="document-editor-workbench__editor">
<slot></slot>
</div>
<slot name="status"></slot>
</main>
<Transition name="document-inspector">
<aside
v-if="inspectorOpen"
class="document-editor-workbench__inspector"
role="complementary"
aria-label="文档检查面板"
>
<button
ref="inspectorCloseRef"
class="document-editor-workbench__inspector-close"
type="button"
aria-label="关闭检查面板"
@click="emit('closeInspector')"
>
关闭
</button>
<slot name="inspector"></slot>
</aside>
</Transition>
</section>
</template>
<style scoped>
.document-editor-workbench {
position: relative;
display: grid;
flex: 1;
grid-template-columns: minmax(248px, 272px) minmax(0, 1fr);
min-width: 0;
min-height: 0;
overflow: hidden;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-panel);
box-shadow: var(--shadow-subtle);
}
.document-editor-workbench__tree {
min-width: 0;
min-height: 0;
overflow: hidden;
background: hsl(var(--surface-subtle));
border-right: 1px solid hsl(var(--line-subtle));
}
.document-editor-workbench__main {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.document-editor-workbench__toolbar {
z-index: 2;
min-height: 48px;
background: hsl(var(--toolbar-bg) / 92%);
border-bottom: 1px solid hsl(var(--toolbar-border));
backdrop-filter: blur(var(--glass-blur));
}
.document-editor-workbench__editor {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.document-editor-workbench__inspector {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 4;
width: min(360px, 88%);
overflow: auto;
background: hsl(var(--surface-elevated));
border-left: 1px solid hsl(var(--line-subtle));
box-shadow: var(--shadow-float);
}
.document-editor-workbench__inspector-close,
.document-editor-workbench__tree-mobile-head,
.document-editor-workbench__mobile-trigger,
.document-editor-workbench__scrim {
display: none;
}
.document-editor-workbench__inspector-close {
position: sticky;
top: var(--space-2);
z-index: 1;
float: right;
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 var(--space-3);
margin: var(--space-2);
color: hsl(var(--text-muted));
cursor: pointer;
background: hsl(var(--surface-subtle));
border: 0;
border-radius: var(--radius-control);
}
.document-editor-workbench__inspector-close:hover {
color: hsl(var(--foreground));
background: hsl(var(--nav-item-hover));
}
.document-editor-workbench__inspector-close:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.document-inspector-enter-active,
.document-inspector-leave-active {
transition:
opacity var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-medium) var(--motion-ease-standard);
}
.document-inspector-enter-from,
.document-inspector-leave-to {
opacity: 0;
transform: translateX(12px);
}
@media (max-width: 900px) {
.document-editor-workbench {
grid-template-columns: minmax(0, 1fr);
}
.document-editor-workbench__mobile-trigger {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: 3;
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 var(--space-3);
color: hsl(var(--nav-item-active-foreground));
cursor: pointer;
background: hsl(var(--nav-item-active));
border: 0;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.document-editor-workbench__mobile-trigger:hover {
color: hsl(var(--foreground));
background: hsl(var(--nav-tool-hover));
}
.document-editor-workbench__mobile-trigger:focus-visible,
.document-editor-workbench__tree-mobile-head button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.document-editor-workbench__tree {
position: absolute;
inset: 0 auto 0 0;
z-index: 6;
visibility: hidden;
width: min(320px, 88%);
box-shadow: var(--shadow-float);
opacity: 0;
transform: translateX(-12px);
transition:
visibility 0s linear var(--motion-duration-medium),
opacity var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-medium) var(--motion-ease-standard);
}
.document-editor-workbench__tree.is-mobile-open {
visibility: visible;
opacity: 1;
transform: translateX(0);
transition-delay: 0s;
}
.document-editor-workbench__tree-mobile-head {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 var(--space-4);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.document-editor-workbench__tree-mobile-head button {
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
}
.document-editor-workbench__scrim {
position: absolute;
inset: 0;
z-index: 5;
display: block;
cursor: pointer;
background: hsl(var(--overlay));
border: 0;
}
.document-editor-workbench__toolbar {
padding-left: calc(var(--space-8) * 3 + var(--space-2));
}
}
@media (prefers-reduced-motion: reduce) {
.document-editor-workbench__tree,
.document-inspector-enter-active,
.document-inspector-leave-active {
transition-duration: 0.01ms;
}
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { EditorSaveState } from '@easyflow-core/editor-ui';
withDefaults(
defineProps<{
column?: number;
encoding?: string;
language?: string;
line?: number;
lines?: number;
mode?: 'code' | 'document';
saveState?: EditorSaveState;
sizeLabel?: string;
wordCount?: number;
}>(),
{
column: 1,
encoding: 'utf8',
language: 'Text',
line: 1,
lines: 1,
mode: 'code',
saveState: 'saved',
sizeLabel: '0 B',
wordCount: 0,
},
);
const saveLabels: Record<EditorSaveState, string> = {
dirty: '未保存',
error: '保存失败',
saved: '已保存',
saving: '保存中',
};
</script>
<template>
<footer class="editor-status-bar" aria-live="polite">
<div v-if="mode === 'code'">
<span>{{ language }}</span>
<span>{{ encoding }}</span>
<span>{{ sizeLabel }}</span>
</div>
<div v-else>
<span>{{ wordCount }} </span>
</div>
<div>
<span v-if="mode === 'code'"> {{ line }} {{ column }}</span>
<span v-if="mode === 'code'">{{ lines }} </span>
<span class="editor-status-bar__save" :class="`is-${saveState}`">
{{ saveLabels[saveState] }}
</span>
</div>
</footer>
</template>
<style scoped>
.editor-status-bar {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
min-height: 30px;
padding: 0 12px;
font-size: 11px;
color: hsl(var(--text-muted));
background: hsl(var(--surface-subtle));
border-top: 1px solid hsl(var(--line-subtle));
}
.editor-status-bar > div {
display: flex;
gap: 12px;
}
.editor-status-bar__save.is-dirty,
.editor-status-bar__save.is-saving {
color: hsl(var(--warning));
}
.editor-status-bar__save.is-error {
color: hsl(var(--destructive));
}
@media (max-width: 640px) {
.editor-status-bar > div:first-child span:not(:first-child),
.editor-status-bar > div:last-child span:nth-child(2) {
display: none;
}
}
</style>

View File

@@ -0,0 +1,475 @@
<script setup lang="ts">
import type { DocumentFileNode } from './types';
import { computed, ref } from 'vue';
import {
ChevronRight,
File,
FileCode2,
FileImage,
FileText,
FolderClosed,
FolderOpen,
Ellipsis,
Plus,
} from '@easyflow/icons';
import { onClickOutside } from '@vueuse/core';
const props = defineProps<{
currentPath?: string;
dirtyPaths?: Set<string>;
errorCounts?: Record<string, number>;
focusedPath?: string;
level?: number;
node: DocumentFileNode;
readonly?: boolean;
warningCounts?: Record<string, number>;
}>();
const emit = defineEmits<{
create: [node: DocumentFileNode];
download: [node: DocumentFileNode];
focusPath: [path: string];
remove: [node: DocumentFileNode];
rename: [node: DocumentFileNode];
select: [node: DocumentFileNode];
upload: [node: DocumentFileNode];
}>();
const expanded = ref(true);
const menuOpen = ref(false);
const menuRef = ref<HTMLElement>();
const isDirectory = computed(() => props.node.type === 'DIRECTORY');
const isRootSkill = computed(() => props.node.path === 'SKILL.md');
const isCurrent = computed(() => props.node.path === props.currentPath);
const isFocusTarget = computed(
() =>
props.node.path === props.focusedPath ||
(isDirectory.value &&
!expanded.value &&
Boolean(props.focusedPath?.startsWith(`${props.node.path}/`))),
);
const icon = computed(() => {
if (isDirectory.value) return expanded.value ? FolderOpen : FolderClosed;
const extension = props.node.name.split('.').pop()?.toLowerCase();
if (props.node.name === 'SKILL.md' || extension === 'md') return FileText;
if (['js', 'py', 'sh'].includes(extension || '')) return FileCode2;
if (['gif', 'jpeg', 'jpg', 'png', 'webp'].includes(extension || ''))
return FileImage;
return File;
});
onClickOutside(menuRef, () => {
menuOpen.value = false;
});
function activate() {
emit('focusPath', props.node.path);
if (isDirectory.value) {
expanded.value = !expanded.value;
}
emit('select', props.node);
}
function runAction(action: 'download' | 'remove' | 'rename') {
menuOpen.value = false;
if (action === 'download') emit('download', props.node);
else if (action === 'remove') emit('remove', props.node);
else emit('rename', props.node);
}
function focusTreeItem(target?: HTMLElement | null) {
if (!target) return;
const tree = target.closest<HTMLElement>('[role="tree"]');
tree
?.querySelectorAll<HTMLElement>('[role="treeitem"]')
.forEach((item) => (item.tabIndex = -1));
target.tabIndex = 0;
emit('focusPath', target.dataset.treePath || '');
target.focus();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight' && isDirectory.value) {
event.preventDefault();
if (!expanded.value) {
expanded.value = true;
emit('focusPath', props.node.path);
return;
}
const row = event.currentTarget as HTMLElement;
const nodeItem = row.closest('.file-tree-node');
const group = [...(nodeItem?.children || [])].find(
(element) => element.getAttribute('role') === 'group',
);
const firstNode = group?.children.item(0);
const firstChild =
firstNode?.querySelector<HTMLElement>('[role="treeitem"]');
focusTreeItem(firstChild);
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
if (isDirectory.value && expanded.value) {
expanded.value = false;
emit('focusPath', props.node.path);
return;
}
const row = event.currentTarget as HTMLElement;
const nodeItem = row.closest('.file-tree-node');
const parentNode = nodeItem?.parentElement?.closest('.file-tree-node');
const parentRow = [...(parentNode?.children || [])].find((element) =>
element.classList.contains('file-tree-node__row-wrap'),
);
const parentItem =
parentRow?.querySelector<HTMLElement>('[role="treeitem"]');
focusTreeItem(parentItem);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
activate();
}
}
</script>
<template>
<li class="file-tree-node" role="none">
<div
class="file-tree-node__row-wrap"
:style="{ '--tree-level': level || 0 }"
>
<button
type="button"
role="treeitem"
class="file-tree-node__row"
:class="{ 'is-current': isCurrent }"
:data-tree-path="node.path"
:aria-current="isCurrent ? 'page' : undefined"
:aria-expanded="isDirectory ? expanded : undefined"
:aria-level="(level || 0) + 1"
:aria-selected="isCurrent"
:tabindex="isFocusTarget ? 0 : -1"
@click="activate"
@focus="emit('focusPath', node.path)"
@keydown="handleKeydown"
>
<ChevronRight
v-if="isDirectory"
class="file-tree-node__chevron"
:class="{ 'is-expanded': expanded }"
:size="14"
aria-hidden="true"
/>
<component
:is="icon"
class="file-tree-node__icon"
:size="15"
aria-hidden="true"
/>
<span class="file-tree-node__name">{{ node.name }}</span>
<span
v-if="dirtyPaths?.has(node.path)"
class="file-tree-node__dirty"
title="未保存"
>
<span aria-hidden="true"></span>
<span class="file-tree-node__sr-only">未保存</span>
</span>
<span
v-if="errorCounts?.[node.path]"
class="file-tree-node__count is-error"
:aria-label="`${errorCounts[node.path]} 个错误`"
>
{{ errorCounts[node.path] }}
</span>
<span
v-else-if="warningCounts?.[node.path]"
class="file-tree-node__count is-warning"
:aria-label="`${warningCounts[node.path]} 个警告`"
>
{{ warningCounts[node.path] }}
</span>
</button>
<div class="file-tree-node__actions">
<button
v-if="isDirectory && !readonly"
type="button"
aria-label="在此目录新建文件"
title="在此目录新建文件"
@click.stop="emit('create', node)"
>
<Plus :size="14" aria-hidden="true" />
</button>
<div
v-if="!isDirectory || !readonly"
ref="menuRef"
class="file-tree-node__menu-wrap"
>
<button
type="button"
aria-label="更多操作"
title="更多操作"
aria-haspopup="menu"
:aria-expanded="menuOpen"
@click.stop="menuOpen = !menuOpen"
>
<Ellipsis :size="14" aria-hidden="true" />
</button>
<div v-if="menuOpen" class="file-tree-node__menu" role="menu">
<button
v-if="isDirectory && !readonly"
type="button"
role="menuitem"
@click="
menuOpen = false;
emit('upload', node);
"
>
上传资源
</button>
<button
v-if="!isDirectory"
type="button"
role="menuitem"
@click="runAction('download')"
>
下载
</button>
<template v-if="!readonly && !isDirectory && !isRootSkill">
<button
type="button"
role="menuitem"
@click="runAction('rename')"
>
重命名
</button>
<button
type="button"
role="menuitem"
class="is-danger"
@click="runAction('remove')"
>
删除
</button>
</template>
</div>
</div>
</div>
</div>
<ul
v-if="isDirectory && expanded && node.children?.length"
class="file-tree-node__children"
role="group"
>
<FileTreeNode
v-for="child in node.children"
:key="child.path"
:node="child"
:level="(level || 0) + 1"
:current-path="currentPath"
:focused-path="focusedPath"
:dirty-paths="dirtyPaths"
:error-counts="errorCounts"
:warning-counts="warningCounts"
:readonly="readonly"
@create="emit('create', $event)"
@download="emit('download', $event)"
@focus-path="emit('focusPath', $event)"
@remove="emit('remove', $event)"
@rename="emit('rename', $event)"
@select="emit('select', $event)"
@upload="emit('upload', $event)"
/>
</ul>
</li>
</template>
<style scoped>
.file-tree-node,
.file-tree-node__children {
padding: 0;
margin: 0;
list-style: none;
}
.file-tree-node__row-wrap {
position: relative;
display: flex;
align-items: center;
width: calc(100% - 16px);
margin: 1px 8px;
border-radius: var(--radius-control);
}
.file-tree-node__row {
display: flex;
gap: var(--space-2);
align-items: center;
width: 100%;
min-height: 36px;
padding: 0 var(--radius-control) 0
calc(var(--radius-control) + var(--tree-level) * 14px);
margin: 0;
color: hsl(var(--nav-item-muted-foreground));
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-fast) var(--motion-ease-standard),
background-color var(--motion-duration-fast) var(--motion-ease-standard);
}
.file-tree-node__row-wrap:hover .file-tree-node__row {
color: hsl(var(--foreground));
background: hsl(var(--nav-item-hover));
}
.file-tree-node__row-wrap:hover .file-tree-node__row,
.file-tree-node__row-wrap:focus-within .file-tree-node__row {
padding-right: 66px;
}
.file-tree-node__row.is-current {
font-weight: 600;
color: hsl(var(--nav-item-active-foreground));
background: hsl(var(--nav-item-active));
}
.file-tree-node__row:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 1px;
}
.file-tree-node__icon {
flex: none;
width: 15px;
color: hsl(var(--text-muted));
}
.file-tree-node__chevron {
flex: none;
color: hsl(var(--text-muted));
transition: transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.file-tree-node__chevron.is-expanded {
transform: rotate(90deg);
}
.file-tree-node__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-tree-node__actions {
position: absolute;
right: var(--space-1);
display: flex;
gap: 2px;
visibility: hidden;
opacity: 0;
}
.file-tree-node__row-wrap:hover .file-tree-node__actions,
.file-tree-node__row-wrap:focus-within .file-tree-node__actions {
visibility: visible;
opacity: 1;
}
.file-tree-node__actions button {
display: grid;
place-items: center;
min-width: 26px;
height: 26px;
padding: 0 var(--space-1);
color: hsl(var(--text-muted));
cursor: pointer;
background: hsl(var(--surface-elevated) / 92%);
border: 0;
border-radius: var(--radius-control);
}
.file-tree-node__actions button:hover {
color: hsl(var(--foreground));
background: hsl(var(--nav-tool-hover));
}
.file-tree-node__actions button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 1px;
}
.file-tree-node__menu-wrap {
position: relative;
}
.file-tree-node__menu {
position: absolute;
top: calc(100% + 2px);
right: 0;
z-index: 10;
display: grid;
width: 112px;
padding: var(--space-1);
background: hsl(var(--surface-elevated));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-control);
box-shadow: var(--shadow-float);
}
.file-tree-node__menu button {
display: flex;
justify-content: flex-start;
width: 100%;
padding: 0 var(--space-2);
}
.file-tree-node__menu button.is-danger {
color: hsl(var(--destructive));
}
.file-tree-node__dirty {
font-size: 8px;
color: hsl(var(--warning));
}
.file-tree-node__sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
white-space: nowrap;
border: 0;
clip: rect(0, 0, 0, 0);
}
.file-tree-node__count {
display: inline-grid;
place-items: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-size: 11px;
border-radius: 999px;
}
.file-tree-node__count.is-error {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 12%);
}
.file-tree-node__count.is-warning {
color: hsl(var(--warning));
background: hsl(var(--warning) / 14%);
}
</style>

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import type { DocumentFileNode } from './types';
import { computed, ref, watch } from 'vue';
import { Plus, Upload } from '@easyflow/icons';
import { onClickOutside } from '@vueuse/core';
import FileTreeNode from './FileTreeNode.vue';
const props = withDefaults(
defineProps<{
busy?: boolean;
currentPath?: string;
dirtyPaths?: Set<string>;
errorCounts?: Record<string, number>;
nodes: DocumentFileNode[];
readonly?: boolean;
title?: string;
warningCounts?: Record<string, number>;
}>(),
{
busy: false,
currentPath: '',
dirtyPaths: () => new Set<string>(),
errorCounts: () => ({}),
readonly: false,
title: 'Skill 文件',
warningCounts: () => ({}),
},
);
const emit = defineEmits<{
create: [node?: DocumentFileNode];
download: [node: DocumentFileNode];
remove: [node: DocumentFileNode];
rename: [node: DocumentFileNode];
select: [node: DocumentFileNode];
upload: [node?: DocumentFileNode];
}>();
const effectiveCurrentPath = computed(
() => props.currentPath || props.nodes[0]?.path || '',
);
const focusedPath = ref('');
const createMenuOpen = ref(false);
const createMenuRef = ref<HTMLElement>();
onClickOutside(createMenuRef, () => {
createMenuOpen.value = false;
});
watch(
effectiveCurrentPath,
(path) => {
focusedPath.value = path;
},
{ immediate: true },
);
function focusTreeItem(items: HTMLElement[], index: number) {
const target = items[index];
if (!target) return;
items.forEach((item) => (item.tabIndex = -1));
target.tabIndex = 0;
focusedPath.value = target.dataset.treePath || '';
target.focus();
}
function handleTreeKeydown(event: KeyboardEvent) {
if (!['ArrowDown', 'ArrowUp', 'End', 'Home'].includes(event.key)) return;
const tree = event.currentTarget as HTMLElement;
const items = [...tree.querySelectorAll<HTMLElement>('[role="treeitem"]')];
if (items.length === 0) return;
const current = (event.target as HTMLElement).closest<HTMLElement>(
'[role="treeitem"]',
);
const currentIndex = current ? items.indexOf(current) : -1;
let nextIndex = currentIndex;
if (event.key === 'ArrowDown')
nextIndex = Math.min(currentIndex + 1, items.length - 1);
if (event.key === 'ArrowUp')
nextIndex =
currentIndex < 0 ? items.length - 1 : Math.max(currentIndex - 1, 0);
if (event.key === 'Home') nextIndex = 0;
if (event.key === 'End') nextIndex = items.length - 1;
if (nextIndex >= 0) {
event.preventDefault();
focusTreeItem(items, nextIndex);
}
}
function runHeaderAction(action: 'create' | 'upload') {
createMenuOpen.value = false;
if (action === 'create') emit('create');
else emit('upload');
}
</script>
<template>
<section class="file-tree-panel">
<header class="file-tree-panel__header">
<strong>{{ title }}</strong>
<div
v-if="!readonly"
ref="createMenuRef"
class="file-tree-panel__actions"
>
<button
type="button"
aria-label="添加资源"
title="添加资源"
:disabled="busy"
:aria-expanded="createMenuOpen"
aria-haspopup="menu"
@click="createMenuOpen = !createMenuOpen"
>
<Plus :size="15" aria-hidden="true" />
</button>
<div v-if="createMenuOpen" class="file-tree-panel__menu" role="menu">
<button
type="button"
role="menuitem"
@click="runHeaderAction('create')"
>
<Plus :size="15" aria-hidden="true" />
新建文件
</button>
<button
type="button"
role="menuitem"
@click="runHeaderAction('upload')"
>
<Upload :size="15" aria-hidden="true" />
上传资源
</button>
</div>
</div>
</header>
<nav class="file-tree-panel__body" aria-label="Skill 文件树">
<ul
v-if="nodes.length > 0"
class="file-tree-panel__list"
role="tree"
aria-label="Skill 文件"
@keydown="handleTreeKeydown"
>
<FileTreeNode
v-for="node in nodes"
:key="node.path"
:node="node"
:current-path="effectiveCurrentPath"
:focused-path="focusedPath"
:dirty-paths="dirtyPaths"
:error-counts="errorCounts"
:warning-counts="warningCounts"
:readonly="readonly"
@create="emit('create', $event)"
@download="emit('download', $event)"
@focus-path="focusedPath = $event"
@remove="emit('remove', $event)"
@rename="emit('rename', $event)"
@select="emit('select', $event)"
@upload="emit('upload', $event)"
/>
</ul>
<div v-else class="file-tree-panel__empty">暂无文件</div>
</nav>
</section>
</template>
<style scoped>
.file-tree-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.file-tree-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0 var(--space-4);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.file-tree-panel__actions {
position: relative;
display: flex;
gap: var(--space-1);
}
.file-tree-panel__actions button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
}
.file-tree-panel__actions button:hover {
color: hsl(var(--foreground));
background: hsl(var(--nav-item-hover));
}
.file-tree-panel__actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.file-tree-panel__actions button:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 1px;
}
.file-tree-panel__menu {
position: absolute;
top: calc(100% + var(--space-1));
right: 0;
z-index: 8;
display: grid;
width: 136px;
padding: var(--space-1);
background: hsl(var(--surface-elevated));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-control);
box-shadow: var(--shadow-float);
}
.file-tree-panel__menu button {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: flex-start;
width: 100%;
padding: 0 var(--space-2);
}
.file-tree-panel__body {
flex: 1;
min-height: 0;
padding: var(--space-2) 0;
overflow: auto;
}
.file-tree-panel__list {
padding: 0;
margin: 0;
}
.file-tree-panel__empty {
padding: var(--space-8) var(--space-4);
color: hsl(var(--text-muted));
text-align: center;
}
</style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import type { DocumentValidationIssue } from './types';
import { computed } from 'vue';
const props = defineProps<{
issues: DocumentValidationIssue[];
}>();
const emit = defineEmits<{
locate: [issue: DocumentValidationIssue];
}>();
const errorCount = computed(
() => props.issues.filter((issue) => issue.severity === 'ERROR').length,
);
const warningCount = computed(
() => props.issues.filter((issue) => issue.severity === 'WARNING').length,
);
</script>
<template>
<section class="validation-panel">
<header class="validation-panel__header">
<div>
<h3>校验结果</h3>
<p>{{ errorCount }} 个错误 · {{ warningCount }} 个警告</p>
</div>
</header>
<div v-if="issues.length > 0" class="validation-panel__list">
<button
v-for="(issue, index) in issues"
:key="`${issue.path}-${issue.line}-${issue.code}-${index}`"
type="button"
class="validation-panel__item"
:class="`is-${issue.severity.toLowerCase()}`"
@click="emit('locate', issue)"
>
<span class="validation-panel__severity">
{{
issue.severity === 'ERROR'
? '错误'
: issue.severity === 'WARNING'
? '警告'
: '提示'
}}
</span>
<strong>{{ issue.message }}</strong>
<small v-if="issue.path">
{{ issue.path
}}<template v-if="issue.line">:{{ issue.line }}</template>
</small>
<span v-if="issue.suggestion" class="validation-panel__suggestion">
建议{{ issue.suggestion }}
</span>
</button>
</div>
<div v-else class="validation-panel__empty">
<strong>校验通过</strong>
<span>未发现需要处理的问题</span>
</div>
</section>
</template>
<style scoped>
.validation-panel {
min-height: 100%;
}
.validation-panel__header {
padding: var(--space-6);
border-bottom: 1px solid hsl(var(--line-subtle));
}
.validation-panel__header h3,
.validation-panel__header p {
margin: 0;
}
.validation-panel__header p {
margin-top: var(--space-1);
font-size: 13px;
color: hsl(var(--text-muted));
}
.validation-panel__list {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.validation-panel__item {
display: grid;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
color: hsl(var(--foreground));
text-align: left;
cursor: pointer;
background: hsl(var(--surface-panel));
border: 1px solid hsl(var(--line-subtle));
border-radius: var(--radius-toolbar);
}
.validation-panel__item:hover {
border-color: hsl(var(--primary) / 42%);
}
.validation-panel__item:focus-visible {
outline: 2px solid hsl(var(--primary) / 72%);
outline-offset: 2px;
}
.validation-panel__severity {
width: fit-content;
padding: 2px 7px;
font-size: 11px;
border-radius: var(--radius-pill, 999px);
}
.validation-panel__item.is-error .validation-panel__severity {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 12%);
}
.validation-panel__item.is-warning .validation-panel__severity {
color: hsl(var(--warning));
background: hsl(var(--warning) / 14%);
}
.validation-panel__item small {
color: hsl(var(--text-muted));
}
.validation-panel__suggestion {
font-size: 12px;
line-height: 1.55;
color: hsl(var(--nav-item-active-foreground));
}
.validation-panel__empty {
display: grid;
gap: var(--space-2);
padding: calc(var(--space-6) * 2) var(--space-6);
color: hsl(var(--text-muted));
text-align: center;
}
.validation-panel__empty strong {
color: hsl(var(--foreground));
}
</style>

View File

@@ -0,0 +1,200 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { afterEach, describe, expect, it } from 'vitest';
import FileTreePanel from '../FileTreePanel.vue';
const nodes = [
{ name: 'SKILL.md', path: 'SKILL.md', type: 'SKILL' },
{
children: [
{ name: 'guide.md', path: 'references/guide.md', type: 'REFERENCE' },
{
children: [
{
name: 'deep.md',
path: 'references/nested/deep.md',
type: 'REFERENCE',
},
],
name: 'nested',
path: 'references/nested',
type: 'DIRECTORY',
},
],
name: 'references',
path: 'references',
type: 'DIRECTORY',
},
{
children: [{ name: 'run.py', path: 'scripts/run.py', type: 'SCRIPT' }],
name: 'scripts',
path: 'scripts',
type: 'DIRECTORY',
},
];
const wrappers: Array<ReturnType<typeof mount>> = [];
function mountTree(currentPath = 'SKILL.md') {
const wrapper = mount(FileTreePanel, {
attachTo: document.body,
props: { currentPath, nodes },
});
wrappers.push(wrapper);
return wrapper;
}
function treeItem(path: string) {
return document.querySelector<HTMLElement>(
`[role="treeitem"][data-tree-path="${path}"]`,
);
}
function expectFocused(path: string) {
const target = treeItem(path);
expect(document.activeElement).toBe(target);
expect(target?.tabIndex).toBe(0);
expect(
document.querySelectorAll('[role="treeitem"][tabindex="0"]'),
).toHaveLength(1);
}
afterEach(() => {
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
document.body.innerHTML = '';
});
describe('file tree panel keyboard navigation', () => {
it('moves through every visible node with ArrowUp, ArrowDown, Home and End', async () => {
mountTree();
treeItem('SKILL.md')?.focus();
await treeItem('SKILL.md')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowDown' }),
);
expectFocused('references');
treeItem('scripts/run.py')?.focus();
treeItem('scripts/run.py')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Home' }),
);
expectFocused('SKILL.md');
treeItem('SKILL.md')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'End' }),
);
expectFocused('scripts/run.py');
treeItem('scripts/run.py')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowUp' }),
);
expectFocused('scripts');
});
it('opens, enters, leaves and closes nested directories with ArrowRight and ArrowLeft', async () => {
mountTree();
treeItem('references')?.focus();
treeItem('references')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
);
expectFocused('references/guide.md');
treeItem('references/nested')?.focus();
treeItem('references/nested')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
);
expectFocused('references/nested/deep.md');
treeItem('references/nested/deep.md')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }),
);
expectFocused('references/nested');
treeItem('references/nested')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowLeft' }),
);
await Promise.resolve();
expect(treeItem('references/nested')?.getAttribute('aria-expanded')).toBe(
'false',
);
expect(treeItem('references/nested/deep.md')).toBeNull();
expectFocused('references/nested');
treeItem('references/nested')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
);
await Promise.resolve();
expect(treeItem('references/nested')?.getAttribute('aria-expanded')).toBe(
'true',
);
expectFocused('references/nested');
treeItem('references/nested')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }),
);
expectFocused('references/nested/deep.md');
});
it('selects files and directories with Enter or Space', async () => {
const wrapper = mountTree();
treeItem('references/guide.md')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
);
expect(wrapper.emitted('select')?.[0]?.[0]).toMatchObject({
path: 'references/guide.md',
});
treeItem('references')?.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: ' ' }),
);
await nextTick();
expect(treeItem('references')?.getAttribute('aria-expanded')).toBe('false');
expect(wrapper.emitted('select')).toHaveLength(2);
expect(wrapper.emitted('select')?.[1]?.[0]).toMatchObject({
path: 'references',
});
});
it('emits contextual create and file operations from row actions', async () => {
const wrapper = mountTree();
const referenceRow = treeItem('references')?.parentElement;
await referenceRow
?.querySelector<HTMLButtonElement>('[aria-label="在此目录新建文件"]')
?.click();
expect(wrapper.emitted('create')?.[0]?.[0]).toMatchObject({
path: 'references',
});
const guideRow = treeItem('references/guide.md')?.parentElement;
guideRow
?.querySelector<HTMLButtonElement>('[aria-label="更多操作"]')
?.click();
await nextTick();
const download = [...(guideRow?.querySelectorAll('button') || [])].find(
(button) => button.textContent?.trim() === '下载',
);
download?.click();
expect(wrapper.emitted('download')?.[0]?.[0]).toMatchObject({
path: 'references/guide.md',
});
});
it('updates the single tab stop when the selected path changes', async () => {
const wrapper = mountTree();
await wrapper.setProps({ currentPath: 'scripts/run.py' });
expect(treeItem('scripts/run.py')?.tabIndex).toBe(0);
expect(
document.querySelectorAll('[role="treeitem"][tabindex="0"]'),
).toHaveLength(1);
expect(treeItem('scripts/run.py')?.getAttribute('aria-selected')).toBe(
'true',
);
});
});

View File

@@ -0,0 +1,5 @@
export { default as DocumentEditorWorkbench } from './DocumentEditorWorkbench.vue';
export { default as EditorStatusBar } from './EditorStatusBar.vue';
export { default as FileTreePanel } from './FileTreePanel.vue';
export type * from './types';
export { default as ValidationPanel } from './ValidationPanel.vue';

View File

@@ -0,0 +1,17 @@
export interface DocumentFileNode {
children?: DocumentFileNode[];
key?: string;
name: string;
path: string;
type: string;
}
export interface DocumentValidationIssue {
code?: string;
column?: number;
line?: number;
message: string;
path?: string;
severity: 'ERROR' | 'INFO' | 'WARNING' | string;
suggestion?: string;
}

View File

@@ -6,6 +6,7 @@ export * from './chat-thinking';
export * from './chat-timeline';
export * from './col-page';
export * from './count-to';
export * from './document-editor';
export * from './ellipsis-text';
export * from './icon-picker';
export * from './json-viewer';

File diff suppressed because it is too large Load Diff