feat: 重构 Skill 管理与编辑工作台
- 统一列表、分类、新建与详情页的产品交互 - 提供 Markdown 实时编辑、源码与脚本资源工作台 - 实现能力自动保存、导入导出及完整状态反馈
This commit is contained in:
28
easyflow-ui-admin/app/src/components/page/PageData.test.ts
Normal file
28
easyflow-ui-admin/app/src/components/page/PageData.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageData from './PageData.vue';
|
||||
|
||||
describe('page data recovery', () => {
|
||||
it('shows a retry action after a page request fails', async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce({ data: { records: [], totalRow: 0 } });
|
||||
const wrapper = mount(PageData, {
|
||||
global: {
|
||||
directives: { loading: {} },
|
||||
},
|
||||
props: { pageUrl: '/page', requestClient: { get } },
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('数据加载失败,请重试');
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
await flushPromises();
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(wrapper.text()).not.toContain('数据加载失败,请重试');
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElEmpty, ElPagination } from 'element-plus';
|
||||
import { ElButton, ElEmpty, ElPagination } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
@@ -28,7 +28,9 @@ const props = withDefaults(defineProps<PageDataProps>(), {
|
||||
// 响应式数据
|
||||
const pageList = ref<PageDataRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref<unknown>();
|
||||
const queryParams = ref<Record<string, any>>({});
|
||||
let pageRequest = 0;
|
||||
|
||||
const pageInfo = reactive({
|
||||
pageNumber: 1,
|
||||
@@ -38,22 +40,20 @@ const pageInfo = reactive({
|
||||
|
||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||
const doGet = async (params: Record<string, any>) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await props.requestClient.get(`${props.pageUrl}`, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
};
|
||||
|
||||
// 获取页面数据
|
||||
const getPageList = async () => {
|
||||
const request = ++pageRequest;
|
||||
loading.value = true;
|
||||
loadError.value = undefined;
|
||||
try {
|
||||
const res = await doGet({
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
@@ -61,12 +61,18 @@ const getPageList = async () => {
|
||||
...props.extraQueryParams,
|
||||
...queryParams.value,
|
||||
});
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
if (request === pageRequest) {
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get data error:', error);
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
if (request === pageRequest) {
|
||||
loadError.value = error;
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
}
|
||||
} finally {
|
||||
if (request === pageRequest) loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -104,7 +110,6 @@ const setQuery = (newQueryParams: Record<string, any>) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
pageInfo.pageSize = props.pageSize;
|
||||
queryParams.value = newQueryParams;
|
||||
getPageList();
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
@@ -116,7 +121,7 @@ defineExpose({
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize, () => queryParams.value],
|
||||
() => {
|
||||
getPageList();
|
||||
},
|
||||
@@ -150,6 +155,16 @@ onMounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<slot
|
||||
v-else-if="loadError"
|
||||
name="error"
|
||||
:error="loadError"
|
||||
:retry="getPageList"
|
||||
>
|
||||
<ElEmpty description="数据加载失败,请重试">
|
||||
<ElButton type="primary" @click="getPageList">重新加载</ElButton>
|
||||
</ElEmpty>
|
||||
</slot>
|
||||
<slot v-else name="empty">
|
||||
<ElEmpty :image="getEmptyStateImageUrl(preferences.theme.mode)" />
|
||||
</slot>
|
||||
|
||||
141
easyflow-ui-admin/app/src/components/page/PageSide.test.ts
Normal file
141
easyflow-ui-admin/app/src/components/page/PageSide.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import PageSide from './PageSide.vue';
|
||||
|
||||
/* eslint-disable vue/one-component-per-file -- Inline dropdown stubs keep this component test focused. */
|
||||
|
||||
const DropdownStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () =>
|
||||
h('div', { class: 'dropdown-stub' }, [
|
||||
slots.default?.(),
|
||||
slots.dropdown?.(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const DropdownMenuStub = defineComponent({
|
||||
setup(_, { slots }) {
|
||||
return () => h('div', { class: 'dropdown-menu-stub' }, slots.default?.());
|
||||
},
|
||||
});
|
||||
const DropdownItemStub = defineComponent({
|
||||
props: { disabled: Boolean },
|
||||
emits: ['click'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
class: 'dropdown-item-stub',
|
||||
'data-disabled': String(props.disabled),
|
||||
onClick: () => {
|
||||
if (!props.disabled) emit('click');
|
||||
},
|
||||
},
|
||||
slots.default?.(),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
describe('page side', () => {
|
||||
it('keeps the existing flat menu selection and footer behavior', async () => {
|
||||
const add = vi.fn();
|
||||
const wrapper = mount(PageSide, {
|
||||
props: {
|
||||
defaultSelected: '',
|
||||
footerButton: { label: '添加', onClick: add },
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{ id: 'docs', name: '文档' },
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.findAll('.page-side__item')).toHaveLength(2);
|
||||
expect(wrapper.findAll('.page-side__item')[0]?.classes()).toContain(
|
||||
'is-selected',
|
||||
);
|
||||
|
||||
await wrapper.findAll('.page-side__item')[1]?.trigger('click');
|
||||
expect(wrapper.emitted('change')?.[0]?.[0]).toMatchObject({ id: 'docs' });
|
||||
|
||||
await wrapper.get('.page-side__footer').trigger('click');
|
||||
expect(add).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders nested menus and emits the selected child', async () => {
|
||||
const wrapper = mount(PageSide, {
|
||||
props: {
|
||||
childrenKey: 'children',
|
||||
defaultSelected: '',
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{
|
||||
children: [{ id: 2, name: '平台' }],
|
||||
id: 1,
|
||||
name: '研发',
|
||||
},
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.findAll('.el-tree-node__content')).toHaveLength(3);
|
||||
const all = wrapper
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('全部'));
|
||||
expect(all?.element.parentElement?.classList).toContain('is-current');
|
||||
const child = wrapper
|
||||
.findAll('.el-tree-node__content')
|
||||
.find((node) => node.text().includes('平台'));
|
||||
expect(child).toBeTruthy();
|
||||
await child?.trigger('click');
|
||||
expect(wrapper.emitted('change')?.at(-1)?.[0]).toMatchObject({ id: 2 });
|
||||
});
|
||||
|
||||
it('supports per-node disabled action states', async () => {
|
||||
const edit = vi.fn();
|
||||
const remove = vi.fn();
|
||||
const wrapper = mount(PageSide, {
|
||||
global: {
|
||||
stubs: {
|
||||
ElDropdown: DropdownStub,
|
||||
ElDropdownItem: DropdownItemStub,
|
||||
ElDropdownMenu: DropdownMenuStub,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
controlBtns: [
|
||||
{
|
||||
disabled: () => true,
|
||||
label: '编辑',
|
||||
onClick: edit,
|
||||
},
|
||||
{ label: '删除', onClick: remove },
|
||||
],
|
||||
labelKey: 'name',
|
||||
menus: [
|
||||
{ id: '', name: '全部' },
|
||||
{ id: 1, name: '研发' },
|
||||
],
|
||||
valueKey: 'id',
|
||||
},
|
||||
});
|
||||
|
||||
const actions = wrapper.findAll('.dropdown-item-stub');
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]?.attributes('data-disabled')).toBe('true');
|
||||
await actions[0]?.trigger('click');
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
|
||||
await actions[1]?.trigger('click');
|
||||
expect(remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
@@ -14,178 +13,313 @@ import {
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElTree,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getEmptyStateImageUrl } from '#/utils/assets';
|
||||
|
||||
interface ControlButton<Item> {
|
||||
disabled?: ((item: Item) => boolean) | boolean;
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (item: Item) => void;
|
||||
type?: any;
|
||||
visible?: (item: Item) => boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
menus: T[];
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
iconSize?: number;
|
||||
controlBtns?: {
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (_: T) => void;
|
||||
type?: any;
|
||||
}[];
|
||||
childrenKey?: string;
|
||||
controlBtns?: ControlButton<T>[];
|
||||
defaultExpandAll?: boolean;
|
||||
defaultSelected?: number | string;
|
||||
footerButton?: {
|
||||
disabled?: boolean;
|
||||
icon?: any;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
defaultSelected?: string;
|
||||
iconSize?: number;
|
||||
labelKey: string;
|
||||
menus: T[];
|
||||
title?: string;
|
||||
valueKey: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
iconSize: 16,
|
||||
childrenKey: '',
|
||||
controlBtns: () => [],
|
||||
footerButton: undefined,
|
||||
defaultExpandAll: true,
|
||||
defaultSelected: '',
|
||||
footerButton: undefined,
|
||||
iconSize: 16,
|
||||
title: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', item: T): void;
|
||||
}>();
|
||||
const panelWidth = ref(225);
|
||||
const selected = ref<string>(props.defaultSelected ?? '');
|
||||
const hoverId = ref<string>();
|
||||
|
||||
const handleChange = (item: T) => {
|
||||
selected.value = item[props.valueKey];
|
||||
const panelWidth = 225;
|
||||
const selected = ref<number | string>(props.defaultSelected ?? '');
|
||||
const treeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const lastEmittedDefault = ref('');
|
||||
const treeProps = computed(() => ({
|
||||
children: props.childrenKey,
|
||||
label: props.labelKey,
|
||||
}));
|
||||
|
||||
function menuValue(item: T) {
|
||||
return item[props.valueKey] as number | string;
|
||||
}
|
||||
|
||||
function normalizedValue(value: unknown) {
|
||||
return String(value ?? '');
|
||||
}
|
||||
|
||||
function findMenu(items: T[], value: number | string): T | undefined {
|
||||
for (const item of items) {
|
||||
if (normalizedValue(menuValue(item)) === normalizedValue(value)) {
|
||||
return item;
|
||||
}
|
||||
if (props.childrenKey) {
|
||||
const children = item[props.childrenKey] as T[] | undefined;
|
||||
const matched = children?.length ? findMenu(children, value) : undefined;
|
||||
if (matched) return matched;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function syncTreeSelection() {
|
||||
if (!props.childrenKey) return;
|
||||
void nextTick(() => treeRef.value?.setCurrentKey(selected.value));
|
||||
}
|
||||
|
||||
function handleChange(item: T) {
|
||||
selected.value = menuValue(item);
|
||||
lastEmittedDefault.value = normalizedValue(selected.value);
|
||||
emits('change', item);
|
||||
};
|
||||
// 监听 defaultSelected 的变化
|
||||
syncTreeSelection();
|
||||
}
|
||||
|
||||
function visibleActions(item: T) {
|
||||
return props.controlBtns.filter(
|
||||
(button) => !button.visible || button.visible(item),
|
||||
);
|
||||
}
|
||||
|
||||
function hasActions(item: T) {
|
||||
const value = normalizedValue(menuValue(item));
|
||||
return !['', '0'].includes(value) && visibleActions(item).length > 0;
|
||||
}
|
||||
|
||||
function actionDisabled(button: ControlButton<T>, item: T) {
|
||||
return typeof button.disabled === 'function'
|
||||
? button.disabled(item)
|
||||
: Boolean(button.disabled);
|
||||
}
|
||||
|
||||
function runAction(button: ControlButton<T>, item: T) {
|
||||
if (!actionDisabled(button, item)) button.onClick(item);
|
||||
}
|
||||
|
||||
function isComponent(icon: any) {
|
||||
return typeof icon !== 'string';
|
||||
}
|
||||
|
||||
function isSvgString(icon: any) {
|
||||
if (typeof icon !== 'string') return false;
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.defaultSelected,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
selected.value = newVal;
|
||||
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
|
||||
if (item) {
|
||||
emits('change', item);
|
||||
}
|
||||
[() => props.defaultSelected, () => props.menus],
|
||||
([defaultSelected]) => {
|
||||
selected.value = defaultSelected ?? '';
|
||||
syncTreeSelection();
|
||||
if (defaultSelected === '' || defaultSelected === undefined) return;
|
||||
const matched = findMenu(props.menus, defaultSelected);
|
||||
const normalized = normalizedValue(defaultSelected);
|
||||
if (matched && lastEmittedDefault.value !== normalized) {
|
||||
lastEmittedDefault.value = normalized;
|
||||
emits('change', matched);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
const handleMouseEvent = (id?: string) => {
|
||||
if (id === undefined) {
|
||||
setTimeout(() => {
|
||||
hoverId.value = id;
|
||||
}, 200);
|
||||
} else {
|
||||
hoverId.value = id;
|
||||
}
|
||||
};
|
||||
const isComponent = (icon: any) => {
|
||||
return typeof icon !== 'string';
|
||||
};
|
||||
const isSvgString = (icon: any) => {
|
||||
if (typeof icon !== 'string') return false;
|
||||
// 简单判断:是否包含 SVG 根标签
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-[225px] flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
|
||||
:style="{ width: `${panelWidth}px` }"
|
||||
>
|
||||
<div class="flex flex-1 flex-col gap-5 overflow-hidden">
|
||||
<h3 v-if="title && title.length > 0" class="text-base font-medium">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="page-side" :style="{ width: `${panelWidth}px` }">
|
||||
<div class="page-side__main">
|
||||
<h3 v-if="title" class="page-side__title">{{ title }}</h3>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="item[valueKey]"
|
||||
class="group list-item"
|
||||
:class="{
|
||||
selected: selected === item[valueKey],
|
||||
}"
|
||||
@click="handleChange(item)"
|
||||
<div class="page-side__scroll">
|
||||
<ElTree
|
||||
v-if="childrenKey"
|
||||
ref="treeRef"
|
||||
class="page-side__tree"
|
||||
:data="menus"
|
||||
:node-key="valueKey"
|
||||
:props="treeProps"
|
||||
:current-node-key="selected"
|
||||
:default-expand-all="defaultExpandAll"
|
||||
:expand-on-click-node="false"
|
||||
highlight-current
|
||||
@node-click="handleChange"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="ml-[-3px] flex items-center justify-center"
|
||||
>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
class="svg-container"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
v-else-if="
|
||||
typeof item.icon === 'string' && !isComponent(item.icon)
|
||||
"
|
||||
:src="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
objectFit: 'contain',
|
||||
}"
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" v-bind="$attrs" />
|
||||
</ElIcon>
|
||||
<template #default="{ data }">
|
||||
<div class="page-side__tree-node">
|
||||
<div class="page-side__label">
|
||||
<div v-if="data.icon" class="page-side__icon">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(data.icon)"
|
||||
v-html="data.icon"
|
||||
class="page-side__svg"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
v-else-if="
|
||||
typeof data.icon === 'string' && !isComponent(data.icon)
|
||||
"
|
||||
:src="data.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="data.icon as Component" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<slot name="label" :item="data">
|
||||
<span class="page-side__label-text">
|
||||
{{ data[labelKey] }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<ElDropdown v-if="hasActions(data)" trigger="click" @click.stop>
|
||||
<ElButton
|
||||
class="page-side__more"
|
||||
:aria-label="`管理${data[labelKey]}`"
|
||||
:icon="MoreFilled"
|
||||
text
|
||||
@click.stop
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="button in visibleActions(data)"
|
||||
:key="button.label"
|
||||
:disabled="actionDisabled(button, data)"
|
||||
@click="runAction(button, data)"
|
||||
>
|
||||
<ElButton
|
||||
:type="button.type"
|
||||
:icon="button.icon"
|
||||
:disabled="actionDisabled(button, data)"
|
||||
link
|
||||
>
|
||||
{{ button.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<div>
|
||||
{{ item[labelKey] }}
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
|
||||
@click.stop
|
||||
</template>
|
||||
</ElTree>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="menuValue(item)"
|
||||
class="page-side__item"
|
||||
:class="{
|
||||
'is-selected':
|
||||
normalizedValue(selected) === normalizedValue(menuValue(item)),
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleChange(item)"
|
||||
@keydown.enter.prevent="handleChange(item)"
|
||||
@keydown.space.prevent="handleChange(item)"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group-hover:!inline-flex',
|
||||
(!hoverId || item.id !== hoverId) && '!hidden',
|
||||
)
|
||||
"
|
||||
>
|
||||
<ElIcon>
|
||||
<MoreFilled />
|
||||
</ElIcon>
|
||||
<div class="page-side__label">
|
||||
<div v-if="item.icon" class="page-side__icon">
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
class="page-side__svg"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<img
|
||||
v-else-if="
|
||||
typeof item.icon === 'string' && !isComponent(item.icon)
|
||||
"
|
||||
:src="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}"
|
||||
alt=""
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<slot name="label" :item="item">
|
||||
<span class="page-side__label-text">
|
||||
{{ item[labelKey] }}
|
||||
</span>
|
||||
</slot>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<div
|
||||
@mouseenter="handleMouseEvent(item.id)"
|
||||
@mouseleave="handleMouseEvent()"
|
||||
>
|
||||
|
||||
<ElDropdown v-if="hasActions(item)" trigger="click" @click.stop>
|
||||
<ElButton
|
||||
class="page-side__more"
|
||||
:aria-label="`管理${item[labelKey]}`"
|
||||
:icon="MoreFilled"
|
||||
text
|
||||
@click.stop
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="btn in controlBtns"
|
||||
:key="btn.label"
|
||||
@click="btn.onClick(item)"
|
||||
v-for="button in visibleActions(item)"
|
||||
:key="button.label"
|
||||
:disabled="actionDisabled(button, item)"
|
||||
@click="runAction(button, item)"
|
||||
>
|
||||
<ElButton :type="btn.type" :icon="btn.icon" link>
|
||||
{{ btn.label }}
|
||||
<ElButton
|
||||
:type="button.type"
|
||||
:icon="button.icon"
|
||||
:disabled="actionDisabled(button, item)"
|
||||
link
|
||||
>
|
||||
{{ button.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElEmpty
|
||||
v-if="menus.length <= 0"
|
||||
v-if="menus.length === 0"
|
||||
:image="getEmptyStateImageUrl(preferences.theme.mode)"
|
||||
/>
|
||||
</div>
|
||||
@@ -193,9 +327,12 @@ const isSvgString = (icon: any) => {
|
||||
|
||||
<ElButton
|
||||
v-if="footerButton"
|
||||
@click="footerButton.onClick"
|
||||
class="page-side__footer"
|
||||
:disabled="footerButton.disabled"
|
||||
:icon="footerButton.icon"
|
||||
:loading="footerButton.loading"
|
||||
plain
|
||||
@click="footerButton.onClick"
|
||||
>
|
||||
{{ footerButton.label }}
|
||||
</ElButton>
|
||||
@@ -203,34 +340,164 @@ const isSvgString = (icon: any) => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
.page-side {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: hsl(var(--surface-panel));
|
||||
border: 1px solid hsl(var(--line-subtle));
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
|
||||
.page-side__main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-side__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.page-side__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.page-side__item,
|
||||
.page-side__tree-node {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-side__item {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
border-radius: var(--radius-control);
|
||||
outline: none;
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
.page-side__item:hover {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 15%);
|
||||
.page-side__item.is-selected {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.list-item.selected:where(.dark, .dark *) {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background-color: hsl(var(--accent));
|
||||
.page-side__item:focus-visible {
|
||||
outline: 2px solid hsl(var(--primary) / 72%);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.svg-container :deep(svg) {
|
||||
.page-side__label {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: var(--space-1);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-side__label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.page-side__icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: -3px;
|
||||
}
|
||||
|
||||
.page-side__icon img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.page-side__svg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-side__more {
|
||||
flex: none;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.page-side__item:hover .page-side__more,
|
||||
.page-side__item:focus-within .page-side__more,
|
||||
.page-side__tree-node:hover .page-side__more,
|
||||
.page-side__tree-node:focus-within .page-side__more {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.page-side__footer {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.page-side__tree {
|
||||
color: hsl(var(--nav-item-muted-foreground));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node__content) {
|
||||
height: 40px;
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-control);
|
||||
transition:
|
||||
color var(--motion-duration-fast) var(--motion-ease-standard),
|
||||
background-color var(--motion-duration-fast) var(--motion-ease-standard);
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node__content:hover) {
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.page-side__tree :deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||
color: hsl(var(--nav-item-active-foreground));
|
||||
background: hsl(var(--nav-item-active));
|
||||
}
|
||||
|
||||
.page-side__tree-node {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding-right: var(--space-1);
|
||||
}
|
||||
|
||||
.page-side__svg :deep(svg) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
|
||||
Reference in New Issue
Block a user