feat: 移除系统管理路由并优化路由结构

- 删除不再使用的系统管理路由模块
- 更新路由配置,确保仅保留必要的模块
- 添加Element Plus图标全局注册功能
- 优化分页组件,增加更多属性支持
- 重构表格操作列渲染逻辑,提升代码可读性和复用性
- 更新多个组件以支持新的操作列渲染方式
This commit is contained in:
zhangtao
2026-05-05 21:47:13 +08:00
parent 6db264bef3
commit 49ffb9dbb2
50 changed files with 936 additions and 3214 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ VITE_PORT = 5180
VITE_BASE_URL = / VITE_BASE_URL = /
# 权限模式【 frontend 仅前端路由 / backend 仅后端菜单 / mixed 后端菜单+前端路由模块合并 】 # 权限模式【 frontend 仅前端路由 / backend 仅后端菜单 / mixed 后端菜单+前端路由模块合并 】
VITE_ACCESS_MODE = mixed VITE_ACCESS_MODE = frontend
# 跨域请求时是否携带 Cookie(开启前需确保后端支持) # 跨域请求时是否携带 Cookie(开启前需确保后端支持)
VITE_WITH_CREDENTIALS = false VITE_WITH_CREDENTIALS = false
Submodule frontend/new-web/art-design-pro added at 6b6d781a63
+3 -63
View File
@@ -156,7 +156,6 @@ const targets = [
"CHANGELOG.md", "CHANGELOG.md",
"CHANGELOG.zh-CN.md", "CHANGELOG.zh-CN.md",
"src/views/safeguard", "src/views/safeguard",
"src/views/system/nested",
"src/views/widgets", "src/views/widgets",
"src/views/dashboard/analysis", "src/views/dashboard/analysis",
"src/views/dashboard/ecommerce", "src/views/dashboard/ecommerce",
@@ -254,73 +253,14 @@ async function cleanRouteModules() {
// 忽略 // 忽略
} }
// 重写 system.ts - 移除 nested 嵌套菜单 // 重写 index.tswidgets 已删则仅保留 exception
const systemContent = `import { AppRouteRecord } from '@/types/router'
export const systemRoutes: AppRouteRecord = {
path: '/art-system',
name: 'ArtSystem',
component: '/index/index',
meta: {
title: 'menus.system.title',
icon: 'ri:user-3-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'ArtTplUser',
component: '/system/user',
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'ArtTplRole',
component: '/system/role',
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'menu',
name: 'ArtTplMenus',
component: '/system/menu',
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{ title: '新增', authMark: 'add' },
{ title: '编辑', authMark: 'edit' },
{ title: '删除', authMark: 'delete' }
]
}
}
]
}
`;
await fs.writeFile(path.join(modulesPath, "system.ts"), systemContent, "utf-8");
// 重写 index.ts - 只导入保留的模块
const indexContent = `import { AppRouteRecord } from '@/types/router' const indexContent = `import { AppRouteRecord } from '@/types/router'
import { systemRoutes } from './system'
import { resultRoutes } from './result'
import { exceptionRoutes } from './exception' import { exceptionRoutes } from './exception'
/** /**
* 导出所有模块化路由 * 导出所有模块化路由(业务菜单由后端动态下发)
*/ */
export const routeModules: AppRouteRecord[] = [ export const routeModules: AppRouteRecord[] = [exceptionRoutes]
systemRoutes,
resultRoutes,
exceptionRoutes
]
`; `;
await fs.writeFile(path.join(modulesPath, "index.ts"), indexContent, "utf-8"); await fs.writeFile(path.join(modulesPath, "index.ts"), indexContent, "utf-8");
@@ -6,8 +6,11 @@
v-model:current-page="currentPage" v-model:current-page="currentPage"
v-model:page-size="pageSize" v-model:page-size="pageSize"
:background="background" :background="background"
:disabled="disabled"
:layout="layout" :layout="layout"
:page-sizes="pageSizes" :page-sizes="pageSizes"
:pager-count="pagerCount"
:size="size"
:total="total" :total="total"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
@@ -17,6 +20,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { watch, type PropType } from "vue";
const props = defineProps({ const props = defineProps({
total: { total: {
type: Number as PropType<number>, type: Number as PropType<number>,
@@ -25,7 +30,7 @@ const props = defineProps({
pageSizes: { pageSizes: {
type: Array as PropType<number[]>, type: Array as PropType<number[]>,
default() { default() {
return [10, 20, 30, 50]; return [10, 20, 30, 50, 100];
}, },
}, },
layout: { layout: {
@@ -36,6 +41,20 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
disabled: {
type: Boolean,
default: false,
},
/** 页码按钮数量(透传 ElPagination */
pagerCount: {
type: Number as PropType<number>,
default: undefined,
},
/** 分页器尺寸(透传 ElPagination */
size: {
type: String as PropType<"" | "default" | "small" | "large">,
default: undefined,
},
autoScroll: { autoScroll: {
type: Boolean, type: Boolean,
default: true, default: true,
@@ -15,13 +15,13 @@
<!-- 渲染展开行 --> <!-- 渲染展开行 -->
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)"> <ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row }"> <template #default="{ row: expandRow }">
<component :is="col.formatter ? col.formatter(row) : null" /> <component :is="col.formatter ? col.formatter(expandRow) : null" />
</template> </template>
</ElTableColumn> </ElTableColumn>
<!-- 渲染普通列 --> <!-- 渲染普通列default 插槽须紧凑书写避免空白文本抢占 formatter renderColumnFormatter 注释 -->
<ElTableColumn v-else v-bind="cleanColumnProps(col)"> <ElTableColumn v-else v-bind="cleanBodyColumnProps(col)">
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope"> <template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot <slot
:name="col.headerSlotName || `${col.prop}-header`" :name="col.headerSlotName || `${col.prop}-header`"
@@ -30,17 +30,9 @@
{{ col.label }} {{ col.label }}
</slot> </slot>
</template> </template>
<template v-if="col.useSlot && col.prop" #default="slotScope"> <!-- 整段单行插槽内兄弟节点之间的空白文本会让 EP 误判单元格内容 -->
<slot <!-- eslint-disable-next-line vue/max-attributes-per-line, prettier/prettier -->
v-if="shouldRenderSlotScope(slotScope)" <template #default="slotScope"><slot v-if="col.useSlot && col.prop && shouldRenderSlotScope(slotScope)" :name="col.slotName || col.prop" v-bind="{ ...slotScope, prop: col.prop, value: col.prop ? slotScope.row[col.prop] : undefined }" /><TableFormatterOutlet v-else-if="col.formatter && !col.useSlot && shouldRenderSlotScope(slotScope)" :column="col" :record="slotScope.row" /></template>
:name="col.slotName || col.prop"
v-bind="{
...slotScope,
prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined,
}"
/>
</template>
</ElTableColumn> </ElTableColumn>
</template> </template>
@@ -58,21 +50,37 @@
:class="mergedPaginationOptions?.align" :class="mergedPaginationOptions?.align"
ref="paginationRef" ref="paginationRef"
> >
<ElPagination <Pagination
v-bind="mergedPaginationOptions" v-if="pagination"
:total="pagination?.total" :page="pagination.current"
:disabled="loading" :limit="pagination.size"
:page-size="pagination?.size" :total="pagination.total"
:current-page="pagination?.current" :page-sizes="mergedPaginationOptions.pageSizes"
@size-change="handleSizeChange" :layout="mergedPaginationOptions.layout"
@current-change="handleCurrentChange" :background="mergedPaginationOptions.background ?? true"
:disabled="!!loading"
:hidden="paginationHidden"
:pager-count="mergedPaginationOptions.pagerCount"
:size="mergedPaginationOptions.size"
@pagination="handlePaginationEvent"
/> />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watchEffect, getCurrentInstance, useAttrs } from "vue"; import {
ref,
computed,
nextTick,
watchEffect,
getCurrentInstance,
useAttrs,
isVNode,
h,
defineComponent,
type PropType,
} from "vue";
import type { ElTable, TableProps } from "element-plus"; import type { ElTable, TableProps } from "element-plus";
import { storeToRefs } from "pinia"; import { storeToRefs } from "pinia";
import { ColumnOption } from "@/types"; import { ColumnOption } from "@/types";
@@ -80,6 +88,7 @@ import { useTableStore } from "@/store/modules/table.store";
import { useCommon } from "@/hooks/core/useCommon"; import { useCommon } from "@/hooks/core/useCommon";
import { useTableHeight } from "@/hooks/core/useTableHeight"; import { useTableHeight } from "@/hooks/core/useTableHeight";
import { useResizeObserver, useWindowSize } from "@vueuse/core"; import { useResizeObserver, useWindowSize } from "@vueuse/core";
import Pagination from "@/components/Pagination/index.vue";
defineOptions({ name: "ArtTable" }); defineOptions({ name: "ArtTable" });
@@ -183,6 +192,17 @@ const mergedPaginationOptions = computed(() => ({
...props.paginationOptions, ...props.paginationOptions,
})); }));
/** 对齐 ElPagination hide-on-single-page,交给封装组件的 hidden */
const paginationHidden = computed(() => {
const p = props.pagination;
const opts = mergedPaginationOptions.value;
if (!p || !opts.hideOnSinglePage) return false;
const size = p.size || 10;
const total = p.total ?? 0;
if (total <= 0) return false;
return Math.ceil(total / size) <= 1;
});
// 边框 (优先级:props > store) // 边框 (优先级:props > store)
const border = computed(() => props.border ?? isBorder.value); const border = computed(() => props.border ?? isBorder.value);
// 斑马纹 // 斑马纹
@@ -269,6 +289,11 @@ const mergedTableProps = computed(() => ({
: undefined, : undefined,
})); }));
const emit = defineEmits<{
(e: "pagination:size-change", val: number): void;
(e: "pagination:current-change", val: number): void;
}>();
// 是否显示分页器 // 是否显示分页器
const showPagination = computed(() => props.pagination && !isEmpty.value); const showPagination = computed(() => props.pagination && !isEmpty.value);
@@ -278,6 +303,36 @@ const shouldRenderSlotScope = (slotScope: { $index?: number }) => {
return slotScope.$index === undefined || slotScope.$index >= 0; return slotScope.$index === undefined || slotScope.$index >= 0;
}; };
/**
* ElTableColumn 若存在 default 插槽且插槽产物含任意非 Comment 的 vnode(含空白文本节点),
* 将不会执行 formatter(见 element-plus render-helper setColumnRenders)。
* ArtTable 中 ElTableColumn 与子节点之间的换行/缩进可能被编译进默认插槽,导致 formatter(如操作列里的按钮)永远不渲染。
* 对声明了 formatter 且未使用 useSlot 的列,在此显式渲染 formatter 返回值。
*/
const renderColumnFormatter = (col: ColumnOption, row: Record<string, unknown>) => {
if (!col.formatter) return null;
const result = col.formatter(row as never);
if (isVNode(result)) return result;
if (result === null || result === undefined) return null;
return h("span", String(result));
};
/**
* 在 render 里调用 formatter(row) 生成 VNode;勿把 VNode 当 props 传入(克隆后会失效)。
* 与 renderColumnFormatter 同文件定义,保证闭包一致。
*/
const TableFormatterOutlet = defineComponent({
name: "TableFormatterOutlet",
props: {
column: { type: Object as PropType<ColumnOption>, required: true },
/** 避免 prop 名 row 与插槽解构冲突 */
record: { type: Object as PropType<Record<string, unknown>>, required: true },
},
setup(props) {
return () => renderColumnFormatter(props.column, props.record);
},
});
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释 // 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
const cleanColumnProps = (col: ColumnOption) => { const cleanColumnProps = (col: ColumnOption) => {
const columnProps = { ...col }; const columnProps = { ...col };
@@ -289,15 +344,11 @@ const cleanColumnProps = (col: ColumnOption) => {
return columnProps; return columnProps;
}; };
// 分页大小变化 /** 普通列:单元格已由插槽内 TableFormatterOutlet 渲染,勿再把 formatter 传给 ElTableColumn,避免与 EP 内置 renderCell 混用 */
const handleSizeChange = (val: number) => { const cleanBodyColumnProps = (col: ColumnOption) => {
emit("pagination:size-change", val); const columnProps = cleanColumnProps(col);
}; delete columnProps.formatter;
return columnProps;
// 分页当前页变化
const handleCurrentChange = (val: number) => {
emit("pagination:current-change", val);
scrollToTop(); // 页码改变后滚动到表格顶部
}; };
const { scrollToTop: scrollPageToTop } = useCommon(); const { scrollToTop: scrollPageToTop } = useCommon();
@@ -310,6 +361,20 @@ const scrollToTop = () => {
}); });
}; };
/** 对接封装分页 @pagination,保持对外仍为 size-change / current-change 事件 */
const handlePaginationEvent = (payload: { page: number; limit: number }) => {
const p = props.pagination;
if (!p) return;
if (payload.limit !== p.size) {
emit("pagination:size-change", payload.limit);
return;
}
if (payload.page !== p.current) {
emit("pagination:current-change", payload.page);
scrollToTop();
}
};
// 全局序号 // 全局序号
const getGlobalIndex = (index: number) => { const getGlobalIndex = (index: number) => {
if (!props.pagination) return index + 1; if (!props.pagination) return index + 1;
@@ -317,11 +382,6 @@ const getGlobalIndex = (index: number) => {
return (current - 1) * size + index + 1; return (current - 1) * size + index + 1;
}; };
const emit = defineEmits<{
(e: "pagination:size-change", val: number): void;
(e: "pagination:current-change", val: number): void;
}>();
// 查找并绑定表格头部元素 - 使用 VueUse 优化 // 查找并绑定表格头部元素 - 使用 VueUse 优化
const findTableHeader = () => { const findTableHeader = () => {
if (!props.showTableHeader) { if (!props.showTableHeader) {
@@ -19,20 +19,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRoute } from "vue-router";
import { useSettingsStore } from "@/store/modules/setting.store"; import { useSettingsStore } from "@/store/modules/setting.store";
import { useCeremony } from "@/hooks/core/useCeremony"; import { useCeremony } from "@/hooks/core/useCeremony";
import { WEB_LINKS } from "@/utils/constants"; import { WEB_LINKS } from "@/utils/constants";
defineOptions({ name: "ArtFestivalTextScroll" }); defineOptions({ name: "ArtFestivalTextScroll" });
const route = useRoute();
const settingStore = useSettingsStore(); const settingStore = useSettingsStore();
const { showFestivalText } = storeToRefs(settingStore); const { showFestivalText } = storeToRefs(settingStore);
const { currentFestivalData, closeFestivalScroll } = useCeremony(); const { currentFestivalData, closeFestivalScroll } = useCeremony();
const isFullPageLayout = computed(() => route.matched.some((r) => r.meta?.isFullPage));
function versionLabel(): string { function versionLabel(): string {
const v = String(import.meta.env.VITE_VERSION ?? "").trim(); const v = String(import.meta.env.VITE_VERSION ?? "").trim();
if (!v) return ""; if (!v) return "";
@@ -49,7 +45,6 @@ const showFestivalStrip = computed(
() => () =>
showFestivalText.value && showFestivalText.value &&
!!currentFestivalData.value?.scrollText && !!currentFestivalData.value?.scrollText &&
currentFestivalData.value.scrollText !== "" && currentFestivalData.value.scrollText !== ""
!isFullPageLayout.value
); );
</script> </script>
+1 -1
View File
@@ -114,7 +114,7 @@ export const SETTING_DEFAULT_CONFIG = {
/** 是否显示语言切换 */ /** 是否显示语言切换 */
showLanguage: true, showLanguage: true,
/** 是否显示进度条 */ /** 是否显示进度条 */
showNprogress: false, showNprogress: true,
/** 是否显示设置引导 */ /** 是否显示设置引导 */
showSettingGuide: true, showSettingGuide: true,
/** 是否显示节日文本 */ /** 是否显示节日文本 */
+18 -7
View File
@@ -35,13 +35,13 @@ import { storeToRefs } from "pinia";
import { useUserStore } from "@/store/modules/user.store"; import { useUserStore } from "@/store/modules/user.store";
import { useAppMode } from "@/hooks/core/useAppMode"; import { useAppMode } from "@/hooks/core/useAppMode";
import type { AppRouteRecord } from "@/types/router"; import type { AppRouteRecord } from "@/types/router";
import { ROLE_ROOT } from "@/constants";
type AuthItem = NonNullable<AppRouteRecord["meta"]["authList"]>[number]; type AuthItem = NonNullable<AppRouteRecord["meta"]["authList"]>[number];
const userStore = useUserStore();
export const useAuth = () => { export const useAuth = () => {
const route = useRoute(); const route = useRoute();
const userStore = useUserStore();
const { isFrontendMode } = useAppMode(); const { isFrontendMode } = useAppMode();
const { info } = storeToRefs(userStore); const { info } = storeToRefs(userStore);
@@ -54,17 +54,28 @@ export const useAuth = () => {
: []; : [];
/** /**
* 检查是否拥有某权限标识(前后端模式通用 * 检查是否拥有某权限标识(与 v-hasPerm / 表格内 ArtButtonMore 全码一致
* @param auth 权限标识 * @param auth 权限标识(多为后端菜单 permission 全码,如 module_system:role:update
* @returns 是否有权限
*/ */
const hasAuth = (auth: string): boolean => { const hasAuth = (auth: string): boolean => {
// 前端模式 if (!auth) return true;
const userPrems = userStore.prems;
const roles = userStore.basicInfo?.roles as { code?: string }[] | undefined;
// 与 directives/permission hasPerm 一致:超级管理员、通配
if (roles?.some((r) => r.code === ROLE_ROOT)) return true;
if (userPrems.includes("*:*:*")) return true;
/** 登录后由菜单树汇总的全局权限码(与 v-hasPerm 同源) */
if (userPrems.includes(auth)) return true;
// 前端模式:兼容 info.permissions
if (isFrontendMode.value) { if (isFrontendMode.value) {
return frontendAuthList.includes(auth); return frontendAuthList.includes(auth);
} }
// 后端模式 // 后端模式:当前路由 meta 短标识(与 v-auth 一致)
return backendAuthList.some((item) => item?.authMark === auth); return backendAuthList.some((item) => item?.authMark === auth);
}; };
@@ -1,14 +1,9 @@
<!-- 布局内容 --> <!-- 布局内容 -->
<template> <template>
<div <div id="app-scroll-main" class="layout-content" :style="containerStyle">
id="app-scroll-main"
class="layout-content"
:class="{ 'overflow-auto': isFullPage }"
:style="containerStyle"
>
<div id="app-content-header"> <div id="app-content-header">
<!-- 节日滚动 --> <!-- 节日滚动 -->
<ArtFestivalTextScroll v-if="!isFullPage" /> <ArtFestivalTextScroll />
<!-- 路由信息调试 --> <!-- 路由信息调试 -->
<div <div
@@ -20,40 +15,28 @@
</div> </div>
<RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle"> <RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle">
<!-- 缓存路由动画 --> <Transition :name="actualTransition" mode="out-in" appear>
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear> <div class="route-view-shell flex min-h-0 flex-1 flex-col">
<KeepAlive :max="10" :exclude="keepAliveExclude"> <KeepAlive :max="10" :exclude="keepAliveExclude">
<component
v-if="router.meta.keepAlive === true"
class="art-page-view min-h-0 flex-1"
:is="Component"
:key="router.fullPath"
/>
</KeepAlive>
<component <component
class="art-page-view" v-if="router.meta.keepAlive !== true"
class="art-page-view min-h-0 flex-1"
:is="Component" :is="Component"
:key="router.path" :key="router.fullPath"
v-if="router.meta.keepAlive"
/> />
</KeepAlive> </div>
</Transition>
<!-- 非缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<component
class="art-page-view"
:is="Component"
:key="router.path"
v-if="!router.meta.keepAlive"
/>
</Transition> </Transition>
</RouterView> </RouterView>
<!-- 全屏页面切换过渡遮罩用于提升页面切换视觉体验 -->
<Teleport to="body">
<div
v-show="showTransitionMask"
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
/>
</Teleport>
<!-- 返回顶部宽屏 #app-scroll-main窄屏文档滚动 target 置空 --> <!-- 返回顶部宽屏 #app-scroll-main窄屏文档滚动 target 置空 -->
<ElBacktop <ElBacktop
v-if="!isFullPage"
:key="backtopTargetKey" :key="backtopTargetKey"
:target="backtopScrollTarget" :target="backtopScrollTarget"
:right="28" :right="28"
@@ -86,65 +69,27 @@ const { keepAliveExclude } = storeToRefs(useWorktabStore());
const isRefresh = shallowRef(true); const isRefresh = shallowRef(true);
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO; const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO;
const showTransitionMask = ref(false);
// 标记是否是首次加载(浏览器刷新) // 标记是否是首次加载(浏览器刷新)
const isFirstLoad = ref(true); const isFirstLoad = ref(true);
// 检查当前路由是否需要使用无基础布局模式
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage));
const prevIsFullPage = ref(isFullPage.value);
// 切换动画名称:首次加载、从全屏返回时不使用动画
const actualTransition = computed(() => { const actualTransition = computed(() => {
if (isFirstLoad.value) return ""; if (isFirstLoad.value) return "";
if (prevIsFullPage.value && !isFullPage.value) return "";
return pageTransition.value; return pageTransition.value;
}); });
// 监听全屏状态变化,显示过渡遮罩
watch(isFullPage, (val, oldVal) => {
if (val !== oldVal) {
showTransitionMask.value = true;
// 延迟隐藏遮罩,给足时间让页面完成切换
setTimeout(() => {
showTransitionMask.value = false;
}, 50);
}
nextTick(() => {
prevIsFullPage.value = val;
});
});
const containerStyle = computed( const containerStyle = computed(
(): CSSProperties => (): CSSProperties => ({
isFullPage.value maxWidth: containerWidth.value,
? { flex: "1",
position: "fixed", minHeight: "0",
top: 0, display: "flex",
left: 0, flexDirection: "column",
width: "100%", })
height: "100vh",
zIndex: 2500,
background: "var(--default-bg-color)",
}
: {
maxWidth: containerWidth.value,
flex: "1",
minHeight: "0",
display: "flex",
flexDirection: "column",
}
); );
/** 常规布局下由 `.layout-content` 承担纵向滚动,路由视图填满剩余高度 */ /** 常规布局下由 `.layout-content` 承担纵向滚动,路由视图填满剩余高度 */
const contentStyle = computed( const contentStyle = computed((): CSSProperties => ({ flex: "1", minHeight: "0" }));
(): CSSProperties =>
isFullPage.value
? { flex: "1", minHeight: "0", display: "flex", flexDirection: "column" }
: { flex: "1", minHeight: "0" }
);
const reload = () => { const reload = () => {
isRefresh.value = false; isRefresh.value = false;
+3
View File
@@ -8,6 +8,7 @@ import "@styles/index.scss"; // 样式
import "@utils/sys/console.ts"; // 控制台输出内容 import "@utils/sys/console.ts"; // 控制台输出内容
import { initGlobDirectives } from "./directives"; import { initGlobDirectives } from "./directives";
import { setupErrorHandle } from "./utils/sys/error-handle"; import { setupErrorHandle } from "./utils/sys/error-handle";
import { initElIcons } from "@/plugins/icons";
// 暗黑主题样式 // 暗黑主题样式
import "element-plus/theme-chalk/dark/css-vars.css"; import "element-plus/theme-chalk/dark/css-vars.css";
@@ -21,6 +22,8 @@ import { createTerminal } from "vue-web-terminal";
document.addEventListener("touchstart", function () {}, { passive: false }); document.addEventListener("touchstart", function () {}, { passive: false });
const app = createApp(App); const app = createApp(App);
/** Element Plus 图标(@element-plus/icons-vue)全局注册,模板中可直接 <Cpu />、<QuestionFilled /> 等 */
initElIcons(app);
initStore(app); initStore(app);
initRouter(app); initRouter(app);
initGlobDirectives(app); initGlobDirectives(app);
@@ -121,7 +121,7 @@ export function setupBeforeEachGuard(router: Router): void {
} catch (error) { } catch (error) {
console.error("[RouteGuard] 路由守卫处理失败:", error); console.error("[RouteGuard] 路由守卫处理失败:", error);
closeLoading(); closeLoading();
next({ name: "Exception500" }); next({ name: "500" });
} }
} }
); );
@@ -168,7 +168,7 @@ async function handleRouteGuard(
next(); next();
} else { } else {
// 未匹配到路由,跳转到 500 页面 // 未匹配到路由,跳转到 500 页面
next({ name: "Exception500", replace: true }); next({ name: "500", replace: true });
} }
return; return;
} }
@@ -204,7 +204,7 @@ async function handleRouteGuard(
} }
// 6. 未匹配到路由,跳转到 404 // 6. 未匹配到路由,跳转到 404
next({ name: "Exception404" }); next({ name: "404" });
} }
/** /**
@@ -262,9 +262,8 @@ function resolveStaticChildFullPath(parentFullPath: string, segment: string): st
function isStaticRoute(path: string): boolean { function isStaticRoute(path: string): boolean {
const checkRoute = (routes: any[], targetPath: string, parentFullPath = ""): boolean => { const checkRoute = (routes: any[], targetPath: string, parentFullPath = ""): boolean => {
return routes.some((route) => { return routes.some((route) => {
// 404 catch-all 路由不应视为可匿名访问的静态页, // 通配 404(pathMatch)不应视为免登录静态页;静态表里可能与 `/404` 同名,按 path 区分。
// 否则未登录时手动输入任意地址会直接落到 404,无法跳转登录页。 if (route.path === "/:pathMatch(.*)*") {
if (route.name === "Exception404") {
return false; return false;
} }
@@ -416,7 +415,7 @@ async function handleDynamicRoutes(
} }
// 跳转到 500 页面,使用 replace 避免产生历史记录 // 跳转到 500 页面,使用 replace 避免产生历史记录
next({ name: "Exception500", replace: true }); next({ name: "500", replace: true });
} }
} }
@@ -1,5 +1,11 @@
import { AppRouteRecord } from "@/types/router"; import { AppRouteRecord } from "@/types/router";
/**
* 「异常」菜单分组(挂载在 /exception/*,走 Layout)。
* 与 `staticRoutes` 中独立页区分:
* - 编程式跳转、守卫:`name` 为 `"403" | "404" | "500"`(对应 `/403`、`/404`、`/500`
* - 本模块子路由:`Exception403` / `Exception404` / `Exception500`(对应 `/exception/403` 等),勿与前者混用
*/
export const exceptionRoutes: AppRouteRecord = { export const exceptionRoutes: AppRouteRecord = {
path: "/exception", path: "/exception",
name: "Exception", name: "Exception",
@@ -17,7 +23,6 @@ export const exceptionRoutes: AppRouteRecord = {
title: "menus.exception.forbidden", title: "menus.exception.forbidden",
keepAlive: true, keepAlive: true,
isHideTab: true, isHideTab: true,
isFullPage: true,
}, },
}, },
{ {
@@ -28,7 +33,6 @@ export const exceptionRoutes: AppRouteRecord = {
title: "menus.exception.notFound", title: "menus.exception.notFound",
keepAlive: true, keepAlive: true,
isHideTab: true, isHideTab: true,
isFullPage: true,
}, },
}, },
{ {
@@ -39,7 +43,6 @@ export const exceptionRoutes: AppRouteRecord = {
title: "menus.exception.serverError", title: "menus.exception.serverError",
keepAlive: true, keepAlive: true,
isHideTab: true, isHideTab: true,
isFullPage: true,
}, },
}, },
], ],
+2 -3
View File
@@ -1,9 +1,8 @@
import { AppRouteRecord } from "@/types/router"; import { AppRouteRecord } from "@/types/router";
import { widgetsRoutes } from "./widgets"; import { widgetsRoutes } from "./widgets";
import { systemRoutes } from "./system";
import { exceptionRoutes } from "./exception"; import { exceptionRoutes } from "./exception";
/** /**
* 导出所有模块化路由 * 导出所有模块化路由(业务菜单由后端动态下发;此处仅演示组件库与异常分组)
*/ */
export const routeModules: AppRouteRecord[] = [widgetsRoutes, systemRoutes, exceptionRoutes]; export const routeModules: AppRouteRecord[] = [widgetsRoutes, exceptionRoutes];
@@ -1,49 +0,0 @@
import { AppRouteRecord } from "@/types/router";
/** Art 模板自带演示路由:与后端「系统管理」路径隔离,避免合并时被覆盖或冲突 */
export const systemRoutes: AppRouteRecord = {
path: "/art-system",
name: "ArtSystem",
component: "/index/index",
meta: {
title: "menus.system.title",
icon: "ri:user-3-line",
},
children: [
{
path: "user",
name: "ArtTplUser",
component: "/system/user",
meta: {
title: "menus.system.user",
icon: "ri:user-line",
keepAlive: true,
},
},
{
path: "role",
name: "ArtTplRole",
component: "/system/role",
meta: {
title: "menus.system.role",
icon: "ri:user-settings-line",
keepAlive: true,
},
},
{
path: "menu",
name: "ArtTplMenus",
component: "/system/menu",
meta: {
title: "menus.system.menu",
icon: "ri:menu-line",
keepAlive: true,
authList: [
{ title: "新增", authMark: "add" },
{ title: "编辑", authMark: "edit" },
{ title: "删除", authMark: "delete" },
],
},
},
],
};
@@ -152,7 +152,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
title: "menus.dashboard.pricing", title: "menus.dashboard.pricing",
icon: "ri:money-cny-box-line", icon: "ri:money-cny-box-line",
keepAlive: true, keepAlive: true,
isFullPage: true,
}, },
}, },
], ],
@@ -173,10 +172,10 @@ export const staticRoutes: AppRouteRecordRaw[] = [
}, },
], ],
}, },
// 通配 404 必须置于静态路由最后 // 通配 404 必须置于静态路由最后name 勿与上方 `/404` 重复,否则按名跳转不稳定)
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
name: "404", name: "CatchAll404",
component: () => import("@views/exception/404/index.vue"), component: () => import("@views/exception/404/index.vue"),
meta: { hidden: true, title: "404" }, meta: { hidden: true, title: "404" },
}, },
@@ -5,6 +5,7 @@
import type { MenuTable } from "@/api/module_system/menu"; import type { MenuTable } from "@/api/module_system/menu";
import type { AppRouteRecord, RouteMeta } from "@/types/router"; import type { AppRouteRecord, RouteMeta } from "@/types/router";
import { RoutesAlias } from "@/router/routesAlias"; import { RoutesAlias } from "@/router/routesAlias";
import { MenuTypeEnum } from "@/enums/system/menu.enum";
/** /**
* 后端常把「模块目录」与「其下功能菜单」写成同一绝对 route_path。 * 后端常把「模块目录」与「其下功能菜单」写成同一绝对 route_path。
@@ -98,7 +99,7 @@ function toComponentImportPath(componentPath: string): string {
} }
function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord { function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
const childrenRaw = item.children?.filter((c) => c.type !== 3) ?? []; const childrenRaw = item.children?.filter((c) => c.type !== MenuTypeEnum.BUTTON) ?? [];
const children = childrenRaw.length const children = childrenRaw.length
? childrenRaw.map((c) => mapMenuNode(c, depth + 1)) ? childrenRaw.map((c) => mapMenuNode(c, depth + 1))
: undefined; : undefined;
@@ -108,7 +109,7 @@ function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
const redirect = item.redirect?.trim() || undefined; const redirect = item.redirect?.trim() || undefined;
const hasKids = !!(children && children.length > 0); const hasKids = !!(children && children.length > 0);
const isDirectory = item.type === 1; const isDirectory = item.type === MenuTypeEnum.CATALOG;
let component: string | undefined; let component: string | undefined;
if (isDirectory || (hasKids && !(item.component_path ?? "").trim())) { if (isDirectory || (hasKids && !(item.component_path ?? "").trim())) {
@@ -144,7 +145,7 @@ function mapMenuNode(item: MenuTable, depth = 0): AppRouteRecord {
* 过滤按钮(type=3),再递归映射为 AppRouteRecord * 过滤按钮(type=3),再递归映射为 AppRouteRecord
*/ */
export function backendMenusToAppRoutes(menus: MenuTable[]): AppRouteRecord[] { export function backendMenusToAppRoutes(menus: MenuTable[]): AppRouteRecord[] {
const roots = menus.filter((m) => m.type !== 3); const roots = menus.filter((m) => m.type !== MenuTypeEnum.BUTTON);
const normalized = normalizeMenuNestedPaths(roots); const normalized = normalizeMenuNestedPaths(roots);
const mapped = normalized.map((m) => mapMenuNode(m, 0)); const mapped = normalized.map((m) => mapMenuNode(m, 0));
return normalizeAppRouteChildPaths(mapped); return normalizeAppRouteChildPaths(mapped);
@@ -89,7 +89,6 @@ const dashboardMenuSource: AppRouteRecord = {
title: "menus.dashboard.pricing", title: "menus.dashboard.pricing",
icon: "ri:money-cny-box-line", icon: "ri:money-cny-box-line",
keepAlive: true, keepAlive: true,
isFullPage: true,
}, },
}, },
], ],
@@ -43,7 +43,7 @@ import { useWorktabStore } from "./worktab.store";
import { useMenuStore } from "./menu.store"; import { useMenuStore } from "./menu.store";
import { AppRouteRecord } from "@/types/router"; import { AppRouteRecord } from "@/types/router";
import { setPageTitle } from "@/utils/navigation/router"; import { setPageTitle } from "@/utils/navigation/router";
import { resetRouterState } from "@/router/guards/beforeEach"; import { resetRouterState, resetRouteInitState } from "@/router/guards/beforeEach";
import { StorageConfig } from "@/utils/storage/storage-config"; import { StorageConfig } from "@/utils/storage/storage-config";
import AuthAPI from "@/api/module_system/auth"; import AuthAPI from "@/api/module_system/auth";
import UserAPI from "@/api/module_system/user"; import UserAPI from "@/api/module_system/user";
@@ -277,6 +277,8 @@ export const useUserStore = defineStore(
}); });
} }
rememberMe.value = loginForm.remember; rememberMe.value = loginForm.remember;
// 清除上次会话里「动态路由初始化失败」标记,避免重新登录后侧栏/菜单不注册
resetRouteInitState();
Auth.setTokens(data.access_token, data.refresh_token, rememberMe.value); Auth.setTokens(data.access_token, data.refresh_token, rememberMe.value);
setToken(data.access_token, data.refresh_token); setToken(data.access_token, data.refresh_token);
setLoginStatus(true); setLoginStatus(true);
@@ -117,8 +117,6 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
fixedTab?: boolean; fixedTab?: boolean;
/** 激活菜单路径 */ /** 激活菜单路径 */
activePath?: string; activePath?: string;
/** 是否为全屏页面 */
isFullPage?: boolean;
/** 是否为权限按钮行 */ /** 是否为权限按钮行 */
isAuthButton?: boolean; isAuthButton?: boolean;
/** 权限标识 */ /** 权限标识 */
@@ -30,19 +30,14 @@ export function isIframe(url: string): boolean {
} }
/** /**
* 判断菜单项是否可作为默认导航落点 * 判断菜单项是否可作为默认导航落点(隐藏菜单不作为首选路径)
* 隐藏的全屏页面虽然不展示在菜单中,但仍然可能是合法首页。
*/ */
export const isNavigableMenuItem = (menuItem: AppRouteRecord): boolean => { export const isNavigableMenuItem = (menuItem: AppRouteRecord): boolean => {
if (!menuItem.path || !menuItem.path.trim()) { if (!menuItem.path || !menuItem.path.trim()) {
return false; return false;
} }
if (!menuItem.meta?.isHide) { return !menuItem.meta?.isHide;
return true;
}
return menuItem.meta?.isFullPage === true;
}; };
/** /**
+1 -1
View File
@@ -6,7 +6,7 @@ const asciiArt = `
\x1b[0m \x1b[0m
\x1b[33mGitHub: https://github.com/fastapiadmin/FastapiAdmin \x1b[33mGitHub: https://github.com/fastapiadmin/FastapiAdmin
\x1b[0m \x1b[0m
\x1b[31m技术支持(微信): 948080782,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌! \x1b[31m技术支持(社区群): https://service.fastapiadmin.com/about/,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌!
\x1b[0m \x1b[0m
`; `;
@@ -9,3 +9,5 @@ export * from "./tableCache";
export * from "./tableConfig"; export * from "./tableConfig";
export * from "./tableUtils"; export * from "./tableUtils";
export * from "./renderTableOperationCell";
@@ -0,0 +1,124 @@
/**
* 表格操作列:最多展示 `maxInline` 个 ArtButtonTable,其余收入 ArtButtonMore(与 demo 一致)。
*/
import { h } from "vue";
import type { VNode } from "vue";
import { ElTooltip } from "element-plus";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import type { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
/** 默认外露操作数:超过则出现「更多」 */
export const DEFAULT_MAX_INLINE_TABLE_OPERATIONS = 3;
export interface TableOperationAction {
key: string | number;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
/** 权限码,传给 ArtButtonMore 的 auth */
perm?: string;
disabled?: boolean;
/** 图标颜色(外露按钮与「更多」内图标);不传则按 artType 使用主题变量 */
iconColor?: string;
/** 「更多」里文字颜色;不传时对 key 为 delete 默认红色 */
color?: string;
run: () => void;
}
export interface RenderTableOperationCellOptions {
maxInline?: number;
/** 包裹容器 class */
wrapperClass?: string;
emptyText?: string;
}
/** 与 `ArtButtonTable` 各 type 默认图标一致,保证「更多」里每项都有图标 */
const ART_TYPE_DEFAULT_ICONS: Record<TableOperationAction["artType"], string> = {
add: "ri:add-fill",
edit: "ri:pencil-line",
delete: "ri:delete-bin-5-line",
view: "ri:eye-line",
more: "ri:more-2-fill",
};
function iconForOperation(a: TableOperationAction): string {
return a.icon ?? ART_TYPE_DEFAULT_ICONS[a.artType];
}
/** 与 Element Plus 语义色一致,便于各操作图标区分 */
const ART_TYPE_ICON_COLORS: Record<TableOperationAction["artType"], string> = {
add: "var(--el-color-primary)",
edit: "var(--el-color-success)",
delete: "var(--el-color-danger)",
view: "var(--el-color-info)",
more: "var(--el-text-color-regular)",
};
function iconColorForOperation(a: TableOperationAction): string | undefined {
if (a.iconColor != null) return a.iconColor;
return ART_TYPE_ICON_COLORS[a.artType];
}
function defaultMoreItemColor(a: TableOperationAction): string | undefined {
if (a.color != null) return a.color;
return String(a.key) === "delete" ? "var(--el-color-danger)" : undefined;
}
export function renderTableOperationCell(
actions: TableOperationAction[],
options?: RenderTableOperationCellOptions
): VNode {
const maxInline = options?.maxInline ?? DEFAULT_MAX_INLINE_TABLE_OPERATIONS;
const wrapperClass =
options?.wrapperClass ?? "inline-flex flex-wrap items-center justify-end gap-1";
const emptyText = options?.emptyText ?? "—";
if (actions.length === 0) {
return h("span", { class: "text-g-400" }, emptyText);
}
const inline = actions.slice(0, maxInline);
const overflow = actions.slice(maxInline);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h(
"span",
{
class: a.disabled ? "inline-flex opacity-40 pointer-events-none" : "inline-flex",
},
[
h(ArtButtonTable, {
type: a.artType,
icon: iconForOperation(a),
iconColor: iconColorForOperation(a),
onClick: a.run,
}),
]
)
)
);
if (overflow.length === 0) {
return h("div", { class: wrapperClass }, inlineNodes);
}
const moreDropdown = h(ArtButtonMore, {
list: overflow.map((a) => ({
key: a.key,
label: a.label,
icon: iconForOperation(a),
auth: a.perm,
disabled: a.disabled,
iconColor: iconColorForOperation(a),
color: defaultMoreItemColor(a),
})),
onClick: (item: ButtonMoreItem) => {
const act = overflow.find((x) => String(x.key) === String(item.key));
act?.run();
},
});
return h("div", { class: wrapperClass }, [...inlineNodes, moreDropdown]);
}
@@ -39,10 +39,10 @@ export const tableConfig = {
recordFields: ["list", "data", "records", "items", "result", "rows"], recordFields: ["list", "data", "records", "items", "result", "rows"],
// 总条数 // 总条数
totalFields: ["total", "count"], totalFields: ["total", "count"],
// 当前页码 // 当前页码(含后端常用 page_no
currentFields: ["current", "page", "pageNum"], currentFields: ["current", "page", "pageNum", "page_no"],
// 每页大小 // 每页大小(含后端常用 page_size
sizeFields: ["size", "pageSize", "limit"], sizeFields: ["size", "pageSize", "limit", "page_size"],
// 请求参数映射配置,前端发送请求时使用的分页参数名 // 请求参数映射配置,前端发送请求时使用的分页参数名
// useTable 组合式函数传递分页参数的时候 用 current 跟 size // useTable 组合式函数传递分页参数的时候 用 current 跟 size
+25 -1
View File
@@ -42,6 +42,25 @@
import type { ApiResponse } from "./tableCache"; import type { ApiResponse } from "./tableCache";
import { tableConfig } from "./tableConfig"; import { tableConfig } from "./tableConfig";
/**
* 请求层若未拆包,这里拿到的是 Axios 整包:业务体在 .data 里。
* 否则「list」后的「data」会把 { code, msg, data } 误当成一层列表容器,读不到内层 items。
*/
function unwrapAxiosResponseBody(response: unknown): unknown {
if (response === null || typeof response !== "object") return response;
const r = response as Record<string, unknown>;
if (
"data" in r &&
"status" in r &&
typeof r.status === "number" &&
"config" in r &&
typeof r.config === "object"
) {
return r.data;
}
return response;
}
// 请求参数基础接口,扩展分页参数 // 请求参数基础接口,扩展分页参数
export interface BaseRequestParams extends PageQuery { export interface BaseRequestParams extends PageQuery {
[key: string]: unknown; [key: string]: unknown;
@@ -115,6 +134,8 @@ export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> =>
// 定义支持的字段 // 定义支持的字段
const recordFields = tableConfig.recordFields; const recordFields = tableConfig.recordFields;
response = unwrapAxiosResponseBody(response);
if (!response) { if (!response) {
return { records: [], total: 0 }; return { records: [], total: 0 };
} }
@@ -184,7 +205,10 @@ export const updatePaginationFromResponse = <T>(
pagination: PageQuery, pagination: PageQuery,
response: ApiResponse<T> response: ApiResponse<T>
): void => { ): void => {
pagination.page_size = response.total ?? pagination.page_size ?? 10; const total = response.total;
if (typeof total === "number") {
(pagination as Record<string, unknown>).total = total;
}
}; };
/** /**
@@ -172,7 +172,7 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
import { h, ref, reactive, computed, nextTick } from "vue"; import { ref, reactive, computed, nextTick } from "vue";
import { Edit } from "@element-plus/icons-vue"; import { Edit } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import AiChatAPI, { type ChatSession, type ChatSessionDetail } from "@/api/module_ai/chat"; import AiChatAPI, { type ChatSession, type ChatSessionDetail } from "@/api/module_ai/chat";
@@ -184,9 +184,9 @@ import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/i
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import type { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import type { ColumnOption } from "@/types/component"; import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
type MemorySearchForm = { type MemorySearchForm = {
title?: string; title?: string;
@@ -212,6 +212,8 @@ const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof ArtSearchBar> | null>(null); const searchBarRef = ref<InstanceType<typeof ArtSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {}; const searchBarRules: Record<string, unknown> = {};
const { hasAuth } = useAuth();
const memorySearchItems = computed<SearchFormItem[]>(() => [ const memorySearchItems = computed<SearchFormItem[]>(() => [
{ {
label: "标题", label: "标题",
@@ -269,17 +271,6 @@ function onTableSelectionChange(rows: ChatSession[]) {
selectedRows.value = rows; selectedRows.value = rows;
} }
function buttonMoreClick(item: ButtonMoreItem, row: ChatSession) {
switch (item.key) {
case "detail":
void handleOpenDialog("detail", row.id);
break;
case "delete":
void deleteSessionRow(row.id);
break;
}
}
function deleteSessionRow(id: string) { function deleteSessionRow(id: string) {
ElMessageBox.confirm("确认删除该项数据?", "警告", { ElMessageBox.confirm("确认删除该项数据?", "警告", {
confirmButtonText: "确定", confirmButtonText: "确定",
@@ -407,30 +398,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 102, width: 160,
fixed: "right", fixed: "right",
align: "center", align: "right",
formatter: (row: ChatSession) => formatter: (row: ChatSession) => formatMemoryOperationCell(row),
h("div", [
h(ArtButtonMore, {
list: [
{
key: "detail",
label: "详情",
icon: "ri:file-list-3-line",
auth: "module_ai:chat:detail",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_ai:chat:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
]),
}, },
], ],
}, },
@@ -522,6 +493,37 @@ async function handleOpenDialog(type: "create" | "detail", id?: string) {
dialogVisible.visible = true; dialogVisible.visible = true;
} }
function buildMemoryRowActions(row: ChatSession): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_ai:chat:detail",
run: () => {
void handleOpenDialog("detail", row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_ai:chat:delete",
run: () => {
deleteSessionRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatMemoryOperationCell(row: ChatSession) {
return renderTableOperationCell(buildMemoryRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 memory-table-actions",
});
}
function handleEditTitle(row: ChatSession) { function handleEditTitle(row: ChatSession) {
editingRowId.value = row.id; editingRowId.value = row.id;
editingTitle.value = row.title || ""; editingTitle.value = row.title || "";
@@ -269,13 +269,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref } from "vue"; import { h, computed, ref } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue"; import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtImportDialog from "@/components/Core/modal/art-import-dialog/index.vue"; import ArtImportDialog from "@/components/Core/modal/art-import-dialog/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IContentConfig, IObject } from "@/components/Core/modal/types"; import type { IContentConfig, IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBarWithAudit from "@/components/Core/forms/art-search-bar/ArtSearchBarWithAudit.vue"; import ArtSearchBarWithAudit from "@/components/Core/forms/art-search-bar/ArtSearchBarWithAudit.vue";
import type { AuditSearchFormParams } from "@/components/Core/forms/art-search-bar/auditSearchFormItems"; import type { AuditSearchFormParams } from "@/components/Core/forms/art-search-bar/auditSearchFormItems";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -294,6 +294,8 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const { hasAuth } = useAuth();
type DemoSearchFormParams = { name?: string; status?: string } & AuditSearchFormParams; type DemoSearchFormParams = { name?: string; status?: string } & AuditSearchFormParams;
function normalizeDemoQuery(params: Record<string, unknown>): DemoPageQuery { function normalizeDemoQuery(params: Record<string, unknown>): DemoPageQuery {
@@ -436,19 +438,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 102, width: 220,
fixed: "right", fixed: "right",
formatter: (row: DemoTable) => align: "right",
h("div", [ formatter: (row: DemoTable) => formatDemoOperationCell(row),
h(ArtButtonMore, {
list: [
{ key: "detail", label: "详情", icon: "ri:file-list-3-line" },
{ key: "edit", label: "编辑", icon: "ri:edit-2-line" },
{ key: "delete", label: "删除", icon: "ri:delete-bin-4-line", color: "#f56c6c" },
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
]),
}, },
], ],
}, },
@@ -591,19 +584,41 @@ const onResetSearch = async () => {
await resetSearchParams(); await resetSearchParams();
}; };
const buttonMoreClick = (item: ButtonMoreItem, row: DemoTable) => { function buildDemoRowActions(row: DemoTable): TableOperationAction[] {
switch (item.key) { const all: TableOperationAction[] = [
case "detail": {
openDetailDialog(row); key: "detail",
break; label: "详情",
case "edit": artType: "view",
openEditDialog("edit", row); icon: "ri:file-list-3-line",
break; perm: "module_example:demo:detail",
case "delete": run: () => void openDetailDialog(row),
deleteDemoRow(row); },
break; {
} key: "edit",
}; label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_example:demo:update",
run: () => void openEditDialog("edit", row),
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_example:demo:delete",
run: () => deleteDemoRow(row),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatDemoOperationCell(row: DemoTable) {
return renderTableOperationCell(buildDemoRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 demo-table-actions",
});
}
async function openDetailDialog(row: DemoTable) { async function openDetailDialog(row: DemoTable) {
if (!row.id) return; if (!row.id) return;
@@ -155,13 +155,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref, reactive } from "vue"; import { h, computed, ref, reactive } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue"; import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtImportDialog from "@/components/Core/modal/art-import-dialog/index.vue"; import ArtImportDialog from "@/components/Core/modal/art-import-dialog/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IContentConfig, IObject } from "@/components/Core/modal/types"; import type { IContentConfig, IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBarWithAudit from "@/components/Core/forms/art-search-bar/ArtSearchBarWithAudit.vue"; import ArtSearchBarWithAudit from "@/components/Core/forms/art-search-bar/ArtSearchBarWithAudit.vue";
import type { AuditSearchFormParams } from "@/components/Core/forms/art-search-bar/auditSearchFormItems"; import type { AuditSearchFormParams } from "@/components/Core/forms/art-search-bar/auditSearchFormItems";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -179,6 +179,8 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const { hasAuth } = useAuth();
type Demo01SearchFormParams = { type Demo01SearchFormParams = {
name?: string; name?: string;
description?: string; description?: string;
@@ -310,19 +312,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 120, width: 220,
fixed: "right", fixed: "right",
formatter: (row: Demo01Table) => align: "right",
h("div", [ formatter: (row: Demo01Table) => formatDemo01OperationCell(row),
h(ArtButtonMore, {
list: [
{ key: "detail", label: "详情", icon: "ri:file-list-3-line" },
{ key: "edit", label: "编辑", icon: "ri:edit-2-line" },
{ key: "delete", label: "删除", icon: "ri:delete-bin-4-line", color: "#f56c6c" },
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
]),
}, },
], ],
}, },
@@ -447,19 +440,41 @@ const onResetSearch = async () => {
await resetSearchParams(); await resetSearchParams();
}; };
const buttonMoreClick = (item: ButtonMoreItem, row: Demo01Table) => { function buildDemo01RowActions(row: Demo01Table): TableOperationAction[] {
switch (item.key) { const all: TableOperationAction[] = [
case "detail": {
openDetailDialog(row); key: "detail",
break; label: "详情",
case "edit": artType: "view",
openEditDialog("edit", row); icon: "ri:file-list-3-line",
break; perm: "module_example:demo01:detail",
case "delete": run: () => void openDetailDialog(row),
deleteDemo01Row(row); },
break; {
} key: "edit",
}; label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_example:demo01:update",
run: () => void openEditDialog("edit", row),
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_example:demo01:delete",
run: () => deleteDemo01Row(row),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatDemo01OperationCell(row: Demo01Table) {
return renderTableOperationCell(buildDemo01RowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 demo01-table-actions",
});
}
async function openDetailDialog(row: Demo01Table) { async function openDetailDialog(row: Demo01Table) {
if (!row.id) return; if (!row.id) return;
@@ -138,7 +138,7 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
import { h, ref, reactive, computed, onActivated, watch, nextTick, provide } from "vue"; import { ref, reactive, computed, onActivated, watch, nextTick, provide } from "vue";
import { useClipboard } from "@vueuse/core"; import { useClipboard } from "@vueuse/core";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import type { EditorConfiguration } from "codemirror"; import type { EditorConfiguration } from "codemirror";
@@ -160,9 +160,9 @@ import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import type { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import type { ColumnOption } from "@/types/component"; import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import CreateTableDialog, { type CreateTableSubmitMeta } from "./components/CreateTableDialog.vue"; import CreateTableDialog, { type CreateTableSubmitMeta } from "./components/CreateTableDialog.vue";
import GenCodeDrawer from "./components/GenCodeDrawer.vue"; import GenCodeDrawer from "./components/GenCodeDrawer.vue";
import ImportDbTableDialog from "./components/ImportDbTableDialog.vue"; import ImportDbTableDialog from "./components/ImportDbTableDialog.vue";
@@ -192,6 +192,7 @@ const listRefresh = {
}; };
const route = useRoute(); const route = useRoute();
const { hasAuth } = useAuth();
provide(GENCODE_BASIC_FORM_KEY, basicInfo); provide(GENCODE_BASIC_FORM_KEY, basicInfo);
provide(GENCODE_CM_KEY, cmRef); provide(GENCODE_CM_KEY, cmRef);
@@ -763,18 +764,42 @@ type GencodeSearchForm = {
table_comment?: string; table_comment?: string;
}; };
function buttonMoreClick(item: ButtonMoreItem, row: GenTableSchema) { function buildGencodeRowActions(row: GenTableSchema): TableOperationAction[] {
switch (item.key) { const all: TableOperationAction[] = [
case "preview": {
void handlePreviewTable(row); key: "preview",
break; label: "代码生成",
case "delete": artType: "edit",
void handleDelete(row); icon: "ri:magic-line",
break; iconColor: "var(--el-color-primary)",
case "sync": perm: "module_generator:gencode:update",
void handleSynchDb(row); run: () => void handlePreviewTable(row),
break; },
} {
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_generator:gencode:delete",
run: () => void handleDelete(row),
},
{
key: "sync",
label: "同步",
artType: "view",
icon: "ri:refresh-line",
iconColor: "var(--el-color-primary)",
perm: "module_generator:db:sync",
run: () => void handleSynchDb(row),
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatGencodeOperationCell(row: GenTableSchema) {
return renderTableOperationCell(buildGencodeRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 gencode-table-actions",
});
} }
const searchForm = ref<GencodeSearchForm>({ const searchForm = ref<GencodeSearchForm>({
@@ -862,36 +887,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 100, width: 220,
fixed: "right", fixed: "right",
align: "center", align: "right",
formatter: (row: GenTableSchema) => formatter: (row: GenTableSchema) => formatGencodeOperationCell(row),
h("div", [
h(ArtButtonMore, {
list: [
{
key: "preview",
label: "代码生成",
icon: "ri:magic-stick-line",
auth: "module_generator:gencode:update",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_generator:gencode:delete",
},
{
key: "sync",
label: "同步",
icon: "ri:refresh-line",
auth: "module_generator:db:sync",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
]),
}, },
], ],
}, },
@@ -18,11 +18,11 @@
/> />
<ElCard <ElCard
class="flex flex-col flex-1 min-h-0 art-table-card" class="resource-monitor-card flex flex-col flex-1 min-h-0 art-table-card"
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
> >
<div <div
class="resource-breadcrumb mb-3 flex flex-wrap items-center gap-2 border-b border-g-200 pb-3" class="resource-breadcrumb mb-3 shrink-0 flex flex-wrap items-center gap-2 border-b border-g-200 pb-3"
> >
<ElTooltip content="资源文件管理系统: 点击路径可以快速返回上级目录"> <ElTooltip content="资源文件管理系统: 点击路径可以快速返回上级目录">
<QuestionFilled class="mx-1 h-4 w-4" /> <QuestionFilled class="mx-1 h-4 w-4" />
@@ -43,9 +43,9 @@
</div> </div>
<ArtTableHeader <ArtTableHeader
class="resource-toolbar shrink-0"
v-model:columns="columnChecks" v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar" v-model:showSearchBar="showSearchBar"
layout="search,refresh,size,fullscreen"
:loading="loading" :loading="loading"
@refresh="refreshData" @refresh="refreshData"
> >
@@ -91,16 +91,20 @@
</template> </template>
</ArtTableHeader> </ArtTableHeader>
<ArtTable <!-- 面包屑与表头已占用高度表格高度须在独立 flex 子项内计算否则分页会被挤出卡片 -->
row-key="file_url" <div class="resource-table-region min-h-0 flex flex-1 flex-col overflow-hidden pb-3">
:loading="loading" <ArtTable
:data="data" row-key="file_url"
:columns="columns" :show-table-header="false"
:pagination="paginationBind" :loading="loading"
@selection-change="onTableSelectionChange" :data="data"
@pagination:size-change="handleSizeChange" :columns="columns"
@pagination:current-change="handleCurrentChange" :pagination="paginationBind"
/> @selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</div>
</ElCard> </ElCard>
<ArtDialog <ArtDialog
@@ -644,6 +648,13 @@ async function handleBatchDelete() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.resource-monitor-page :deep(.resource-monitor-card.el-card > .el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
:deep(.el-breadcrumb__item) { :deep(.el-breadcrumb__item) {
&.is-link { &.is-link {
color: var(--el-color-primary); color: var(--el-color-primary);
@@ -178,29 +178,20 @@ import DeptAPI, {
import ArtTable from "@/components/Core/tables/art-table/index.vue"; import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
import { import { ElMessage, ElMessageBox, ElTag } from "element-plus";
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
import { useAuth } from "@/hooks/core/useAuth"; import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@/store"; import { useUserStore } from "@/store";
import { formatTree } from "@/utils/common"; import { formatTree } from "@/utils/common";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
defineOptions({ defineOptions({
name: "Dept", name: "Dept",
inheritAttrs: false, inheritAttrs: false,
}); });
const MAX_INLINE_ROW_ACTIONS = 3;
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -219,16 +210,6 @@ function buildDeptQuery(p: DeptSearchForm): DeptPageQuery {
}; };
} }
type RowAction = {
key: string;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildDeptRowActions( function buildDeptRowActions(
row: DeptTable, row: DeptTable,
ctx: { ctx: {
@@ -237,8 +218,8 @@ function buildDeptRowActions(
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
} }
): RowAction[] { ): TableOperationAction[] {
const all: RowAction[] = [ const all: TableOperationAction[] = [
{ {
key: "add", key: "add",
label: "新增", label: "新增",
@@ -268,74 +249,14 @@ function buildDeptRowActions(
run: () => ctx.onDelete(row.id!), run: () => ctx.onDelete(row.id!),
}, },
]; ];
return all.filter((a) => hasAuth(a.perm)); return all.filter((a) => a.perm != null && hasAuth(a.perm));
} }
function formatDeptOperationCell(row: DeptTable, ctx: Parameters<typeof buildDeptRowActions>[1]) { function formatDeptOperationCell(row: DeptTable, ctx: Parameters<typeof buildDeptRowActions>[1]) {
const actions = buildDeptRowActions(row, ctx); const actions = buildDeptRowActions(row, ctx);
if (actions.length === 0) { return renderTableOperationCell(actions, {
return h("span", { class: "text-g-400" }, "—"); wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 dept-table-actions",
} });
const inline = actions.slice(0, MAX_INLINE_ROW_ACTIONS);
const overflow = actions.slice(MAX_INLINE_ROW_ACTIONS);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: a.artType,
icon: a.icon,
onClick: a.run,
}),
])
)
);
if (overflow.length === 0) {
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 dept-table-actions" },
inlineNodes
);
}
const dropdown = h(
ElDropdown,
{ trigger: "click" },
{
default: () =>
h(ElTooltip, { content: "更多", placement: "top" }, () =>
h("span", { class: "inline-flex align-middle" }, [
h(ArtButtonTable, {
type: "more",
onClick: () => {},
}),
])
),
dropdown: () =>
h(
ElDropdownMenu,
null,
overflow.map((a) =>
h(
ElDropdownItem,
{
key: a.key,
disabled: a.disabled,
onClick: () => a.run(),
},
() => a.label
)
)
),
}
);
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 dept-table-actions" },
[...inlineNodes, dropdown]
);
} }
const searchForm = ref<DeptSearchForm>({ const searchForm = ref<DeptSearchForm>({
@@ -267,12 +267,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref, reactive } from "vue"; import { h, computed, ref, reactive } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IObject } from "@/components/Core/modal/types"; import type { IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -284,6 +282,8 @@ import DictAPI, {
type DictDataTable, type DictDataTable,
} from "@/api/module_system/dict"; } from "@/api/module_system/dict";
import { ElMessage, ElMessageBox, ElTag } from "element-plus"; import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useDictStore } from "@/store"; import { useDictStore } from "@/store";
import { useAppStore } from "@/store/modules/app.store"; import { useAppStore } from "@/store/modules/app.store";
import { DeviceEnum } from "@/enums/settings/device.enum"; import { DeviceEnum } from "@/enums/settings/device.enum";
@@ -328,6 +328,7 @@ const TAG_TYPE_STYLE_MAP: Record<string, { background: string; color: string; bo
const appStore = useAppStore(); const appStore = useAppStore();
const dictStore = useDictStore(); const dictStore = useDictStore();
const { hasAuth } = useAuth();
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "80%" : "60%")); const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "80%" : "60%"));
function getTagPreviewStyle(value?: string) { function getTagPreviewStyle(value?: string) {
@@ -398,7 +399,7 @@ const statusOptions = ref([
const dictDataSearchItems = computed<SearchFormItem[]>(() => [ const dictDataSearchItems = computed<SearchFormItem[]>(() => [
{ {
label: "字典数据标签", label: "字典标签",
key: "dict_label", key: "dict_label",
type: "input", type: "input",
placeholder: "请输入字典标签", placeholder: "请输入字典标签",
@@ -509,33 +510,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 120, width: 220,
fixed: "right", fixed: "right",
formatter: (row: DictDataTable) => align: "right",
h(ArtButtonMore, { formatter: (row: DictDataTable) => formatDictDataOperationCell(row),
list: [
{
key: "detail",
label: "详情",
icon: "ri:file-list-3-line",
auth: "module_system:dict_data:detail",
},
{
key: "edit",
label: "编辑",
icon: "ri:edit-2-line",
auth: "module_system:dict_data:update",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_system:dict_data:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
}, },
], ],
}, },
@@ -667,20 +645,6 @@ function onResetSearch() {
void resetSearchParams(); void resetSearchParams();
} }
function buttonMoreClick(item: ButtonMoreItem, row: DictDataTable) {
switch (item.key) {
case "detail":
void handleOpenDialog("detail", row.id);
break;
case "edit":
void handleOpenDialog("update", row.id);
break;
case "delete":
if (row.id != null) deleteDictDataRow(row.id);
break;
}
}
async function resetForm() { async function resetForm() {
if (dataFormRef.value) { if (dataFormRef.value) {
dataFormRef.value.resetFields(); dataFormRef.value.resetFields();
@@ -744,6 +708,43 @@ async function handleSubmit() {
}); });
} }
function buildDictDataRowActions(row: DictDataTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:dict_data:detail",
run: () => void handleOpenDialog("detail", row.id),
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_system:dict_data:update",
run: () => void handleOpenDialog("update", row.id),
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:dict_data:delete",
run: () => {
if (row.id != null) deleteDictDataRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatDictDataOperationCell(row: DictDataTable) {
return renderTableOperationCell(buildDictDataRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 dict-data-drawer-actions",
});
}
function deleteDictDataRow(id: number) { function deleteDictDataRow(id: number) {
ElMessageBox.confirm("确认删除该项数据?", "警告", { ElMessageBox.confirm("确认删除该项数据?", "警告", {
confirmButtonText: "确定", confirmButtonText: "确定",
@@ -1,4 +1,4 @@
<!-- 字典类型 demo 同一套 Art 布局接口 DictAPI / fetchGetDictTypeList --> <!-- 字典类型Art 布局操作列最多 3 个外露 +更多 -->
<template> <template>
<div class="art-full-height"> <div class="art-full-height">
<ArtSearchBar <ArtSearchBar
@@ -152,12 +152,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref, reactive } from "vue"; import { h, computed, ref, reactive } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IObject } from "@/components/Core/modal/types"; import type { IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -170,6 +168,8 @@ import DictAPI, {
import { ElMessage, ElMessageBox, ElTag } from "element-plus"; import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { useDictStore } from "@/store"; import { useDictStore } from "@/store";
import DataDrawer from "@/views/module_system/dict/components/DataDrawer.vue"; import DataDrawer from "@/views/module_system/dict/components/DataDrawer.vue";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
defineOptions({ defineOptions({
name: "Dict", name: "Dict",
@@ -191,6 +191,7 @@ function normalizeDictTypeQuery(params: Record<string, unknown>): DictPageQuery
} }
const dictStore = useDictStore(); const dictStore = useDictStore();
const { hasAuth } = useAuth();
const searchForm = ref<DictTypeSearchForm>({ const searchForm = ref<DictTypeSearchForm>({
dict_name: undefined, dict_name: undefined,
@@ -314,39 +315,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 120, width: 220,
fixed: "right", fixed: "right",
formatter: (row: DictTable) => align: "right",
h(ArtButtonMore, { formatter: (row: DictTable) => formatDictOperationCell(row),
list: [
{
key: "dictData",
label: "字典",
icon: "ri:book-2-line",
auth: "module_system:dict_data:query",
},
{
key: "detail",
label: "详情",
icon: "ri:file-list-3-line",
auth: "module_system:dict_type:detail",
},
{
key: "edit",
label: "编辑",
icon: "ri:edit-2-line",
auth: "module_system:dict_type:update",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_system:dict_type:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
}, },
], ],
}, },
@@ -462,21 +434,50 @@ function onResetSearch() {
void resetSearchParams(); void resetSearchParams();
} }
function buttonMoreClick(item: ButtonMoreItem, row: DictTable) { function buildDictRowActions(row: DictTable): TableOperationAction[] {
switch (item.key) { const all: TableOperationAction[] = [
case "dictData": {
handleDictDataDrawer(row); key: "dictData",
break; label: "字典",
case "detail": artType: "view",
void handleOpenDialog("detail", row.id); icon: "ri:book-2-line",
break; iconColor: "var(--el-color-warning)",
case "edit": perm: "module_system:dict_data:query",
void handleOpenDialog("update", row.id); run: () => handleDictDataDrawer(row),
break; },
case "delete": {
if (row.id != null) deleteDictTypeRow(row.id); key: "detail",
break; label: "详情",
} artType: "view",
perm: "module_system:dict_type:detail",
run: () => void handleOpenDialog("detail", row.id),
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_system:dict_type:update",
run: () => void handleOpenDialog("update", row.id),
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:dict_type:delete",
run: () => {
if (row.id != null) deleteDictTypeRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatDictOperationCell(row: DictTable) {
return renderTableOperationCell(buildDictRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 dict-table-actions",
});
} }
function handleDictDataDrawer(dictTypeRow: DictTable) { function handleDictDataDrawer(dictTypeRow: DictTable) {
@@ -144,14 +144,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref, nextTick } from "vue"; import { h, computed, ref, nextTick } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTable from "@/components/Core/tables/art-table/index.vue"; import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IObject } from "@/components/Core/modal/types"; import type { IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -160,6 +158,8 @@ import CopyButton from "@/components/CopyButton/index.vue";
import type { ColumnOption } from "@/types/component"; import type { ColumnOption } from "@/types/component";
import LogAPI, { type LogPageQuery, type LogTable } from "@/api/module_system/log"; import LogAPI, { type LogPageQuery, type LogTable } from "@/api/module_system/log";
import { ElMessage, ElMessageBox, ElTag } from "element-plus"; import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import UserTableSelect from "@/views/module_system/user/components/UserTableSelect.vue"; import UserTableSelect from "@/views/module_system/user/components/UserTableSelect.vue";
defineOptions({ defineOptions({
@@ -167,6 +167,8 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const { hasAuth } = useAuth();
type LogSearchForm = { type LogSearchForm = {
request_path?: string; request_path?: string;
type?: number; type?: number;
@@ -348,27 +350,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 100, width: 160,
fixed: "right", fixed: "right",
formatter: (row: LogTable) => align: "right",
h(ArtButtonMore, { formatter: (row: LogTable) => formatLogOperationCell(row),
list: [
{
key: "detail",
label: "详情",
icon: "ri:file-list-3-line",
auth: "module_system:log:detail",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_system:log:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
}, },
], ],
}, },
@@ -473,17 +458,6 @@ function onResetSearch() {
void resetSearchParams(); void resetSearchParams();
} }
function buttonMoreClick(item: ButtonMoreItem, row: LogTable) {
switch (item.key) {
case "detail":
if (row.id != null) void handleOpenDialog(row.id);
break;
case "delete":
if (row.id != null) deleteLogRow(row.id);
break;
}
}
async function resetForm() { async function resetForm() {
formData.value = {}; formData.value = {};
} }
@@ -515,6 +489,37 @@ function deleteLogRow(id: number) {
.catch(() => {}); .catch(() => {});
} }
function buildLogRowActions(row: LogTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:log:detail",
run: () => {
if (row.id != null) void handleOpenDialog(row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:log:delete",
run: () => {
if (row.id != null) deleteLogRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatLogOperationCell(row: LogTable) {
return renderTableOperationCell(buildLogRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 log-table-actions",
});
}
function handleBatchDelete() { function handleBatchDelete() {
const ids = selectedIds.value; const ids = selectedIds.value;
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -1,4 +1,4 @@
<!-- 菜单管理Art + 树形表格对齐 system/menu 模版 --> <!-- 菜单管理Art + 树形表格操作列最多 3 个外露其余更多 -->
<template> <template>
<div class="art-full-height"> <div class="art-full-height">
<ArtSearchBar <ArtSearchBar
@@ -526,22 +526,13 @@ import MenuRouteIcon from "@/components/MenuRouteIcon/index.vue";
import ArtTable from "@/components/Core/tables/art-table/index.vue"; import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDrawer from "@/components/Core/modal/art-drawer/index.vue"; import ArtDrawer from "@/components/Core/modal/art-drawer/index.vue";
import { import { ElMessage, ElMessageBox, ElTag, ElTooltip } from "element-plus";
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
import { useAuth } from "@/hooks/core/useAuth"; import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
const MAX_INLINE_ROW_ACTIONS = 3;
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const appStore = useAppStore(); const appStore = useAppStore();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -580,16 +571,6 @@ function ynTag(v: boolean | undefined, yes = "是", no = "否") {
return h(ElTag, { type: v ? "success" : "danger" }, () => (v ? yes : no)); return h(ElTag, { type: v ? "success" : "danger" }, () => (v ? yes : no));
} }
type RowAction = {
key: string;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildMenuRowActions( function buildMenuRowActions(
row: MenuTable, row: MenuTable,
ctx: { ctx: {
@@ -598,8 +579,8 @@ function buildMenuRowActions(
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
} }
): RowAction[] { ): TableOperationAction[] {
const actions: RowAction[] = []; const actions: TableOperationAction[] = [];
if (ctx.onAdd && (row.type === MenuTypeEnum.CATALOG || row.type === MenuTypeEnum.MENU)) { if (ctx.onAdd && (row.type === MenuTypeEnum.CATALOG || row.type === MenuTypeEnum.MENU)) {
actions.push({ actions.push({
key: "add", key: "add",
@@ -632,74 +613,14 @@ function buildMenuRowActions(
run: () => ctx.onDelete(row.id!), run: () => ctx.onDelete(row.id!),
} }
); );
return actions.filter((a) => hasAuth(a.perm)); return actions.filter((a) => a.perm != null && hasAuth(a.perm));
} }
function formatMenuOperationCell(row: MenuTable, ctx: Parameters<typeof buildMenuRowActions>[1]) { function formatMenuOperationCell(row: MenuTable, ctx: Parameters<typeof buildMenuRowActions>[1]) {
const actions = buildMenuRowActions(row, ctx); const actions = buildMenuRowActions(row, ctx);
if (actions.length === 0) { return renderTableOperationCell(actions, {
return h("span", { class: "text-g-400" }, "—"); wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 menu-table-actions",
} });
const inline = actions.slice(0, MAX_INLINE_ROW_ACTIONS);
const overflow = actions.slice(MAX_INLINE_ROW_ACTIONS);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: a.artType,
icon: a.icon,
onClick: a.run,
}),
])
)
);
if (overflow.length === 0) {
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 menu-table-actions" },
inlineNodes
);
}
const dropdown = h(
ElDropdown,
{ trigger: "click" },
{
default: () =>
h(ElTooltip, { content: "更多", placement: "top" }, () =>
h("span", { class: "inline-flex align-middle" }, [
h(ArtButtonTable, {
type: "more",
onClick: () => {},
}),
])
),
dropdown: () =>
h(
ElDropdownMenu,
null,
overflow.map((a) =>
h(
ElDropdownItem,
{
key: a.key,
disabled: a.disabled,
onClick: () => a.run(),
},
() => a.label
)
)
),
}
);
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 menu-table-actions" },
[...inlineNodes, dropdown]
);
} }
const menuClientTab = ref<"pc" | "app">("pc"); const menuClientTab = ref<"pc" | "app">("pc");
@@ -997,7 +918,7 @@ const { columnChecks, columns } = useTableColumns<MenuTable>(() => [
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 260, width: 220,
fixed: "right", fixed: "right",
align: "right", align: "right",
formatter: (row: MenuTable) => formatMenuOperationCell(row, opCtx), formatter: (row: MenuTable) => formatMenuOperationCell(row, opCtx),
@@ -185,14 +185,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref, reactive, nextTick, onMounted } from "vue"; import { h, computed, ref, reactive, nextTick, onMounted } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTable from "@/components/Core/tables/art-table/index.vue"; import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IObject } from "@/components/Core/modal/types"; import type { IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -203,6 +201,8 @@ import NoticeAPI, {
type NoticeTable, type NoticeTable,
} from "@/api/module_system/notice"; } from "@/api/module_system/notice";
import { ElMessage, ElMessageBox, ElTag } from "element-plus"; import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useDictStore, useNoticeStore } from "@/store/index"; import { useDictStore, useNoticeStore } from "@/store/index";
import UserTableSelect from "@/views/module_system/user/components/UserTableSelect.vue"; import UserTableSelect from "@/views/module_system/user/components/UserTableSelect.vue";
@@ -213,6 +213,7 @@ defineOptions({
const dictStore = useDictStore(); const dictStore = useDictStore();
const noticeStore = useNoticeStore(); const noticeStore = useNoticeStore();
const { hasAuth } = useAuth();
type NoticeSearchForm = { type NoticeSearchForm = {
notice_title?: string; notice_title?: string;
@@ -391,33 +392,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 120, width: 220,
fixed: "right", fixed: "right",
formatter: (row: NoticeTable) => align: "right",
h(ArtButtonMore, { formatter: (row: NoticeTable) => formatNoticeOperationCell(row),
list: [
{
key: "detail",
label: "详情",
icon: "ri:file-list-3-line",
auth: "module_system:notice:detail",
},
{
key: "edit",
label: "编辑",
icon: "ri:edit-2-line",
auth: "module_system:notice:update",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_system:notice:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
}, },
], ],
}, },
@@ -555,20 +533,6 @@ function onResetSearch() {
void resetSearchParams(); void resetSearchParams();
} }
function buttonMoreClick(item: ButtonMoreItem, row: NoticeTable) {
switch (item.key) {
case "detail":
if (row.id != null) void handleOpenDialog("detail", row.id);
break;
case "edit":
if (row.id != null) void handleOpenDialog("update", row.id);
break;
case "delete":
if (row.id != null) deleteNoticeRow(row.id);
break;
}
}
async function resetForm() { async function resetForm() {
if (dataFormRef.value) { if (dataFormRef.value) {
dataFormRef.value.resetFields(); dataFormRef.value.resetFields();
@@ -641,6 +605,47 @@ function deleteNoticeRow(id: number) {
.catch(() => {}); .catch(() => {});
} }
function buildNoticeRowActions(row: NoticeTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:notice:detail",
run: () => {
if (row.id != null) void handleOpenDialog("detail", row.id);
},
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_system:notice:update",
run: () => {
if (row.id != null) void handleOpenDialog("update", row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:notice:delete",
run: () => {
if (row.id != null) deleteNoticeRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatNoticeOperationCell(row: NoticeTable) {
return renderTableOperationCell(buildNoticeRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 notice-table-actions",
});
}
function handleBatchDelete() { function handleBatchDelete() {
const ids = selectedIds.value; const ids = selectedIds.value;
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -161,14 +161,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { h, computed, ref, reactive } from "vue"; import { h, computed, ref, reactive } from "vue";
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import ArtTable from "@/components/Core/tables/art-table/index.vue"; import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import type { IObject } from "@/components/Core/modal/types"; import type { IObject } from "@/components/Core/modal/types";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue"; import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
@@ -179,6 +177,8 @@ import ParamsAPI, {
type ConfigTable, type ConfigTable,
} from "@/api/module_system/params"; } from "@/api/module_system/params";
import { ElMessage, ElMessageBox, ElTag } from "element-plus"; import { ElMessage, ElMessageBox, ElTag } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useConfigStore } from "@/store"; import { useConfigStore } from "@/store";
defineOptions({ defineOptions({
@@ -187,6 +187,7 @@ defineOptions({
}); });
const configStore = useConfigStore(); const configStore = useConfigStore();
const { hasAuth } = useAuth();
type ParamSearchForm = { type ParamSearchForm = {
config_name?: string; config_name?: string;
@@ -329,33 +330,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 120, width: 220,
fixed: "right", fixed: "right",
formatter: (row: ConfigTable) => align: "right",
h(ArtButtonMore, { formatter: (row: ConfigTable) => formatParamOperationCell(row),
list: [
{
key: "detail",
label: "详情",
icon: "ri:file-list-3-line",
auth: "module_system:param:detail",
},
{
key: "edit",
label: "编辑",
icon: "ri:edit-2-line",
auth: "module_system:param:update",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_system:param:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
}, },
], ],
}, },
@@ -462,20 +440,6 @@ function onResetSearch() {
void resetSearchParams(); void resetSearchParams();
} }
function buttonMoreClick(item: ButtonMoreItem, row: ConfigTable) {
switch (item.key) {
case "detail":
if (row.id != null) void handleOpenDialog("detail", row.id);
break;
case "edit":
if (row.id != null) void handleOpenDialog("update", row.id);
break;
case "delete":
if (row.id != null) deleteParamRow(row.id);
break;
}
}
async function resetForm() { async function resetForm() {
if (dataFormRef.value) { if (dataFormRef.value) {
dataFormRef.value.resetFields(); dataFormRef.value.resetFields();
@@ -550,6 +514,47 @@ function deleteParamRow(id: number) {
.catch(() => {}); .catch(() => {});
} }
function buildParamRowActions(row: ConfigTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_system:param:detail",
run: () => {
if (row.id != null) void handleOpenDialog("detail", row.id);
},
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_system:param:update",
run: () => {
if (row.id != null) void handleOpenDialog("update", row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:param:delete",
run: () => {
if (row.id != null) deleteParamRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatParamOperationCell(row: ConfigTable) {
return renderTableOperationCell(buildParamRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 param-table-actions",
});
}
function handleBatchDelete() { function handleBatchDelete() {
const ids = selectedIds.value; const ids = selectedIds.value;
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -1,4 +1,4 @@
<!-- 角色管理Art + useTable操作列 3 ArtButtonTable其余收入更多下拉对齐 system/menu --> <!-- 角色管理Art + useTable操作列最多 3 外露 +更多 -->
<template> <template>
<div class="art-full-height"> <div class="art-full-height">
<ArtSearchBar <ArtSearchBar
@@ -201,7 +201,7 @@ import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue"; import ArtExportDialog from "@/components/Core/modal/art-export-dialog/index.vue";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue"; import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import type { IObject } from "@/components/Core/modal/types"; import type { IObject } from "@/components/Core/modal/types";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
@@ -212,15 +212,7 @@ import RoleAPI, {
type RoleTable, type RoleTable,
type TablePageQuery, type TablePageQuery,
} from "@/api/module_system/role"; } from "@/api/module_system/role";
import { import { ElMessage, ElMessageBox, ElTag } from "element-plus";
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
} from "element-plus";
import { useAuth } from "@/hooks/core/useAuth"; import { useAuth } from "@/hooks/core/useAuth";
import { useUserStore } from "@/store"; import { useUserStore } from "@/store";
import PermissonDrawer from "./components/PermissonDrawer.vue"; import PermissonDrawer from "./components/PermissonDrawer.vue";
@@ -230,8 +222,6 @@ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}); });
const MAX_INLINE_ROW_ACTIONS = 3;
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
type RoleSearchForm = { type RoleSearchForm = {
@@ -292,17 +282,6 @@ function deptsCell(row: RoleTable) {
return h("span", { class: "inline-flex flex-wrap items-center" }, tags); return h("span", { class: "inline-flex flex-wrap items-center" }, tags);
} }
type RowAction = {
key: string;
label: string;
/** ArtButtonTable 预设类型,分配权限用 edit + 自定义图标 */
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildRoleRowActions( function buildRoleRowActions(
row: RoleTable, row: RoleTable,
ctx: { ctx: {
@@ -311,22 +290,25 @@ function buildRoleRowActions(
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
} }
): RowAction[] { ): TableOperationAction[] {
const isSys = row.id === 1; const isSys = row.id === 1;
const warn = () => { const warnSys = () => ElMessage.warning("系统默认角色,不可操作");
ElMessage.warning("系统默认角色,不可操作");
}; const all: TableOperationAction[] = [
const all: RowAction[] = [
{ {
key: "perm", key: "perm",
label: "分配权限", label: "分配权限",
artType: "edit", artType: "view",
icon: "ri:shield-keyhole-line", icon: "ri:shield-keyhole-line",
iconColor: "var(--el-color-primary)",
perm: "module_system:role:permission", perm: "module_system:role:permission",
disabled: isSys, disabled: isSys,
run: () => { run: () => {
if (isSys) warn(); if (isSys) {
else ctx.onPerm(row.id, row.name); warnSys();
return;
}
ctx.onPerm(row.id!, row.name);
}, },
}, },
{ {
@@ -334,99 +316,46 @@ function buildRoleRowActions(
label: "详情", label: "详情",
artType: "view", artType: "view",
perm: "module_system:role:detail", perm: "module_system:role:detail",
run: () => ctx.onDetail(row.id), run: () => ctx.onDetail(row.id!),
}, },
{ {
key: "edit", key: "edit",
label: "编辑", label: "编辑",
artType: "edit", artType: "edit",
icon: "ri:edit-2-line",
perm: "module_system:role:update", perm: "module_system:role:update",
disabled: isSys, disabled: isSys,
run: () => { run: () => {
if (isSys) warn(); if (isSys) {
else ctx.onEdit(row.id); warnSys();
return;
}
ctx.onEdit(row.id!);
}, },
}, },
{ {
key: "delete", key: "delete",
label: "删除", label: "删除",
artType: "delete", artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_system:role:delete", perm: "module_system:role:delete",
disabled: isSys, disabled: isSys,
run: () => { run: () => {
if (isSys) warn(); if (isSys) {
else ctx.onDelete(row.id); warnSys();
return;
}
ctx.onDelete(row.id!);
}, },
}, },
]; ];
return all.filter((a) => hasAuth(a.perm)); return all.filter((a) => a.perm != null && hasAuth(a.perm));
} }
function formatRoleOperationCell(row: RoleTable, ctx: Parameters<typeof buildRoleRowActions>[1]) { function formatRoleOperationCell(row: RoleTable, ctx: Parameters<typeof buildRoleRowActions>[1]) {
const actions = buildRoleRowActions(row, ctx); return renderTableOperationCell(buildRoleRowActions(row, ctx), {
if (actions.length === 0) { wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 role-table-actions",
return h("span", { class: "text-g-400" }, "—"); });
}
const inline = actions.slice(0, MAX_INLINE_ROW_ACTIONS);
const overflow = actions.slice(MAX_INLINE_ROW_ACTIONS);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: a.artType,
icon: a.icon,
onClick: a.run,
}),
])
)
);
if (overflow.length === 0) {
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 role-table-actions" },
inlineNodes
);
}
const dropdown = h(
ElDropdown,
{ trigger: "click" },
{
default: () =>
h(ElTooltip, { content: "更多", placement: "top" }, () =>
h("span", { class: "inline-flex align-middle" }, [
h(ArtButtonTable, {
type: "more",
onClick: () => {},
}),
])
),
dropdown: () =>
h(
ElDropdownMenu,
null,
overflow.map((a) =>
h(
ElDropdownItem,
{
key: a.key,
disabled: a.disabled,
onClick: () => a.run(),
},
() => a.label
)
)
),
}
);
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 role-table-actions" },
[...inlineNodes, dropdown]
);
} }
const searchForm = ref<RoleSearchForm>({ const searchForm = ref<RoleSearchForm>({
@@ -579,7 +508,7 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 200, width: 220,
fixed: "right", fixed: "right",
align: "right", align: "right",
formatter: (row: RoleTable) => formatRoleOperationCell(row, opCtx), formatter: (row: RoleTable) => formatRoleOperationCell(row, opCtx),
@@ -329,7 +329,6 @@ import { useTable } from "@/hooks/core/useTable";
import ArtTable from "@/components/Core/tables/art-table/index.vue"; import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"; import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtDrawer from "@/components/Core/modal/art-drawer/index.vue"; import ArtDrawer from "@/components/Core/modal/art-drawer/index.vue";
@@ -348,20 +347,11 @@ import RoleAPI from "@/api/module_system/role";
import DeptTree from "./components/DeptTree.vue"; import DeptTree from "./components/DeptTree.vue";
import UserTableSelect from "./components/UserTableSelect.vue"; import UserTableSelect from "./components/UserTableSelect.vue";
import { useUserStore } from "@/store"; import { useUserStore } from "@/store";
import { import { ElMessage, ElMessageBox, ElTag, ElAvatar } from "element-plus";
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElAvatar,
} from "element-plus";
import { useAuth } from "@/hooks/core/useAuth"; import { useAuth } from "@/hooks/core/useAuth";
import type { ColumnOption } from "@/types/component"; import type { ColumnOption } from "@/types/component";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
const MAX_INLINE_ROW_ACTIONS = 3;
const { hasAuth } = useAuth(); const { hasAuth } = useAuth();
const appStore = useAppStore(); const appStore = useAppStore();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -403,16 +393,6 @@ function fetchUserTableList(params: Record<string, unknown>) {
}); });
} }
type RowAction = {
key: string;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildUserRowActions( function buildUserRowActions(
row: UserInfo, row: UserInfo,
ctx: { ctx: {
@@ -421,9 +401,9 @@ function buildUserRowActions(
onEdit: (id: number) => void; onEdit: (id: number) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
} }
): RowAction[] { ): TableOperationAction[] {
const sys = row.is_superuser === true; const sys = row.is_superuser === true;
const all: RowAction[] = [ const all: TableOperationAction[] = [
{ {
key: "resetPwd", key: "resetPwd",
label: "重置密码", label: "重置密码",
@@ -466,80 +446,14 @@ function buildUserRowActions(
}, },
}, },
]; ];
return all.filter((a) => hasAuth(a.perm)); return all.filter((a) => a.perm != null && hasAuth(a.perm));
} }
function formatUserOperationCell(row: UserInfo, ctx: Parameters<typeof buildUserRowActions>[1]) { function formatUserOperationCell(row: UserInfo, ctx: Parameters<typeof buildUserRowActions>[1]) {
const actions = buildUserRowActions(row, ctx); const actions = buildUserRowActions(row, ctx);
if (actions.length === 0) { return renderTableOperationCell(actions, {
return h("span", { class: "text-g-400" }, "—"); wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 user-table-actions",
} });
const inline = actions.slice(0, MAX_INLINE_ROW_ACTIONS);
const overflow = actions.slice(MAX_INLINE_ROW_ACTIONS);
const inlineNodes = inline.map((a) =>
h(ElTooltip, { content: a.label, placement: "top" }, () =>
h(
"span",
{
class: a.disabled ? "inline-flex opacity-40 pointer-events-none" : "inline-flex",
},
[
h(ArtButtonTable, {
type: a.artType,
icon: a.icon,
onClick: a.run,
}),
]
)
)
);
if (overflow.length === 0) {
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 user-table-actions" },
inlineNodes
);
}
const dropdown = h(
ElDropdown,
{ trigger: "click" },
{
default: () =>
h(ElTooltip, { content: "更多", placement: "top" }, () =>
h("span", { class: "inline-flex align-middle" }, [
h(ArtButtonTable, {
type: "more",
onClick: () => {},
}),
])
),
dropdown: () =>
h(
ElDropdownMenu,
null,
overflow.map((a) =>
h(
ElDropdownItem,
{
key: a.key,
disabled: a.disabled,
onClick: () => a.run(),
},
() => a.label
)
)
),
}
);
return h(
"div",
{ class: "inline-flex flex-wrap items-center justify-end gap-1 user-table-actions" },
[...inlineNodes, dropdown]
);
} }
const dataFormRef = ref(); const dataFormRef = ref();
@@ -317,11 +317,11 @@ import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue"
import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue"; import ArtTableHeaderLeft from "@/components/Core/tables/art-table-header-left/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue"; import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import type { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import type { ColumnOption } from "@/types/component"; import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
import { renderTableOperationCell, type TableOperationAction } from "@/utils/table";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import { h, computed, nextTick, onMounted, reactive, ref } from "vue"; import { computed, nextTick, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { vue3CronPlus } from "vue3-cron-plus"; import { vue3CronPlus } from "vue3-cron-plus";
import "vue3-cron-plus/dist/index.css"; import "vue3-cron-plus/dist/index.css";
@@ -332,6 +332,7 @@ import "codemirror/mode/python/python.js";
import "codemirror/theme/dracula.css"; import "codemirror/theme/dracula.css";
const dictStore = useDictStore(); const dictStore = useDictStore();
const { hasAuth } = useAuth();
const BATCH_DELETE_NODE_MSG = const BATCH_DELETE_NODE_MSG =
"确认删除选中的节点吗?\n" + "确认删除选中的节点吗?\n" +
@@ -389,20 +390,6 @@ function onTableSelectionChange(rows: NodeTable[]) {
selectedRows.value = rows; selectedRows.value = rows;
} }
function buttonMoreClick(item: ButtonMoreItem, row: NodeTable) {
switch (item.key) {
case "execute":
handleOpenExecuteDialog(row);
break;
case "edit":
if (row.id != null) void handleOpenDialog("update", row.id);
break;
case "delete":
void deleteNodeRow(row.id);
break;
}
}
function deleteNodeRow(id: number | undefined) { function deleteNodeRow(id: number | undefined) {
if (id == null) return; if (id == null) return;
ElMessageBox.confirm("确认删除该节点吗?将从调度器移除相关任务。", "警告", { ElMessageBox.confirm("确认删除该节点吗?将从调度器移除相关任务。", "警告", {
@@ -419,6 +406,47 @@ function deleteNodeRow(id: number | undefined) {
.catch(() => {}); .catch(() => {});
} }
function buildNodeRowActions(row: NodeTable): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "execute",
label: "调试",
artType: "more",
icon: "ri:play-circle-line",
iconColor: "var(--el-color-primary)",
perm: "module_task:cronjob:node:execute",
run: () => handleOpenExecuteDialog(row),
},
{
key: "edit",
label: "编辑",
artType: "edit",
icon: "ri:edit-2-line",
perm: "module_task:cronjob:node:update",
run: () => {
if (row.id != null) void handleOpenDialog("update", row.id);
},
},
{
key: "delete",
label: "删除",
artType: "delete",
icon: "ri:delete-bin-4-line",
perm: "module_task:cronjob:node:delete",
run: () => {
void deleteNodeRow(row.id);
},
},
];
return all.filter((a) => a.perm != null && hasAuth(a.perm));
}
function formatNodeOperationCell(row: NodeTable) {
return renderTableOperationCell(buildNodeRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 cronjob-node-table-actions",
});
}
function handleBatchDelete() { function handleBatchDelete() {
const ids = selectedIds.value; const ids = selectedIds.value;
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -498,36 +526,10 @@ const {
{ {
prop: "operation", prop: "operation",
label: "操作", label: "操作",
width: 88, width: 220,
fixed: "right", fixed: "right",
align: "center", align: "right",
formatter: (row: NodeTable) => formatter: (row: NodeTable) => formatNodeOperationCell(row),
h("div", [
h(ArtButtonMore, {
list: [
{
key: "execute",
label: "调试",
icon: "ri:play-circle-line",
auth: "module_task:cronjob:node:execute",
},
{
key: "edit",
label: "编辑",
icon: "ri:edit-2-line",
auth: "module_task:cronjob:node:update",
},
{
key: "delete",
label: "删除",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
auth: "module_task:cronjob:node:delete",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
]),
}, },
], ],
}, },
@@ -1,479 +0,0 @@
<!-- 菜单管理页面 -->
<template>
<div class="menu-page art-full-height">
<!-- 搜索栏 -->
<ArtSearchBar
v-model="formFilters"
:items="formItems"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
/>
<ElCard class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader
:showZebra="false"
:loading="loading"
v-model:columns="columnChecks"
@refresh="handleRefresh"
>
<template #left>
<ElButton v-auth="'add'" @click="handleAddMenu" v-ripple>添加菜单</ElButton>
<ElButton @click="toggleExpand" v-ripple>
{{ isExpanded ? "收起" : "展开" }}
</ElButton>
</template>
</ArtTableHeader>
<ArtTable
ref="tableRef"
rowKey="path"
:loading="loading"
:columns="columns"
:data="filteredTableData"
:stripe="false"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
/>
<!-- 菜单弹窗 -->
<MenuDialog
v-model:visible="dialogVisible"
:type="dialogType"
:editData="editData"
:lockType="lockMenuType"
@submit="handleSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { formatMenuTitle } from "@/utils/navigation/router";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import { useTableColumns } from "@/hooks/core/useTableColumns";
import type { AppRouteRecord } from "@/types/router";
import MenuDialog from "./modules/menu-dialog.vue";
import { fetchGetMenuList } from "@/api/system-manage";
import { ElTag, ElMessageBox } from "element-plus";
defineOptions({ name: "Menus" });
//
const loading = ref(false);
const isExpanded = ref(false);
const tableRef = ref();
//
const dialogVisible = ref(false);
const dialogType = ref<"menu" | "button">("menu");
const editData = ref<AppRouteRecord | any>(null);
const lockMenuType = ref(false);
//
const initialSearchState = {
name: "",
route: "",
};
const formFilters = reactive({ ...initialSearchState });
const appliedFilters = reactive({ ...initialSearchState });
const formItems = computed(() => [
{
label: "菜单名称",
key: "name",
type: "input",
props: { clearable: true },
},
{
label: "路由地址",
key: "route",
type: "input",
props: { clearable: true },
},
]);
onMounted(() => {
getMenuList();
});
/**
* 获取菜单列表数据
*/
const getMenuList = async (): Promise<void> => {
loading.value = true;
try {
const list = await fetchGetMenuList();
tableData.value = list;
} catch (error) {
throw error instanceof Error ? error : new Error("获取菜单失败");
} finally {
loading.value = false;
}
};
/**
* 获取菜单类型标签颜色
* @param row 菜单行数据
* @returns 标签颜色类型
*/
const getMenuTypeTag = (
row: AppRouteRecord
): "primary" | "success" | "warning" | "info" | "danger" => {
if (row.meta?.isAuthButton) return "danger";
if (row.children?.length) return "info";
if (row.meta?.link && row.meta?.isIframe) return "success";
if (row.path) return "primary";
if (row.meta?.link) return "warning";
return "info";
};
/**
* 获取菜单类型文本
* @param row 菜单行数据
* @returns 菜单类型文本
*/
const getMenuTypeText = (row: AppRouteRecord): string => {
if (row.meta?.isAuthButton) return "按钮";
if (row.children?.length) return "目录";
if (row.meta?.link && row.meta?.isIframe) return "内嵌";
if (row.path) return "菜单";
if (row.meta?.link) return "外链";
return "未知";
};
//
const { columnChecks, columns } = useTableColumns(() => [
{
prop: "meta.title",
label: "菜单名称",
minWidth: 120,
formatter: (row: AppRouteRecord) => formatMenuTitle(row.meta?.title),
},
{
prop: "type",
label: "菜单类型",
formatter: (row: AppRouteRecord) => {
return h(ElTag, { type: getMenuTypeTag(row) }, () => getMenuTypeText(row));
},
},
{
prop: "path",
label: "路由",
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) return "";
return row.meta?.link || row.path || "";
},
},
{
prop: "meta.authList",
label: "权限标识",
formatter: (row: AppRouteRecord) => {
if (row.meta?.isAuthButton) {
return row.meta?.authMark || "";
}
if (!row.meta?.authList?.length) return "";
return `${row.meta.authList.length} 个权限标识`;
},
},
{
prop: "date",
label: "编辑时间",
formatter: () => "2022-3-12 12:00:00",
},
{
prop: "status",
label: "状态",
formatter: () => h(ElTag, { type: "success" }, () => "启用"),
},
{
prop: "operation",
label: "操作",
width: 180,
align: "right",
formatter: (row: AppRouteRecord) => {
const buttonStyle = { style: "text-align: right" };
if (row.meta?.isAuthButton) {
return h("div", buttonStyle, [
h(ArtButtonTable, {
type: "edit",
onClick: () => handleEditAuth(row),
}),
h(ArtButtonTable, {
type: "delete",
onClick: () => handleDeleteAuth(),
}),
]);
}
return h("div", buttonStyle, [
h(ArtButtonTable, {
type: "add",
onClick: () => handleAddAuth(),
title: "新增权限",
}),
h(ArtButtonTable, {
type: "edit",
onClick: () => handleEditMenu(row),
}),
h(ArtButtonTable, {
type: "delete",
onClick: () => handleDeleteMenu(),
}),
]);
},
},
]);
//
const tableData = ref<AppRouteRecord[]>([]);
/**
* 重置搜索条件
*/
const handleReset = (): void => {
Object.assign(formFilters, { ...initialSearchState });
Object.assign(appliedFilters, { ...initialSearchState });
getMenuList();
};
/**
* 执行搜索
*/
const handleSearch = (): void => {
Object.assign(appliedFilters, { ...formFilters });
getMenuList();
};
/**
* 刷新菜单列表
*/
const handleRefresh = (): void => {
getMenuList();
};
/**
* 深度克隆对象
* @param obj 要克隆的对象
* @returns 克隆后的对象
*/
const deepClone = <T,>(obj: T): T => {
if (obj === null || typeof obj !== "object") return obj;
if (obj instanceof Date) return new Date(obj) as T;
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
const cloned = {} as T;
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloned[key] = deepClone(obj[key]);
}
}
return cloned;
};
/**
* 将权限列表转换为子节点
* @param items 菜单项数组
* @returns 转换后的菜单项数组
*/
const convertAuthListToChildren = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items.map((item) => {
const clonedItem = deepClone(item);
if (clonedItem.children?.length) {
clonedItem.children = convertAuthListToChildren(clonedItem.children);
}
if (item.meta?.authList?.length) {
const authChildren: AppRouteRecord[] = item.meta.authList.map(
(auth: { title: string; authMark: string }) => ({
path: `${item.path}_auth_${auth.authMark}`,
name: `${String(item.name)}_auth_${auth.authMark}`,
meta: {
title: auth.title,
authMark: auth.authMark,
isAuthButton: true,
parentPath: item.path,
},
})
);
clonedItem.children = clonedItem.children?.length
? [...clonedItem.children, ...authChildren]
: authChildren;
}
return clonedItem;
});
};
/**
* 搜索菜单
* @param items 菜单项数组
* @returns 搜索结果数组
*/
const searchMenu = (items: AppRouteRecord[]): AppRouteRecord[] => {
const results: AppRouteRecord[] = [];
for (const item of items) {
const searchName = appliedFilters.name?.toLowerCase().trim() || "";
const searchRoute = appliedFilters.route?.toLowerCase().trim() || "";
const menuTitle = formatMenuTitle(item.meta?.title || "").toLowerCase();
const menuPath = (item.path || "").toLowerCase();
const nameMatch = !searchName || menuTitle.includes(searchName);
const routeMatch = !searchRoute || menuPath.includes(searchRoute);
if (item.children?.length) {
const matchedChildren = searchMenu(item.children);
if (matchedChildren.length > 0) {
const clonedItem = deepClone(item);
clonedItem.children = matchedChildren;
results.push(clonedItem);
continue;
}
}
if (nameMatch && routeMatch) {
results.push(deepClone(item));
}
}
return results;
};
//
const filteredTableData = computed(() => {
const searchedData = searchMenu(tableData.value);
return convertAuthListToChildren(searchedData);
});
/**
* 添加菜单
*/
const handleAddMenu = (): void => {
dialogType.value = "menu";
editData.value = null;
lockMenuType.value = true;
dialogVisible.value = true;
};
/**
* 添加权限按钮
*/
const handleAddAuth = (): void => {
dialogType.value = "menu";
editData.value = null;
lockMenuType.value = false;
dialogVisible.value = true;
};
/**
* 编辑菜单
* @param row 菜单行数据
*/
const handleEditMenu = (row: AppRouteRecord): void => {
dialogType.value = "menu";
editData.value = row;
lockMenuType.value = true;
dialogVisible.value = true;
};
/**
* 编辑权限按钮
* @param row 权限行数据
*/
const handleEditAuth = (row: AppRouteRecord): void => {
dialogType.value = "button";
editData.value = {
title: row.meta?.title,
authMark: row.meta?.authMark,
};
lockMenuType.value = false;
dialogVisible.value = true;
};
/**
* 菜单表单数据类型
*/
interface MenuFormData {
name: string;
path: string;
component?: string;
icon?: string;
roles?: string[];
sort?: number;
[key: string]: any;
}
/**
* 提交表单数据
* @param formData 表单数据
*/
const handleSubmit = (formData: MenuFormData): void => {
console.log("提交数据:", formData);
// TODO: API
getMenuList();
};
/**
* 删除菜单
*/
const handleDeleteMenu = async (): Promise<void> => {
try {
await ElMessageBox.confirm("确定要删除该菜单吗?删除后无法恢复", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
ElMessage.success("删除成功");
getMenuList();
} catch (error) {
if (error !== "cancel") {
ElMessage.error("删除失败");
}
}
};
/**
* 删除权限按钮
*/
const handleDeleteAuth = async (): Promise<void> => {
try {
await ElMessageBox.confirm("确定要删除该权限吗?删除后无法恢复", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
ElMessage.success("删除成功");
getMenuList();
} catch (error) {
if (error !== "cancel") {
ElMessage.error("删除失败");
}
}
};
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value;
nextTick(() => {
if (tableRef.value?.elTableRef && filteredTableData.value) {
const processRows = (rows: AppRouteRecord[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value);
processRows(row.children);
}
});
};
processRows(filteredTableData.value);
}
});
};
</script>
@@ -1,384 +0,0 @@
<template>
<ElDialog
:title="dialogTitle"
:model-value="visible"
@update:model-value="handleCancel"
width="860px"
align-center
class="menu-dialog"
@closed="handleClosed"
>
<ArtForm
ref="formRef"
v-model="form"
:items="formItems"
:rules="rules"
:span="width > 640 ? 12 : 24"
:gutter="20"
label-width="100px"
:show-reset="false"
:show-submit="false"
>
<template #menuType>
<ElRadioGroup v-model="form.menuType" :disabled="disableMenuType">
<ElRadioButton value="menu" label="menu">菜单</ElRadioButton>
<ElRadioButton value="button" label="button">按钮</ElRadioButton>
</ElRadioGroup>
</template>
</ArtForm>
<template #footer>
<span class="dialog-footer">
<ElButton @click="handleCancel"> </ElButton>
<ElButton type="primary" @click="handleSubmit"> </ElButton>
</span>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormRules } from "element-plus";
import { ElIcon, ElTooltip } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { formatMenuTitle } from "@/utils/navigation/router";
import type { AppRouteRecord } from "@/types/router";
import type { FormItem } from "@/components/Core/forms/art-form/index.vue";
import ArtForm from "@/components/Core/forms/art-form/index.vue";
import { useWindowSize } from "@vueuse/core";
const { width } = useWindowSize();
/**
* 创建带 tooltip 的表单标签
* @param label 标签文本
* @param tooltip 提示文本
* @returns 渲染函数
*/
const createLabelTooltip = (label: string, tooltip: string) => {
return () =>
h("span", { class: "flex items-center" }, [
h("span", label),
h(
ElTooltip,
{
content: tooltip,
placement: "top",
},
() => h(ElIcon, { class: "ml-0.5 cursor-help" }, () => h(QuestionFilled))
),
]);
};
interface MenuFormData {
id: number;
name: string;
path: string;
label: string;
component: string;
icon: string;
isEnable: boolean;
sort: number;
isMenu: boolean;
keepAlive: boolean;
isHide: boolean;
isHideTab: boolean;
link: string;
isIframe: boolean;
showBadge: boolean;
showTextBadge: string;
fixedTab: boolean;
activePath: string;
roles: string[];
isFullPage: boolean;
authName: string;
authLabel: string;
authIcon: string;
authSort: number;
}
interface Props {
visible: boolean;
editData?: AppRouteRecord | any;
type?: "menu" | "button";
lockType?: boolean;
}
interface Emits {
(e: "update:visible", value: boolean): void;
(e: "submit", data: MenuFormData): void;
}
const props = withDefaults(defineProps<Props>(), {
visible: false,
type: "menu",
lockType: false,
});
const emit = defineEmits<Emits>();
const formRef = ref();
const isEdit = ref(false);
const form = reactive<MenuFormData & { menuType: "menu" | "button" }>({
menuType: "menu",
id: 0,
name: "",
path: "",
label: "",
component: "",
icon: "",
isEnable: true,
sort: 1,
isMenu: true,
keepAlive: true,
isHide: false,
isHideTab: false,
link: "",
isIframe: false,
showBadge: false,
showTextBadge: "",
fixedTab: false,
activePath: "",
roles: [],
isFullPage: false,
authName: "",
authLabel: "",
authIcon: "",
authSort: 1,
});
const rules = reactive<FormRules>({
name: [
{ required: true, message: "请输入菜单名称", trigger: "blur" },
{ min: 2, max: 20, message: "长度在 2 到 20 个字符", trigger: "blur" },
],
path: [{ required: true, message: "请输入路由地址", trigger: "blur" }],
label: [{ required: true, message: "输入权限标识", trigger: "blur" }],
authName: [{ required: true, message: "请输入权限名称", trigger: "blur" }],
authLabel: [{ required: true, message: "请输入权限标识", trigger: "blur" }],
});
/**
* 表单项配置
*/
const formItems = computed<FormItem[]>(() => {
const baseItems: FormItem[] = [{ label: "菜单类型", key: "menuType", span: 24 }];
// Switch span 12 6
const switchSpan = width.value < 640 ? 12 : 6;
if (form.menuType === "menu") {
return [
...baseItems,
{ label: "菜单名称", key: "name", type: "input", props: { placeholder: "菜单名称" } },
{
label: createLabelTooltip(
"路由地址",
"一级菜单:以 / 开头的绝对路径(如 /dashboard)\n二级及以下:相对路径(如 console、user"
),
key: "path",
type: "input",
props: { placeholder: "如:/dashboard 或 console" },
},
{ label: "权限标识", key: "label", type: "input", props: { placeholder: "如:User" } },
{
label: createLabelTooltip(
"组件路径",
"一级父级菜单:填写 /index/index\n具体页面:填写组件路径(如 /system/user\n目录菜单:留空"
),
key: "component",
type: "input",
props: { placeholder: "如:/system/user 或留空" },
},
{ label: "图标", key: "icon", type: "input", props: { placeholder: "如:ri:user-line" } },
{
label: createLabelTooltip(
"角色权限",
"仅用于前端权限模式:配置角色标识(如 R_SUPER、R_ADMIN\n后端权限模式:无需配置"
),
key: "roles",
type: "inputtag",
props: { placeholder: "输入角色标识后按回车,如:R_SUPER" },
},
{
label: "菜单排序",
key: "sort",
type: "number",
props: { min: 1, controlsPosition: "right", style: { width: "100%" } },
},
{
label: "外部链接",
key: "link",
type: "input",
props: { placeholder: "如:https://www.example.com" },
},
{
label: "文本徽章",
key: "showTextBadge",
type: "input",
props: { placeholder: "如:New、Hot" },
},
{
label: createLabelTooltip(
"激活路径",
'用于详情页等隐藏菜单,指定高亮显示的父级菜单路径\n例如:用户详情页高亮显示"用户管理"菜单'
),
key: "activePath",
type: "input",
props: { placeholder: "如:/system/user" },
},
{ label: "是否启用", key: "isEnable", type: "switch", span: switchSpan },
{ label: "页面缓存", key: "keepAlive", type: "switch", span: switchSpan },
{ label: "隐藏菜单", key: "isHide", type: "switch", span: switchSpan },
{ label: "是否内嵌", key: "isIframe", type: "switch", span: switchSpan },
{ label: "显示徽章", key: "showBadge", type: "switch", span: switchSpan },
{ label: "固定标签", key: "fixedTab", type: "switch", span: switchSpan },
{ label: "标签隐藏", key: "isHideTab", type: "switch", span: switchSpan },
{ label: "全屏页面", key: "isFullPage", type: "switch", span: switchSpan },
];
} else {
return [
...baseItems,
{
label: "权限名称",
key: "authName",
type: "input",
props: { placeholder: "如:新增、编辑、删除" },
},
{
label: "权限标识",
key: "authLabel",
type: "input",
props: { placeholder: "如:add、edit、delete" },
},
{
label: "权限排序",
key: "authSort",
type: "number",
props: { min: 1, controlsPosition: "right", style: { width: "100%" } },
},
];
}
});
const dialogTitle = computed(() => {
const type = form.menuType === "menu" ? "菜单" : "按钮";
return isEdit.value ? `编辑${type}` : `新建${type}`;
});
/**
* 是否禁用菜单类型切换
*/
const disableMenuType = computed(() => {
if (isEdit.value) return true;
if (!isEdit.value && form.menuType === "menu" && props.lockType) return true;
return false;
});
/**
* 重置表单数据
*/
const resetForm = (): void => {
formRef.value?.reset();
form.menuType = "menu";
};
/**
* 加载表单数据编辑模式
*/
const loadFormData = (): void => {
if (!props.editData) return;
isEdit.value = true;
if (form.menuType === "menu") {
const row = props.editData;
form.id = row.id || 0;
form.name = formatMenuTitle(row.meta?.title || "");
form.path = row.path || "";
form.label = row.name || "";
form.component = row.component || "";
form.icon = row.meta?.icon || "";
form.sort = row.meta?.sort || 1;
form.isMenu = row.meta?.isMenu ?? true;
form.keepAlive = row.meta?.keepAlive ?? false;
form.isHide = row.meta?.isHide ?? false;
form.isHideTab = row.meta?.isHideTab ?? false;
form.isEnable = row.meta?.isEnable ?? true;
form.link = row.meta?.link || "";
form.isIframe = row.meta?.isIframe ?? false;
form.showBadge = row.meta?.showBadge ?? false;
form.showTextBadge = row.meta?.showTextBadge || "";
form.fixedTab = row.meta?.fixedTab ?? false;
form.activePath = row.meta?.activePath || "";
form.roles = row.meta?.roles || [];
form.isFullPage = row.meta?.isFullPage ?? false;
} else {
const row = props.editData;
form.authName = row.title || "";
form.authLabel = row.authMark || "";
form.authIcon = row.icon || "";
form.authSort = row.sort || 1;
}
};
/**
* 提交表单
*/
const handleSubmit = async (): Promise<void> => {
if (!formRef.value) return;
try {
await formRef.value.validate();
emit("submit", { ...form });
ElMessage.success(`${isEdit.value ? "编辑" : "新增"}成功`);
handleCancel();
} catch {
ElMessage.error("表单校验失败,请检查输入");
}
};
/**
* 取消操作
*/
const handleCancel = (): void => {
emit("update:visible", false);
};
/**
* 对话框关闭后的回调
*/
const handleClosed = (): void => {
resetForm();
isEdit.value = false;
};
/**
* 监听对话框显示状态
*/
watch(
() => props.visible,
(newVal) => {
if (newVal) {
form.menuType = props.type;
nextTick(() => {
if (props.editData) {
loadFormData();
}
});
}
}
);
/**
* 监听菜单类型变化
*/
watch(
() => props.type,
(newType) => {
if (props.visible) {
form.menuType = newType;
}
}
);
</script>
@@ -1,239 +0,0 @@
<!-- 角色管理页面 -->
<template>
<div class="art-full-height">
<RoleSearch
v-show="showSearchBar"
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></RoleSearch>
<ElCard class="art-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<ArtTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElSpace wrap>
<ElButton @click="showDialog('add')" v-ripple>新增角色</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
></ArtTable>
</ElCard>
<!-- 角色编辑弹窗 -->
<RoleEditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:role-data="currentRoleData"
@success="refreshData"
/>
<!-- 菜单权限弹窗 -->
<RolePermissionDialog
v-model="permissionDialog"
:role-data="currentRoleData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
import { useTable } from "@/hooks/core/useTable";
import { fetchGetRoleList } from "@/api/system-manage";
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
import RoleSearch from "./modules/role-search.vue";
import RoleEditDialog from "./modules/role-edit-dialog.vue";
import RolePermissionDialog from "./modules/role-permission-dialog.vue";
import { ElTag, ElMessageBox } from "element-plus";
defineOptions({ name: "Role" });
type RoleListItem = RoleListItem;
type RoleSearchFormParams = RoleSearchParams & {
daterange?: string[];
};
//
const searchForm = ref<RoleSearchFormParams>({
roleName: undefined,
roleCode: undefined,
description: undefined,
enabled: undefined,
daterange: undefined,
});
const showSearchBar = ref(false);
const dialogVisible = ref(false);
const permissionDialog = ref(false);
const currentRoleData = ref<RoleListItem | undefined>(undefined);
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
} = useTable({
//
core: {
apiFn: fetchGetRoleList,
apiParams: {
current: 1,
size: 20,
},
// apiParams
excludeParams: ["daterange"],
columnsFactory: () => [
{
prop: "roleId",
label: "角色ID",
width: 100,
},
{
prop: "roleName",
label: "角色名称",
minWidth: 120,
},
{
prop: "roleCode",
label: "角色编码",
minWidth: 120,
},
{
prop: "description",
label: "角色描述",
minWidth: 150,
showOverflowTooltip: true,
},
{
prop: "enabled",
label: "角色状态",
width: 100,
formatter: (row) => {
const statusConfig = row.enabled
? { type: "success", text: "启用" }
: { type: "warning", text: "禁用" };
return h(
ElTag,
{ type: statusConfig.type as "success" | "warning" },
() => statusConfig.text
);
},
},
{
prop: "createTime",
label: "创建日期",
width: 180,
sortable: true,
},
{
prop: "operation",
label: "操作",
width: 80,
fixed: "right",
formatter: (row) =>
h("div", [
h(ArtButtonMore, {
list: [
{
key: "permission",
label: "菜单权限",
icon: "ri:user-3-line",
},
{
key: "edit",
label: "编辑角色",
icon: "ri:edit-2-line",
},
{
key: "delete",
label: "删除角色",
icon: "ri:delete-bin-4-line",
color: "#f56c6c",
},
],
onClick: (item: ButtonMoreItem) => buttonMoreClick(item, row),
}),
]),
},
],
},
});
const dialogType = ref<"add" | "edit">("add");
const showDialog = (type: "add" | "edit", row?: RoleListItem) => {
dialogVisible.value = true;
dialogType.value = type;
currentRoleData.value = row;
};
/**
* 搜索处理
* @param params 搜索参数
*/
const handleSearch = (params: RoleSearchFormParams) => {
// daterange startTime endTime
const { daterange, ...filtersParams } = params;
const [startTime, endTime] = Array.isArray(daterange) ? daterange : [null, null];
replaceSearchParams({ ...filtersParams, startTime, endTime });
getData();
};
const buttonMoreClick = (item: ButtonMoreItem, row: RoleListItem) => {
switch (item.key) {
case "permission":
showPermissionDialog(row);
break;
case "edit":
showDialog("edit", row);
break;
case "delete":
deleteRole(row);
break;
}
};
const showPermissionDialog = (row?: RoleListItem) => {
permissionDialog.value = true;
currentRoleData.value = row;
};
const deleteRole = (row: RoleListItem) => {
ElMessageBox.confirm(`确定删除角色"${row.roleName}"吗?此操作不可恢复!`, "删除确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(() => {
// TODO:
ElMessage.success("删除成功");
refreshData();
})
.catch(() => {
ElMessage.info("已取消删除");
});
};
</script>
@@ -1,162 +0,0 @@
<template>
<ElDialog
v-model="visible"
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
width="30%"
align-center
@close="handleClose"
>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="120px">
<ElFormItem label="角色名称" prop="roleName">
<ElInput v-model="form.roleName" placeholder="请输入角色名称" />
</ElFormItem>
<ElFormItem label="角色编码" prop="roleCode">
<ElInput v-model="form.roleCode" placeholder="请输入角色编码" />
</ElFormItem>
<ElFormItem label="描述" prop="description">
<ElInput
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</ElFormItem>
<ElFormItem label="启用">
<ElSwitch v-model="form.enabled" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="handleClose">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from "element-plus";
type RoleListItem = RoleListItem;
interface Props {
modelValue: boolean;
dialogType: "add" | "edit";
roleData?: RoleListItem;
}
interface Emits {
(e: "update:modelValue", value: boolean): void;
(e: "success"): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: "add",
roleData: undefined,
});
const emit = defineEmits<Emits>();
const formRef = ref<FormInstance>();
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
roleName: [
{ required: true, message: "请输入角色名称", trigger: "blur" },
{ min: 2, max: 20, message: "长度在 2 到 20 个字符", trigger: "blur" },
],
roleCode: [
{ required: true, message: "请输入角色编码", trigger: "blur" },
{ min: 2, max: 50, message: "长度在 2 到 50 个字符", trigger: "blur" },
],
description: [{ required: true, message: "请输入角色描述", trigger: "blur" }],
});
/**
* 表单数据
*/
const form = reactive<RoleListItem>({
roleId: 0,
roleName: "",
roleCode: "",
description: "",
createTime: "",
enabled: true,
});
/**
* 监听弹窗打开初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) initForm();
}
);
/**
* 监听角色数据变化更新表单
*/
watch(
() => props.roleData,
(newData) => {
if (newData && props.modelValue) initForm();
},
{ deep: true }
);
/**
* 初始化表单数据
* 根据弹窗类型填充表单或重置表单
*/
const initForm = () => {
if (props.dialogType === "edit" && props.roleData) {
Object.assign(form, props.roleData);
} else {
Object.assign(form, {
roleId: 0,
roleName: "",
roleCode: "",
description: "",
createTime: "",
enabled: true,
});
}
};
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false;
formRef.value?.resetFields();
};
/**
* 提交表单
* 验证通过后调用接口保存数据
*/
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
// TODO: /
const message = props.dialogType === "add" ? "新增成功" : "修改成功";
ElMessage.success(message);
emit("success");
handleClose();
} catch (error) {
console.log("表单验证失败:", error);
}
};
</script>
@@ -1,254 +0,0 @@
<template>
<ElDialog
v-model="visible"
title="菜单权限"
width="520px"
align-center
class="el-dialog-border"
@close="handleClose"
>
<ElScrollbar height="70vh">
<ElTree
ref="treeRef"
:data="processedMenuList"
show-checkbox
node-key="name"
:default-expand-all="isExpandAll"
:default-checked-keys="[1, 2, 3]"
:props="defaultProps"
@check="handleTreeCheck"
>
<template #default="{ data }">
<div style="display: flex; align-items: center">
<span v-if="data.isAuth">
{{ data.label }}
</span>
<span v-else>{{ defaultProps.label(data) }}</span>
</div>
</template>
</ElTree>
</ElScrollbar>
<template #footer>
<ElButton @click="outputSelectedData" style="margin-left: 8px">获取选中数据</ElButton>
<ElButton @click="toggleExpandAll">{{ isExpandAll ? "全部收起" : "全部展开" }}</ElButton>
<ElButton @click="toggleSelectAll" style="margin-left: 8px">
{{ isSelectAll ? "取消全选" : "全部选择" }}
</ElButton>
<ElButton type="primary" @click="savePermission">保存</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { useMenuStore } from "@/store/modules/menu.store";
import { formatMenuTitle } from "@/utils/navigation/router";
type RoleListItem = RoleListItem;
interface Props {
modelValue: boolean;
roleData?: RoleListItem;
}
interface Emits {
(e: "update:modelValue", value: boolean): void;
(e: "success"): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
roleData: undefined,
});
const emit = defineEmits<Emits>();
const { menuList } = storeToRefs(useMenuStore());
const treeRef = ref();
const isExpandAll = ref(true);
const isSelectAll = ref(false);
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
/**
* 菜单节点类型
*/
interface MenuNode {
id?: string | number;
name?: string;
label?: string;
meta?: {
title?: string;
authList?: Array<{
authMark: string;
title: string;
checked?: boolean;
}>;
};
children?: MenuNode[];
[key: string]: any;
}
/**
* 处理菜单数据 authList 转换为树形子节点
* 递归处理菜单树将权限列表展开为可选择的子节点
*/
const processedMenuList = computed(() => {
const processNode = (node: MenuNode): MenuNode => {
const processed = { ...node };
// authList
if (node.meta?.authList?.length) {
const authNodes = node.meta.authList.map((auth) => ({
id: `${node.id}_${auth.authMark}`,
name: `${node.name}_${auth.authMark}`,
label: auth.title,
authMark: auth.authMark,
isAuth: true,
checked: auth.checked || false,
}));
processed.children = processed.children ? [...processed.children, ...authNodes] : authNodes;
}
//
if (processed.children) {
processed.children = processed.children.map(processNode);
}
return processed;
};
return (menuList.value as any[]).map(processNode);
});
/**
* 树形组件配置
*/
const defaultProps = {
children: "children",
label: (data: any) => formatMenuTitle(data.meta?.title) || data.label || "",
};
/**
* 监听弹窗打开初始化权限数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal && props.roleData) {
// TODO:
console.log("设置权限:", props.roleData);
}
}
);
/**
* 关闭弹窗并清空选中状态
*/
const handleClose = () => {
visible.value = false;
treeRef.value?.setCheckedKeys([]);
};
/**
* 保存权限配置
*/
const savePermission = () => {
// TODO:
ElMessage.success("权限保存成功");
emit("success");
handleClose();
};
/**
* 切换全部展开/收起状态
*/
const toggleExpandAll = () => {
const tree = treeRef.value;
if (!tree) return;
const nodes = tree.store.nodesMap;
// any Element Plus
Object.values(nodes).forEach((node: any) => {
node.expanded = !isExpandAll.value;
});
isExpandAll.value = !isExpandAll.value;
};
/**
* 切换全选/取消全选状态
*/
const toggleSelectAll = () => {
const tree = treeRef.value;
if (!tree) return;
if (!isSelectAll.value) {
const allKeys = getAllNodeKeys(processedMenuList.value);
tree.setCheckedKeys(allKeys);
} else {
tree.setCheckedKeys([]);
}
isSelectAll.value = !isSelectAll.value;
};
/**
* 递归获取所有节点的 key
* @param nodes 节点列表
* @returns 所有节点的 key 数组
*/
const getAllNodeKeys = (nodes: MenuNode[]): string[] => {
const keys: string[] = [];
const traverse = (nodeList: MenuNode[]): void => {
nodeList.forEach((node) => {
if (node.name) keys.push(node.name);
if (node.children?.length) traverse(node.children);
});
};
traverse(nodes);
return keys;
};
/**
* 处理树节点选中状态变化
* 同步更新全选按钮状态
*/
const handleTreeCheck = () => {
const tree = treeRef.value;
if (!tree) return;
const checkedKeys = tree.getCheckedKeys();
const allKeys = getAllNodeKeys(processedMenuList.value);
isSelectAll.value = checkedKeys.length === allKeys.length && allKeys.length > 0;
};
/**
* 输出选中的权限数据到控制台
* 用于调试和查看当前选中的权限配置
*/
const outputSelectedData = () => {
const tree = treeRef.value;
if (!tree) return;
const selectedData = {
checkedKeys: tree.getCheckedKeys(),
halfCheckedKeys: tree.getHalfCheckedKeys(),
checkedNodes: tree.getCheckedNodes(),
halfCheckedNodes: tree.getHalfCheckedNodes(),
totalChecked: tree.getCheckedKeys().length,
totalHalfChecked: tree.getHalfCheckedKeys().length,
};
console.log("=== 选中的权限数据 ===", selectedData);
ElMessage.success(`已输出选中数据到控制台,共选中 ${selectedData.totalChecked} 个节点`);
};
</script>
@@ -1,124 +0,0 @@
<template>
<ArtSearchBar
ref="searchBarRef"
v-model="formData"
:items="formItems"
:rules="rules"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
</template>
<script setup lang="ts">
type RoleSearchFormParams = RoleSearchParams & {
daterange?: string[];
};
interface Props {
modelValue: RoleSearchFormParams;
}
interface Emits {
(e: "update:modelValue", value: RoleSearchFormParams): void;
(e: "search", params: RoleSearchFormParams): void;
(e: "reset"): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const searchBarRef = ref();
/**
* 表单数据双向绑定
*/
const formData = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
/**
* 表单校验规则
*/
const rules = {};
/**
* 角色状态选项
*/
const statusOptions = ref([
{ label: "启用", value: true },
{ label: "禁用", value: false },
]);
/**
* 搜索表单配置项
*/
const formItems = computed(() => [
{
label: "角色名称",
key: "roleName",
type: "input",
placeholder: "请输入角色名称",
clearable: true,
},
{
label: "角色编码",
key: "roleCode",
type: "input",
placeholder: "请输入角色编码",
clearable: true,
},
{
label: "角色描述",
key: "description",
type: "input",
placeholder: "请输入角色描述",
clearable: true,
},
{
label: "角色状态",
key: "enabled",
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
clearable: true,
},
},
{
label: "创建日期",
key: "daterange",
type: "datetime",
props: {
style: { width: "100%" },
placeholder: "请选择日期范围",
type: "daterange",
rangeSeparator: "至",
startPlaceholder: "开始日期",
endPlaceholder: "结束日期",
valueFormat: "YYYY-MM-DD",
shortcuts: [
{ text: "今日", value: [new Date(), new Date()] },
{ text: "最近一周", value: [new Date(Date.now() - 604800000), new Date()] },
{ text: "最近一个月", value: [new Date(Date.now() - 2592000000), new Date()] },
],
},
},
]);
/**
* 处理重置事件
*/
const handleReset = () => {
emit("reset");
};
/**
* 处理搜索事件
* 验证表单后触发搜索
*/
const handleSearch = async (params: RoleSearchFormParams) => {
await searchBarRef.value.validate();
emit("search", params);
};
</script>
@@ -1,258 +0,0 @@
<!-- 用户管理页面 -->
<!-- art-full-height 自动计算出页面剩余高度 -->
<!-- art-table-card 一个符合系统样式的 class同时自动撑满剩余高度 -->
<!-- 更多 useTable 使用示例请移步至 功能示例 下面的高级表格示例或者查看官方文档 -->
<!-- useTable 文档https://www.artd.pro/docs/zh/guide/hooks/use-table.html -->
<template>
<div class="user-page art-full-height">
<!-- 搜索栏 -->
<UserSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams"></UserSearch>
<ElCard class="art-table-card">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton @click="showDialog('add')" v-ripple>新增用户</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
></ArtTable>
<!-- 用户弹窗 -->
<UserDialog
v-model:visible="dialogVisible"
:type="dialogType"
:user-data="currentUserData"
@submit="handleDialogSubmit"
/>
</ElCard>
</div>
</template>
<script setup lang="ts">
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import { ACCOUNT_TABLE_DATA } from "@/mock/temp/formData";
import { useTable } from "@/hooks/core/useTable";
import { fetchGetUserList } from "@/api/system-manage";
import UserSearch from "./modules/user-search.vue";
import UserDialog from "./modules/user-dialog.vue";
import { ElTag, ElMessageBox, ElImage } from "element-plus";
import { DialogType } from "@/types";
defineOptions({ name: "User" });
type UserListItem = UserListItem;
//
const dialogType = ref<DialogType>("add");
const dialogVisible = ref(false);
const currentUserData = ref<Partial<UserListItem>>({});
//
const selectedRows = ref<UserListItem[]>([]);
//
const searchForm = ref({
userName: undefined,
userGender: undefined,
userPhone: undefined,
userEmail: undefined,
status: "1",
});
//
const USER_STATUS_CONFIG = {
"1": { type: "success" as const, text: "在线" },
"2": { type: "info" as const, text: "离线" },
"3": { type: "warning" as const, text: "异常" },
"4": { type: "danger" as const, text: "注销" },
} as const;
/**
* 获取用户状态配置
*/
const getUserStatusConfig = (status: string) => {
return (
USER_STATUS_CONFIG[status as keyof typeof USER_STATUS_CONFIG] || {
type: "info" as const,
text: "未知",
}
);
};
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
} = useTable({
//
core: {
apiFn: fetchGetUserList,
apiParams: {
current: 1,
size: 20,
...searchForm.value,
},
// 使 tableConfig.ts paginationKey
// paginationKey: {
// current: 'pageNum',
// size: 'pageSize'
// },
columnsFactory: () => [
{ type: "selection" }, //
{ type: "index", width: 60, label: "序号" }, //
{
prop: "userInfo",
label: "用户名",
width: 280,
// visible: false, //
formatter: (row) => {
return h("div", { class: "user flex-c" }, [
h(ElImage, {
class: "size-9.5 rounded-md",
src: row.avatar,
previewSrcList: [row.avatar],
// body
previewTeleported: true,
}),
h("div", { class: "ml-2" }, [
h("p", { class: "user-name" }, row.userName),
h("p", { class: "email" }, row.userEmail),
]),
]);
},
},
{
prop: "userGender",
label: "性别",
sortable: true,
formatter: (row) => row.userGender,
},
{ prop: "userPhone", label: "手机号" },
{
prop: "status",
label: "状态",
formatter: (row) => {
const statusConfig = getUserStatusConfig(row.status);
return h(ElTag, { type: statusConfig.type }, () => statusConfig.text);
},
},
{
prop: "createTime",
label: "创建日期",
sortable: true,
},
{
prop: "operation",
label: "操作",
width: 120,
fixed: "right", //
formatter: (row) =>
h("div", [
h(ArtButtonTable, {
type: "edit",
onClick: () => showDialog("edit", row),
}),
h(ArtButtonTable, {
type: "delete",
onClick: () => deleteUser(row),
}),
]),
},
],
},
//
transform: {
// -
dataTransformer: (records) => {
//
if (!Array.isArray(records)) {
console.warn("数据转换器: 期望数组类型,实际收到:", typeof records);
return [];
}
// 使
return records.map((item, index: number) => {
return {
...item,
avatar: ACCOUNT_TABLE_DATA[index % ACCOUNT_TABLE_DATA.length].avatar,
};
});
},
},
});
/**
* 搜索处理
* @param params 参数
*/
const handleSearch = (params: UserSearchParams) => {
replaceSearchParams(params);
getData();
};
/**
* 显示用户弹窗
*/
const showDialog = (type: DialogType, row?: UserListItem): void => {
console.log("打开弹窗:", { type, row });
dialogType.value = type;
currentUserData.value = row || {};
nextTick(() => {
dialogVisible.value = true;
});
};
/**
* 删除用户
*/
const deleteUser = (row: UserListItem): void => {
console.log("删除用户:", row);
ElMessageBox.confirm(`确定要注销该用户吗?`, "注销用户", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "error",
}).then(() => {
ElMessage.success("注销成功");
});
};
/**
* 处理弹窗提交事件
*/
const handleDialogSubmit = async () => {
try {
dialogVisible.value = false;
currentUserData.value = {};
} catch (error) {
console.error("提交失败:", error);
}
};
/**
* 处理表格行选择变化
*/
const handleSelectionChange = (selection: UserListItem[]): void => {
selectedRows.value = selection;
console.log("选中行数据:", selectedRows.value);
};
</script>
@@ -1,143 +0,0 @@
<template>
<ElDialog
v-model="dialogVisible"
:title="dialogType === 'add' ? '添加用户' : '编辑用户'"
width="30%"
align-center
>
<ElForm ref="formRef" :model="formData" :rules="rules" label-width="80px">
<ElFormItem label="用户名" prop="username">
<ElInput v-model="formData.username" placeholder="请输入用户名" />
</ElFormItem>
<ElFormItem label="手机号" prop="phone">
<ElInput v-model="formData.phone" placeholder="请输入手机号" />
</ElFormItem>
<ElFormItem label="性别" prop="gender">
<ElSelect v-model="formData.gender">
<ElOption label="男" value="男" />
<ElOption label="女" value="女" />
</ElSelect>
</ElFormItem>
<ElFormItem label="角色" prop="role">
<ElSelect v-model="formData.role" multiple>
<ElOption
v-for="role in roleList"
:key="role.roleCode"
:value="role.roleCode"
:label="role.roleName"
/>
</ElSelect>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit">提交</ElButton>
</div>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { ROLE_LIST_DATA } from "@/mock/temp/formData";
import type { FormInstance, FormRules } from "element-plus";
interface Props {
visible: boolean;
type: string;
userData?: Partial<UserListItem>;
}
interface Emits {
(e: "update:visible", value: boolean): void;
(e: "submit"): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
const roleList = ref(ROLE_LIST_DATA);
//
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit("update:visible", value),
});
const dialogType = computed(() => props.type);
//
const formRef = ref<FormInstance>();
//
const formData = reactive({
username: "",
phone: "",
gender: "男",
role: [] as string[],
});
//
const rules: FormRules = {
username: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{ min: 2, max: 20, message: "长度在 2 到 20 个字符", trigger: "blur" },
],
phone: [
{ required: true, message: "请输入手机号", trigger: "blur" },
{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号格式", trigger: "blur" },
],
gender: [{ required: true, message: "请选择性别", trigger: "blur" }],
role: [{ required: true, message: "请选择角色", trigger: "blur" }],
};
/**
* 初始化表单数据
* 根据对话框类型新增/编辑填充表单
*/
const initFormData = () => {
const isEdit = props.type === "edit" && props.userData;
const row = props.userData;
Object.assign(formData, {
username: isEdit && row ? row.userName || "" : "",
phone: isEdit && row ? row.userPhone || "" : "",
gender: isEdit && row ? row.userGender || "男" : "男",
role: isEdit && row ? (Array.isArray(row.userRoles) ? row.userRoles : []) : [],
});
};
/**
* 监听对话框状态变化
* 当对话框打开时初始化表单数据并清除验证状态
*/
watch(
() => [props.visible, props.type, props.userData],
([visible]) => {
if (visible) {
initFormData();
nextTick(() => {
formRef.value?.clearValidate();
});
}
},
{ immediate: true }
);
/**
* 提交表单
* 验证通过后触发提交事件
*/
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate((valid) => {
if (valid) {
ElMessage.success(dialogType.value === "add" ? "添加成功" : "更新成功");
dialogVisible.value = false;
emit("submit");
}
});
};
</script>
@@ -1,111 +0,0 @@
<template>
<ArtSearchBar
ref="searchBarRef"
v-model="formData"
:items="formItems"
:rules="rules"
@reset="handleReset"
@search="handleSearch"
></ArtSearchBar>
</template>
<script setup lang="ts">
interface Props {
modelValue: UserSearchParams;
}
interface Emits {
(e: "update:modelValue", value: UserSearchParams): void;
(e: "search", params: UserSearchParams): void;
(e: "reset"): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
//
const searchBarRef = ref();
const formData = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
//
const rules = {
// userName: [{ required: true, message: '', trigger: 'blur' }]
};
// options
const statusOptions = ref<{ label: string; value: string; disabled?: boolean }[]>([]);
//
function fetchStatusOptions(): Promise<typeof statusOptions.value> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ label: "在线", value: "1" },
{ label: "离线", value: "2" },
{ label: "异常", value: "3" },
{ label: "注销", value: "4" },
]);
}, 1000);
});
}
onMounted(async () => {
statusOptions.value = await fetchStatusOptions();
});
//
const formItems = computed(() => [
{
label: "用户名",
key: "userName",
type: "input",
placeholder: "请输入用户名",
clearable: true,
},
{
label: "手机号",
key: "userPhone",
type: "input",
props: { placeholder: "请输入手机号", maxlength: "11" },
},
{
label: "邮箱",
key: "userEmail",
type: "input",
props: { placeholder: "请输入邮箱" },
},
{
label: "状态",
key: "status",
type: "select",
props: {
placeholder: "请选择状态",
options: statusOptions.value,
},
},
{
label: "性别",
key: "userGender",
type: "radiogroup",
props: {
options: [
{ label: "男", value: "1" },
{ label: "女", value: "2" },
],
},
},
]);
//
function handleReset() {
console.log("重置表单");
emit("reset");
}
async function handleSearch(params: UserSearchParams) {
await searchBarRef.value.validate();
emit("search", params);
console.log("表单数据", params);
}
</script>
@@ -21,7 +21,19 @@
</div> </div>
<div class="flex flex-col flex-grow min-w-0"> <div class="flex flex-col flex-grow min-w-0">
<UserSearch v-model="defaultFilter" /> <ArtSearchBar
ref="searchBarRef"
v-model="defaultFilter"
:items="treeSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:default-expanded="false"
@search="onTreeSearch"
@reset="onTreeSearchReset"
/>
<ElCard class="flex flex-col flex-1 min-h-0 art-table-card"> <ElCard class="flex flex-col flex-1 min-h-0 art-table-card">
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData"> <ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
@@ -53,13 +65,52 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue";
import { useTable } from "@/hooks/core/useTable"; import { useTable } from "@/hooks/core/useTable";
import { fetchGetUserList } from "@/api/system-manage"; import { fetchGetUserList } from "@/api/system-manage";
import UserSearch from "@/views/system/user/modules/user-search.vue"; import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
defineOptions({ name: "TreeTable" }); defineOptions({ name: "TreeTable" });
const showButtons = ref(false); const showButtons = ref(false);
const searchBarRef = ref<InstanceType<typeof ArtSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const statusOptions = ref([
{ label: "在线", value: "1" },
{ label: "离线", value: "2" },
{ label: "异常", value: "3" },
{ label: "注销", value: "4" },
]);
const treeSearchItems = computed<SearchFormItem[]>(() => [
{ label: "用户名", key: "userName", type: "input", placeholder: "请输入用户名", clearable: true },
{
label: "手机号",
key: "userPhone",
type: "input",
props: { placeholder: "请输入手机号", maxlength: "11" },
},
{ label: "邮箱", key: "userEmail", type: "input", props: { placeholder: "请输入邮箱" } },
{
label: "状态",
key: "status",
type: "select",
props: { placeholder: "请选择状态", options: statusOptions.value },
},
{
label: "性别",
key: "userGender",
type: "radiogroup",
props: {
options: [
{ label: "男", value: "1" },
{ label: "女", value: "2" },
],
},
},
]);
// - // -
const treeData = ref([ const treeData = ref([
@@ -229,6 +280,15 @@ const {
], ],
}, },
}); });
function onTreeSearch() {
void searchBarRef.value?.validate?.();
void refreshData();
}
function onTreeSearchReset() {
void refreshData();
}
</script> </script>
<style scoped> <style scoped>