feat: 重构 Skill 管理与编辑工作台

- 统一列表、分类、新建与详情页的产品交互

- 提供 Markdown 实时编辑、源码与脚本资源工作台

- 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
2026-07-27 18:54:57 +08:00
parent 2a9e882ac6
commit 0dc5c3ca55
78 changed files with 16413 additions and 799 deletions

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,22 +40,20 @@ const pageInfo = reactive({
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
const doGet = async (params: Record<string, any>) => {
loading.value = true;
try {
// 这里替换为你的实际 API 调用
// 例如return await api.get(props.pageUrl, { params })
const response = await props.requestClient.get(`${props.pageUrl}`, {
params,
});
const data = await response.data;
return { data };
} finally {
loading.value = false;
}
// 这里替换为你的实际 API 调用
// 例如return await api.get(props.pageUrl, { params })
const response = await props.requestClient.get(`${props.pageUrl}`, {
params,
});
const data = await response.data;
return { data };
};
// 获取页面数据
const getPageList = async () => {
const request = ++pageRequest;
loading.value = true;
loadError.value = undefined;
try {
const res = await doGet({
pageNumber: pageInfo.pageNumber,
@@ -61,12 +61,18 @@ const getPageList = async () => {
...props.extraQueryParams,
...queryParams.value,
});
pageList.value = res.data?.records || [];
pageInfo.total = res.data?.totalRow || 0;
if (request === pageRequest) {
pageList.value = res.data?.records || [];
pageInfo.total = res.data?.totalRow || 0;
}
} catch (error) {
console.error('get data error:', error);
pageList.value = [];
pageInfo.total = 0;
if (request === pageRequest) {
loadError.value = error;
pageList.value = [];
pageInfo.total = 0;
}
} finally {
if (request === pageRequest) loading.value = false;
}
};
@@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record<string, any>) => {
pageInfo.pageNumber = 1;
pageInfo.pageSize = props.pageSize;
queryParams.value = newQueryParams;
getPageList();
};
// 暴露方法给父组件
@@ -116,7 +121,7 @@ defineExpose({
// 监听器
watch(
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
[() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value],
() => {
getPageList();
},
@@ -150,6 +155,16 @@ onMounted(() => {
/>
</div>
</template>
<slot
v-else-if="loadError"
name="error"
:error="loadError"
:retry="getPageList"
>
<ElEmpty description="数据加载失败,请重试">
<ElButton type="primary" @click="getPageList">重新加载</ElButton>
</ElEmpty>
</slot>
<slot v-else name="empty">
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
</slot>

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