feat: restore lightweight admin modules

This commit is contained in:
2026-06-11 19:39:38 +08:00
parent 15229cc5ab
commit 1fba0aafb2
73 changed files with 13943 additions and 80 deletions
+2
View File
@@ -37,6 +37,7 @@
"@vben/layouts": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/preferences": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/request": "workspace:*",
"@vben/stores": "workspace:*",
"@vben/styles": "workspace:*",
@@ -50,6 +51,7 @@
"cron-parser": "^4.9.0",
"dayjs": "catalog:",
"element-plus": "catalog:",
"grid-layout-plus": "^1.1.1",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
+258
View File
@@ -0,0 +1,258 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types';
import { h } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t, $te } from '@vben/locales';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { get, isFunction, isString } from '@vben/utils';
import {
ElButton,
ElImage,
ElPopconfirm,
ElTag,
ElTooltip,
} from 'element-plus';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
formConfig: {
enabled: false,
},
minHeight: 180,
proxyConfig: {
autoLoad: true,
response: {
list: '',
result: 'items',
total: 'total',
},
showActionMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'medium',
} as VxeTableGridOptions,
});
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
const src = row[column.field];
return h(ElImage, { previewSrcList: [src], src });
},
});
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
ElButton,
{ link: true, size: 'small' },
{ default: () => props?.text },
);
},
});
vxeUI.renderer.add('CellTag', {
renderTableDefault({ options, props }, { column, row }) {
const value = get(row, column.field);
const tagOptions = options ?? [
{ label: $t('common.enabled'), type: 'success', value: true },
{ label: $t('common.disabled'), type: 'danger', value: false },
];
const tagItem = tagOptions.find((item) => item.value === value);
return h(
ElTag,
{
type: tagItem?.type ?? 'info',
...props,
},
{ default: () => tagItem?.label ?? value },
);
},
});
vxeUI.renderer.add('CellOperation', {
renderTableDefault({ attrs, options, props }, { column, row }) {
const defaultProps = { link: true, size: 'small', ...props };
const align =
column.align === 'center'
? 'center'
: column.align === 'left'
? 'start'
: 'end';
const presets: Recordable<Recordable<any>> = {
delete: {
icon: 'ep:delete',
text: $t('common.delete'),
type: 'danger',
},
edit: {
icon: 'ep:edit',
text: $t('common.edit'),
type: 'primary',
},
};
const operations: Array<Recordable<any>> = (
options || ['edit', 'delete']
)
.map((opt) => {
if (isString(opt)) {
return presets[opt]
? { code: opt, ...presets[opt], ...defaultProps }
: {
code: opt,
text: $te(`common.${opt}`) ? $t(`common.${opt}`) : opt,
type: 'primary',
...defaultProps,
};
}
const buttonConfig = {
...defaultProps,
...presets[opt.code],
...opt,
};
if (!buttonConfig.type && !presets[opt.code]) {
buttonConfig.type = 'primary';
}
return buttonConfig;
})
.map((opt) => {
const optBtn: Recordable<any> = {};
Object.keys(opt).forEach((key) => {
optBtn[key] = isFunction(opt[key]) ? opt[key](row) : opt[key];
});
return optBtn;
})
.filter((opt) => opt.show !== false);
function renderBtn(opt: Recordable<any>, listen = true) {
const { code, icon, text, ...btnProps } = opt;
const buttonType =
btnProps.type === 'danger'
? 'danger'
: btnProps.type === 'primary'
? 'primary'
: 'default';
const button = h(
ElButton,
{
...btnProps,
circle: !!icon,
link: true,
onClick: listen
? () =>
attrs?.onClick?.({
code,
row,
})
: undefined,
size: 'small',
type: buttonType,
},
{
default: () =>
icon ? h(IconifyIcon, { class: 'size-4', icon }) : text,
},
);
if (!icon) return button;
return h(
ElTooltip,
{
content: text,
placement: 'top',
},
{
default: () => button,
},
);
}
function renderConfirm(opt: Recordable<any>) {
const { icon, text } = opt;
const button = h(
ElButton,
{
circle: !!icon,
link: true,
size: 'small',
title: icon ? text : undefined,
type: 'danger',
},
{
default: () =>
icon ? h(IconifyIcon, { class: 'size-4', icon }) : text,
},
);
return h(
ElPopconfirm,
{
cancelButtonText: $t('common.cancel'),
confirmButtonText: $t('common.confirm'),
confirmButtonType: 'danger',
onConfirm: () => {
attrs?.onClick?.({
code: opt.code,
row,
});
},
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),
},
{
default: () =>
$t('ui.actionMessage.deleteConfirm', [
row[attrs?.nameField || 'name'],
]),
reference: () => button,
},
);
}
const btns = operations.map((opt) =>
opt.code === 'delete' ? renderConfirm(opt) : renderBtn(opt),
);
return h(
'div',
{
class: 'flex table-operations',
style: { justifyContent: align },
},
btns,
);
},
});
},
useVbenForm,
});
export type OnActionClickParams<T = Recordable<any>> = {
code: string;
row: T;
};
export type OnActionClickFn<T = Recordable<any>> = (
params: OnActionClickParams<T>,
) => void;
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';
@@ -0,0 +1,238 @@
import { requestClient } from '#/api/request';
/**
* 页面管理 API
* 页面元数据的 CRUD、发布、复制、导入导出
*/
// ============ 类型定义 ============
/** 页面元数据 */
export interface PageMeta {
id: string;
application_id?: string;
name: string;
code: string;
category: string;
description: string;
status: string;
version: number;
page_config: Record<string, any>;
sort: number;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 页面列表项 */
export interface PageMetaListItem {
id: string;
application_id?: string;
application_name?: string;
application_code?: string;
name: string;
code: string;
category: string;
description: string;
status: string;
version: number;
sort: number;
sys_create_datetime: string;
sys_update_datetime: string;
}
/** 创建页面请求 */
export interface PageMetaCreateInput {
application_id?: string;
name: string;
code: string;
category?: string;
description?: string;
sort?: number;
page_config?: Record<string, any>;
}
/** 更新页面请求 */
export interface PageMetaUpdateInput {
name?: string;
category?: string;
description?: string;
sort?: number;
page_config?: Record<string, any>;
}
/** 导入页面请求 */
export interface PageImportInput {
application_id?: string;
name: string;
code: string;
category?: string;
description?: string;
page_config?: Record<string, any>;
}
/** 导入预检查请求 */
export interface PageImportCheckInput {
code: string;
}
/** 导入预检查结果 */
export interface PageImportCheckResult {
code_exists: boolean;
can_import: boolean;
}
/** 列表查询参数 */
export interface PageListParams {
page?: number;
pageSize?: number;
applicationId?: string;
name?: string;
code?: string;
category?: string;
status?: string;
}
/** 发布配置 */
export interface PagePublishInput {
/** 菜单名称 */
menu_name: string;
/** 上级菜单ID */
menu_parent_id?: string;
/** 菜单图标 */
menu_icon?: string;
/** 菜单排序 */
menu_order?: number;
}
/** 分页响应 */
interface PagePaginatedResponse<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
// ============ 页面元数据 API ============
/**
* 获取页面列表(分页)
*/
export async function getPageListApi(params?: PageListParams) {
return requestClient.get<PagePaginatedResponse<PageMetaListItem>>(
'/api/online_dev/page/list',
{ params },
);
}
/**
* 获取分类列表
*/
export async function getPageCategoriesApi() {
return requestClient.get<string[]>('/api/online_dev/page/categories');
}
/**
* 获取页面详情
*/
export async function getPageDetailApi(pageId: string) {
return requestClient.get<PageMeta>(`/api/online_dev/page/${pageId}`);
}
/**
* 根据编码获取页面
*/
export async function getPageByCodeApi(code: string) {
return requestClient.get<PageMeta>(`/api/online_dev/page/code/${code}`);
}
/**
* 创建页面
*/
export async function createPageApi(data: PageMetaCreateInput) {
return requestClient.post<PageMeta>('/api/online_dev/page', data);
}
/**
* 更新页面
*/
export async function updatePageApi(pageId: string, data: PageMetaUpdateInput) {
return requestClient.put<PageMeta>(`/api/online_dev/page/${pageId}`, data);
}
/**
* 删除页面
*/
export async function deletePageApi(pageId: string) {
return requestClient.delete<PageMeta>(`/api/online_dev/page/${pageId}`);
}
/**
* 批量删除页面
*/
export async function batchDeletePageApi(ids: string[]) {
return requestClient.delete<{ count: number }>('/api/online_dev/page/batch', {
params: { ids },
});
}
/**
* 发布页面
*/
export async function publishPageApi(pageId: string, data: PagePublishInput) {
return requestClient.post<PageMeta>(
`/api/online_dev/page/${pageId}/publish`,
data,
);
}
/**
* 取消发布页面
*/
export async function unpublishPageApi(pageId: string) {
return requestClient.post<PageMeta>(
`/api/online_dev/page/${pageId}/unpublish`,
);
}
/**
* 复制页面
*/
export async function copyPageApi(
pageId: string,
newCode: string,
newName?: string,
) {
return requestClient.post<PageMeta>(
`/api/online_dev/page/${pageId}/copy`,
null,
{
params: { new_code: newCode, new_name: newName },
},
);
}
/**
* 导出页面配置(返回 JSON 文件)
*/
export async function exportPageConfigApi(pageId: string) {
return requestClient.get<Blob>(`/api/online_dev/page/${pageId}/export`, {
responseType: 'blob',
});
}
/**
* 导入预检查
*/
export async function checkImportPageApi(data: PageImportCheckInput) {
return requestClient.post<PageImportCheckResult>(
'/api/online_dev/page/import/check',
data,
);
}
/**
* 导入页面配置
*/
export async function importPageConfigApi(data: PageImportInput) {
return requestClient.post<PageMeta>('/api/online_dev/page/import', data);
}
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { DashboardConfig } from './store/dashboardDesignStore';
/**
* 仪表盘渲染器
* 用于在实际页面中展示设计好的仪表盘配置
*/
import { computed, onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElEmpty } from 'element-plus';
import { GridItem, GridLayout } from 'grid-layout-plus';
import WidgetRenderer from './components/WidgetRenderer.vue';
const props = defineProps<{
// 仪表盘配置 JSON 字符串或对象
config: DashboardConfig | string;
}>();
// 解析后的配置
const dashboardConfig = ref<DashboardConfig | null>(null);
// 解析配置
const parseConfig = () => {
if (!props.config) {
dashboardConfig.value = null;
return;
}
if (typeof props.config === 'string') {
try {
dashboardConfig.value = JSON.parse(props.config);
} catch {
console.error('Invalid dashboard config JSON');
dashboardConfig.value = null;
}
} else {
dashboardConfig.value = props.config;
}
};
onMounted(parseConfig);
watch(() => props.config, parseConfig);
// 布局数据
const layout = computed(() => {
if (!dashboardConfig.value) return [];
return dashboardConfig.value.widgets.map((w) => ({
i: w.i,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
}));
});
// 获取 widget 配置
const getWidget = (i: string) => {
if (!dashboardConfig.value) return null;
return dashboardConfig.value.widgets.find((w) => w.i === i);
};
// 获取动画延迟(交错入场效果)
const getAnimationDelay = (i: string) => {
if (!dashboardConfig.value) return 0;
const index = dashboardConfig.value.widgets.findIndex((w) => w.i === i);
return index * 80; // 每个组件延迟 80ms
};
</script>
<template>
<div
class="dashboard-renderer h-full"
:style="
dashboardConfig?.backgroundColor?.includes('gradient')
? { background: dashboardConfig.backgroundColor }
: { backgroundColor: dashboardConfig?.backgroundColor || '' }
"
>
<div v-if="dashboardConfig && layout.length > 0">
<GridLayout
:layout="layout"
:col-num="dashboardConfig.columns"
:row-height="dashboardConfig.rowHeight"
:margin="dashboardConfig.margin"
:is-draggable="false"
:is-resizable="false"
:vertical-compact="true"
:use-css-transforms="true"
:style="
dashboardConfig.showOuterMargin
? {}
: {
marginLeft: `-${dashboardConfig.margin[0]}px`,
marginRight: `-${dashboardConfig.margin[0]}px`,
marginTop: `-${dashboardConfig.margin[1]}px`,
width: `calc(100% + ${dashboardConfig.margin[0] * 2}px)`,
}
"
>
<GridItem
v-for="item in layout"
:key="item.i"
:i="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
class="dashboard-widget"
>
<WidgetRenderer
v-if="getWidget(item.i)"
:widget="getWidget(item.i)!"
:is-design-mode="false"
:animation-delay="getAnimationDelay(item.i)"
/>
</GridItem>
</GridLayout>
</div>
<ElEmpty
v-else
:description="$t('dashboard-design.noConfigTip')"
class="py-20"
/>
</div>
</template>
<style scoped>
.dashboard-renderer {
background-color: var(--el-bg-color-page);
}
.dashboard-widget {
overflow: hidden;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
}
</style>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import type {
DashboardWidget,
WidgetMaterial,
} from '../store/dashboardDesignStore';
import {
computed,
defineAsyncComponent,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { Loader2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
defaultWidgetStyle,
useDashboardDesignStore,
} from '../store/dashboardDesignStore';
import { createRefreshTimer, fetchWidgetData } from '../utils/dataFetcher';
const props = defineProps<{
animationDelay?: number; // 入场动画延迟(毫秒)
isDesignMode?: boolean;
material?: WidgetMaterial;
widget: DashboardWidget;
}>();
// 入场动画状态
const isEntered = ref(false);
// 数据更新动画状态
const isUpdating = ref(false);
// 组件映射
const widgetComponents: Record<
string,
ReturnType<typeof defineAsyncComponent>
> = {
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
'announcement-list': defineAsyncComponent(
() => import('./widgets/AnnouncementList.vue'),
),
'quick-links': defineAsyncComponent(() => import('./widgets/QuickLinks.vue')),
'welcome-card': defineAsyncComponent(
() => import('./widgets/WelcomeCard.vue'),
),
calendar: defineAsyncComponent(() => import('./widgets/CalendarWidget.vue')),
countdown: defineAsyncComponent(
() => import('./widgets/CountdownWidget.vue'),
),
clock: defineAsyncComponent(() => import('./widgets/ClockWidget.vue')),
weather: defineAsyncComponent(() => import('./widgets/WeatherWidget.vue')),
'approval-center': defineAsyncComponent(
() => import('./widgets/ApprovalCenter.vue'),
),
'my-apps': defineAsyncComponent(() => import('./widgets/MyApps.vue')),
'server-monitor': defineAsyncComponent(
() => import('./widgets/ServerMonitor.vue'),
),
};
const store = useDashboardDesignStore();
const currentComponent = computed(() => {
return widgetComponents[props.widget.type];
});
// 从 paramBindings 解析出实际参数值
function resolveParams(): Record<string, any> | undefined {
const bindings = props.widget.dataSource?.paramBindings;
if (!bindings || bindings.length === 0) return undefined;
const params: Record<string, any> = {};
for (const b of bindings) {
if (b.paramName && b.globalKey) {
const val = store.globalParams[b.globalKey];
if (val !== undefined && val !== '') {
params[b.paramName] = val;
}
}
}
return Object.keys(params).length > 0 ? params : undefined;
}
// 动态数据
const dynamicProps = ref<Record<string, any>>({});
const isLoading = ref(false);
const hasError = ref(false);
// 合并后的 widget(静态 props + 动态 props
const mergedWidget = computed(() => {
return {
...props.widget,
props: {
...props.widget.props,
...dynamicProps.value,
},
};
});
// 计算样式(只应用容器级别的样式,不影响组件内部)
const widgetStyle = computed(() => {
const style = { ...defaultWidgetStyle, ...props.widget.style };
const css: Record<string, string> = {};
// 背景(默认使用主题背景色,支持渐变色)
const bgValue = style.backgroundColor || 'var(--el-bg-color)';
if (bgValue.includes('gradient')) {
css.background = bgValue;
} else {
css.backgroundColor = bgValue;
}
// 边框
if (style.borderWidth && style.borderWidth > 0) {
css.borderWidth = `${style.borderWidth}px`;
css.borderStyle = style.borderStyle || 'solid';
css.borderColor = style.borderColor || 'var(--el-border-color)';
}
// 圆角
if (style.borderRadius !== undefined) {
css.borderRadius = `${style.borderRadius}px`;
}
// 阴影
if (style.shadowEnabled) {
const color = style.shadowColor || 'rgba(0, 0, 0, 0.1)';
const blur = style.shadowBlur || 4;
const x = style.shadowOffsetX || 0;
const y = style.shadowOffsetY || 1;
css.boxShadow = `${x}px ${y}px ${blur}px ${color}`;
} else {
css.boxShadow = 'none';
}
return css;
});
// 加载数据
const loadData = async () => {
if (!props.widget.dataSource || props.widget.dataSource.type === 'static') {
dynamicProps.value = {};
return;
}
isLoading.value = true;
hasError.value = false;
try {
const params = resolveParams();
const result = await fetchWidgetData(
props.widget.dataSource,
props.widget.props,
params,
);
// 数据更新动画
if (isEntered.value && Object.keys(dynamicProps.value).length > 0) {
isUpdating.value = true;
setTimeout(() => {
isUpdating.value = false;
}, 300);
}
dynamicProps.value = result.props;
} catch (error) {
console.error('[WidgetRenderer] loadData error', error);
hasError.value = true;
} finally {
isLoading.value = false;
}
};
// 刷新定时器清理函数
let cleanupTimer: (() => void) | null = null;
// 设置刷新定时器
const setupRefreshTimer = () => {
if (cleanupTimer) {
cleanupTimer();
cleanupTimer = null;
}
// 支持 api 和 dataSource 类型的自动刷新
const dsType = props.widget.dataSource?.type;
if (!props.isDesignMode && (dsType === 'api' || dsType === 'dataSource')) {
cleanupTimer = createRefreshTimer(props.widget.dataSource, loadData);
}
};
// 监听数据源变化
watch(
() => props.widget.dataSource,
() => {
loadData();
setupRefreshTimer();
},
{ deep: true },
);
// 监听全局筛选参数变化,有 paramBindings 的组件重新加载数据
watch(
() => store.globalParamsVersion,
() => {
if (props.isDesignMode) return;
const bindings = props.widget.dataSource?.paramBindings;
if (bindings && bindings.length > 0) {
loadData();
}
},
);
onMounted(() => {
loadData();
setupRefreshTimer();
// 入场动画
const delay = props.animationDelay || 0;
setTimeout(() => {
isEntered.value = true;
}, delay);
});
onUnmounted(() => {
if (cleanupTimer) {
cleanupTimer();
}
});
// 动画类名
const animationClass = computed(() => {
return {
'widget-enter': true,
'widget-entered': isEntered.value,
'widget-updating': isUpdating.value,
};
});
</script>
<template>
<div
class="widget-renderer relative h-full w-full overflow-hidden"
:class="animationClass"
:style="widgetStyle"
>
<!-- 加载状态 -->
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50"
>
<Loader2 class="text-primary h-6 w-6 animate-spin" />
</div>
<!-- 错误状态 -->
<div v-if="hasError && !isLoading" class="absolute right-2 top-2 z-10">
<span class="text-xs text-red-500">{{
$t('dashboard-design.loadDataError')
}}</span>
</div>
<!-- 组件内容 -->
<component
:is="currentComponent"
v-if="currentComponent"
:widget="mergedWidget"
:is-design-mode="isDesignMode"
/>
<div
v-else
class="flex h-full w-full items-center justify-center text-gray-400"
>
{{ $t('dashboard-design.unknownWidget') }}: {{ widget.type }}
</div>
</div>
</template>
<style scoped>
@keyframes pulse-update {
0% {
transform: scale(1);
}
50% {
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.widget-enter {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.widget-entered {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 数据更新动画 */
.widget-updating {
animation: pulse-update 0.3s ease-in-out;
}
/* 入场动画 */
</style>
@@ -0,0 +1,298 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import type { UserAnnouncement } from '#/api/core/announcement';
import { computed, onMounted, ref } from 'vue';
import { CheckCheck, Megaphone } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElEmpty, ElScrollbar, ElTag } from 'element-plus';
import {
getUserAnnouncementDetailApi,
getUserAnnouncementListApi,
} from '#/api/core/announcement';
import { ZqDialog } from '#/components/zq-dialog';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 实际公告数据
const announcements = ref<UserAnnouncement[]>([]);
const loading = ref(false);
// 详情弹窗
const detailVisible = ref(false);
const currentAnnouncement = ref<null | UserAnnouncement>(null);
const detailLoading = ref(false);
// 未读数量
const unreadCount = computed(
() => announcements.value.filter((a) => !a.is_read).length,
);
// 获取优先级配置
const getPriorityConfig = (priority: number) => {
switch (priority) {
case 1: {
return {
type: 'warning' as const,
label: $t('dashboard-design.widgets.announcement.priority.important'),
};
}
case 2: {
return {
type: 'danger' as const,
label: $t('dashboard-design.widgets.announcement.priority.urgent'),
};
}
default: {
return {
type: 'info' as const,
label: $t('dashboard-design.widgets.announcement.priority.normal'),
};
}
}
};
// 格式化时间
const formatTime = (dateStr?: string) => {
if (!dateStr) return '';
// 处理 "2025-12-01 21:47:29" 格式,替换空格为T以兼容所有浏览器
const date = new Date(dateStr.replace(' ', 'T'));
if (isNaN(date.getTime())) return dateStr;
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1)
return $t('dashboard-design.widgets.announcement.time.justNow');
if (minutes < 60)
return `${minutes}${$t('dashboard-design.widgets.announcement.time.minutesAgo')}`;
if (hours < 24)
return `${hours}${$t('dashboard-design.widgets.announcement.time.hoursAgo')}`;
if (days < 7)
return `${days}${$t('dashboard-design.widgets.announcement.time.daysAgo')}`;
return date.toLocaleDateString();
};
// 加载公告数据
const loadAnnouncements = async () => {
loading.value = true;
try {
const limit = props.widget.props.limit || 5;
const res = await getUserAnnouncementListApi({ page: 1, pageSize: limit });
announcements.value = res.items || [];
} catch (error) {
console.error('Failed to load announcements:', error);
} finally {
loading.value = false;
}
};
// 查看公告详情(会自动标记已读)
const viewDetail = async (item: UserAnnouncement) => {
detailVisible.value = true;
detailLoading.value = true;
try {
// 获取详情会自动标记已读
const detail = await getUserAnnouncementDetailApi(item.id);
currentAnnouncement.value = detail;
// 更新列表中的已读状态
item.is_read = true;
} catch (error) {
console.error('Failed to load announcement detail:', error);
currentAnnouncement.value = item;
} finally {
detailLoading.value = false;
}
};
// 全部已读(逐个标记)
const markAllRead = async () => {
if (unreadCount.value === 0) return;
const unreadItems = announcements.value.filter((a) => !a.is_read);
for (const item of unreadItems) {
try {
await getUserAnnouncementDetailApi(item.id);
item.is_read = true;
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
};
onMounted(() => {
loadAnnouncements();
});
</script>
<template>
<div class="announcement-list flex h-full flex-col p-3">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Megaphone class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
<ElTag v-if="unreadCount > 0" type="danger" size="small" round>
{{ unreadCount }}
</ElTag>
</div>
<ElButton
v-if="unreadCount > 0"
type="primary"
text
size="small"
@click="markAllRead"
>
<CheckCheck class="mr-1 h-3.5 w-3.5" />
{{ $t('dashboard-design.widgets.announcement.markAllRead') }}
</ElButton>
</div>
<ElScrollbar class="flex-1">
<div v-if="announcements.length > 0" class="space-y-2">
<div
v-for="item in announcements"
:key="item.id"
class="cursor-pointer rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
:class="{ 'opacity-60': item.is_read }"
@click="viewDetail(item)"
>
<div class="flex items-start gap-2">
<ElTag
v-if="item.is_top"
type="danger"
size="small"
effect="dark"
class="flex-shrink-0"
>
{{ $t('dashboard-design.widgets.announcement.top') }}
</ElTag>
<ElTag
:type="getPriorityConfig(item.priority).type"
size="small"
class="flex-shrink-0"
>
{{ getPriorityConfig(item.priority).label }}
</ElTag>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ item.title }}</div>
</div>
</div>
<div
v-if="item.summary"
class="text-muted-foreground mt-1 line-clamp-2 text-xs"
>
{{ item.summary }}
</div>
<div
class="text-muted-foreground mt-1 flex items-center justify-between text-xs"
>
<span>{{ item.publisher_name }}</span>
<span>{{ formatTime(item.publish_time) }}</span>
</div>
</div>
</div>
<ElEmpty
v-else
:description="$t('dashboard-design.widgets.announcement.noData')"
:image-size="60"
/>
</ElScrollbar>
<!-- 公告详情弹窗 -->
<ZqDialog
v-model="detailVisible"
:title="currentAnnouncement?.title"
width="800px"
destroy-on-close
append-to-body
>
<div class="min-h-[500px]" v-loading="detailLoading">
<!-- <div v-if="detailLoading" class="py-8 text-center text-gray-400">
{{ $t('dashboard-design.widgets.announcement.loading') }}
</div> -->
<div v-if="currentAnnouncement" class="space-y-4">
<div class="flex items-center gap-2">
<ElTag
v-if="currentAnnouncement.is_top"
type="danger"
size="small"
effect="dark"
>
{{ $t('dashboard-design.widgets.announcement.top') }}
</ElTag>
<ElTag
:type="getPriorityConfig(currentAnnouncement.priority).type"
size="small"
>
{{ getPriorityConfig(currentAnnouncement.priority).label }}
</ElTag>
<span class="text-muted-foreground text-xs">{{
formatTime(currentAnnouncement.publish_time)
}}</span>
</div>
<!-- 摘要 -->
<div
v-if="currentAnnouncement.summary"
class="border-l-4 border-[var(--el-color-primary)] bg-[var(--el-fill-color-light)] py-3 pl-4 pr-3 text-sm text-[var(--el-text-color-regular)]"
>
{{ currentAnnouncement.summary }}
</div>
<!-- 富文本内容 -->
<div
class="announcement-content prose max-w-none"
v-html="
currentAnnouncement.content ||
$t('dashboard-design.widgets.announcement.noContent')
"
></div>
<div class="text-muted-foreground text-xs">
{{ $t('dashboard-design.widgets.announcement.publisher')
}}{{ currentAnnouncement.publisher_name }}
</div>
</div>
</div>
</ZqDialog>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
.announcement-content :deep(img) {
max-width: 100%;
}
.announcement-content :deep(table) {
border-collapse: collapse;
width: 100%;
}
.announcement-content :deep(td),
.announcement-content :deep(th) {
border: 1px solid var(--el-border-color);
padding: 8px;
}
.announcement-content :deep(blockquote) {
border-left: 4px solid var(--el-border-color);
padding-left: 16px;
margin: 8px 0;
color: var(--el-text-color-secondary);
}
.announcement-content :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
</style>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed } from 'vue';
import {
ChevronRight,
ClipboardCheck,
ClipboardList,
FilePen,
Play,
Send,
UserCheck,
} from '@vben/icons';
import { $t } from '@vben/locales';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 审批中心菜单项
const menuItems = computed(() => [
{
key: 'initiated',
title: $t('dashboard-design.widgets.approvalCenter.initiated'),
icon: Send,
color: 'rgba(59, 130, 246, 0.85)',
path: '/workflow/initiated',
},
{
key: 'pending',
title: $t('dashboard-design.widgets.approvalCenter.pending'),
icon: ClipboardList,
color: 'rgba(245, 158, 11, 0.85)',
path: '/workflow/pending',
},
{
key: 'handling',
title: $t('dashboard-design.widgets.approvalCenter.handling'),
icon: FilePen,
color: 'rgba(139, 92, 246, 0.85)',
path: '/workflow/pending',
},
{
key: 'handled',
title: $t('dashboard-design.widgets.approvalCenter.handled'),
icon: ClipboardCheck,
color: 'rgba(14, 165, 233, 0.85)',
path: '/workflow/handled',
},
{
key: 'copy',
title: $t('dashboard-design.widgets.approvalCenter.copy'),
icon: UserCheck,
color: 'rgba(249, 115, 22, 0.85)',
path: '/workflow/copy',
},
{
key: 'start',
title: $t('dashboard-design.widgets.approvalCenter.start'),
icon: Play,
color: 'rgba(20, 184, 166, 0.85)',
path: '/workflow/start',
},
]);
// 路由前缀
const routePrefix = computed(
() => props.widget.props.routePrefix || '/app/workflow_center',
);
// 点击菜单项
const handleClick = (item: { path: string }) => {
window.open(`${routePrefix.value}${item.path}`, '_blank');
};
// 点击更多
const handleMore = () => {
window.open(`${routePrefix.value}/workflow/pending`, '_blank');
};
</script>
<template>
<div class="approval-center flex h-full flex-col p-4">
<!-- 头部 -->
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<button
v-if="widget.props.showMore"
type="button"
class="text-muted-foreground hover:text-primary flex items-center gap-0.5 text-xs transition-colors"
@click="handleMore"
>
{{ $t('dashboard-design.widgets.approvalCenter.more') }}
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 菜单网格 -->
<div class="flex flex-1 items-center justify-around gap-2">
<div
v-for="item in menuItems"
:key="item.key"
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
@click="handleClick(item)"
>
<div
class="flex h-11 w-11 items-center justify-center rounded-full"
:style="{ backgroundColor: item.color }"
>
<component :is="item.icon" class="h-5 w-5 text-white" />
</div>
<span class="text-muted-foreground whitespace-nowrap text-xs">{{
item.title
}}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { ref } from 'vue';
import { Calendar } from '@vben/icons';
import { ElCalendar } from 'element-plus';
defineProps<{
widget: DashboardWidget;
}>();
const selectedDate = ref(new Date());
</script>
<template>
<div class="calendar-widget flex h-full flex-col p-3">
<div class="mb-2 flex items-center gap-2">
<Calendar class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<ElCalendar v-model="selectedDate" class="compact-calendar" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
.compact-calendar {
--el-calendar-border: none;
}
:deep(.el-calendar) {
height: 100%;
}
:deep(.el-calendar__header) {
padding: 8px 0;
}
:deep(.el-calendar__body) {
padding: 0;
}
:deep(.el-calendar-table thead th) {
padding: 4px 0;
font-size: 12px;
}
:deep(.el-calendar-table .el-calendar-day) {
height: 32px;
padding: 2px;
font-size: 12px;
}
:deep(.el-calendar-table td.is-selected .el-calendar-day) {
color: white;
background-color: var(--el-color-primary);
border-radius: 4px;
}
:deep(.el-calendar-table td.is-today .el-calendar-day) {
font-weight: bold;
color: var(--el-color-primary);
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
const props = defineProps<{
widget: DashboardWidget;
}>();
const locale = computed(() => preferences.app.locale);
const currentTime = ref('');
const currentDate = ref('');
let timer: null | ReturnType<typeof setInterval> = null;
const updateTime = () => {
const now = new Date();
// 处理时区
const date = now;
if (props.widget.props.timezone && props.widget.props.timezone !== 'local') {
try {
const options: Intl.DateTimeFormatOptions = {
timeZone: props.widget.props.timezone,
hour: '2-digit',
minute: '2-digit',
second: props.widget.props.showSeconds ? '2-digit' : undefined,
hour12: !props.widget.props.format24,
};
currentTime.value = now.toLocaleTimeString(locale.value, options);
const dateOptions: Intl.DateTimeFormatOptions = {
timeZone: props.widget.props.timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
};
currentDate.value = now.toLocaleDateString(locale.value, dateOptions);
return;
} catch {
// 时区无效,使用本地时间
}
}
// 本地时间
const hours = props.widget.props.format24
? String(date.getHours()).padStart(2, '0')
: String(date.getHours() % 12 || 12).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const ampm = props.widget.props.format24
? ''
: (date.getHours() >= 12
? ' PM'
: ' AM');
currentTime.value = props.widget.props.showSeconds
? `${hours}:${minutes}:${seconds}${ampm}`
: `${hours}:${minutes}${ampm}`;
const weekdays = $t(
'dashboard-design.widgets.clock.weekdays',
) as unknown as string[];
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const weekday = weekdays[date.getDay()];
const yearSuffix = $t('dashboard-design.widgets.clock.year');
const monthSuffix = $t('dashboard-design.widgets.clock.month');
const daySuffix = $t('dashboard-design.widgets.clock.day');
currentDate.value = `${year}${yearSuffix}${month}${monthSuffix}${day}${daySuffix} ${weekday}`;
};
onMounted(() => {
updateTime();
timer = setInterval(updateTime, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
</script>
<template>
<div
class="clock-widget flex h-full flex-col items-center justify-center p-3"
>
<div v-if="widget.props.title" class="text-muted-foreground mb-2 text-sm">
{{ widget.props.title }}
</div>
<div class="time-display">
{{ currentTime }}
</div>
<div v-if="widget.props.showDate" class="date-display">
{{ currentDate }}
</div>
</div>
</template>
<style scoped>
.time-display {
font-size: 2.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--el-text-color-primary);
}
.date-display {
margin-top: 8px;
font-size: 0.875rem;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, onUnmounted, ref } from 'vue';
import { $t } from '@vben/locales';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 剩余时间
const remainingTime = ref({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
finished: false,
});
let timer: null | ReturnType<typeof setInterval> = null;
const calculateRemaining = () => {
const targetTime = new Date(props.widget.props.targetTime).getTime();
const now = Date.now();
const diff = targetTime - now;
if (diff <= 0) {
remainingTime.value = {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
finished: true,
};
if (timer) {
clearInterval(timer);
timer = null;
}
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
remainingTime.value = { days, hours, minutes, seconds, finished: false };
};
onMounted(() => {
calculateRemaining();
timer = setInterval(calculateRemaining, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
const padZero = (num: number) => String(num).padStart(2, '0');
</script>
<template>
<div class="countdown-widget flex h-full flex-col p-3">
<div
v-if="widget.props.title"
class="text-muted-foreground mb-2 text-sm font-medium"
>
{{ widget.props.title }}
</div>
<div class="flex flex-1 items-center justify-center">
<template v-if="!remainingTime.finished">
<div class="flex items-center gap-2">
<template v-if="widget.props.showDays">
<div class="time-block">
<div class="time-value">{{ remainingTime.days }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.day') }}
</div>
</div>
<span class="time-separator">:</span>
</template>
<template v-if="widget.props.showHours">
<div class="time-block">
<div class="time-value">{{ padZero(remainingTime.hours) }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.hour') }}
</div>
</div>
<span class="time-separator">:</span>
</template>
<template v-if="widget.props.showMinutes">
<div class="time-block">
<div class="time-value">{{ padZero(remainingTime.minutes) }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.minute') }}
</div>
</div>
<span v-if="widget.props.showSeconds" class="time-separator">:</span>
</template>
<template v-if="widget.props.showSeconds">
<div class="time-block">
<div class="time-value">{{ padZero(remainingTime.seconds) }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.second') }}
</div>
</div>
</template>
</div>
</template>
<div v-else class="text-muted-foreground text-lg">
{{
widget.props.finishedText ||
$t('dashboard-design.widgets.countdown.finished')
}}
</div>
</div>
</div>
</template>
<style scoped>
.time-block {
display: flex;
flex-direction: column;
align-items: center;
min-width: 48px;
}
.time-value {
font-size: 1.75rem;
font-weight: 600;
line-height: 1;
color: var(--el-color-primary);
}
.time-label {
margin-top: 4px;
font-size: 0.75rem;
color: var(--el-text-color-secondary);
}
.time-separator {
margin-bottom: 16px;
font-size: 1.5rem;
font-weight: 600;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { DashboardWidget } from '#/components/dashboard-design';
import type { ApplicationListItem } from '#/api/core/application';
import { computed, onMounted, ref } from 'vue';
import { AppWindow, ChevronRight, IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty, ElScrollbar } from 'element-plus';
import { getApplicationListApi } from '#/api/core/application';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 应用列表
const appList = ref<ApplicationListItem[]>([]);
const loading = ref(false);
// 最大显示数量
const maxCount = computed(() => props.widget.props.maxCount || 8);
// 显示的应用列表
const displayApps = computed(() => appList.value.slice(0, maxCount.value));
// 加载已发布的应用列表
const loadApps = async () => {
loading.value = true;
try {
const res = await getApplicationListApi({
page: 1,
pageSize: 100,
status: 'published',
});
appList.value = res.items || [];
} catch (error) {
console.error('Failed to load applications:', error);
} finally {
loading.value = false;
}
};
// 点击应用
const handleClick = (app: ApplicationListItem) => {
window.open(`${window.location.origin}/app/${app.code}`, '_blank');
};
// 点击更多
const handleMore = () => {
window.open(`${window.location.origin}/application`, '_blank');
};
onMounted(() => {
loadApps();
});
</script>
<template>
<div class="my-apps flex h-full flex-col p-4" v-loading="loading">
<!-- 头部 -->
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<button
v-if="widget.props.showMore"
type="button"
class="text-muted-foreground hover:text-primary flex items-center gap-0.5 text-xs transition-colors"
@click="handleMore"
>
{{ $t('dashboard-design.widgets.myApps.more') }}
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 应用网格 -->
<ElScrollbar class="flex-1">
<div
v-if="displayApps.length > 0"
class="flex flex-wrap gap-4"
>
<div
v-for="app in displayApps"
:key="app.id"
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105 m-4"
style="width: 72px"
@click="handleClick(app)"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl"
style="background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary))"
>
<IconifyIcon
v-if="app.icon"
:icon="app.icon"
class="h-6 w-6 text-white"
/>
<AppWindow v-else class="h-6 w-6 text-white" />
</div>
<span class="text-muted-foreground w-full truncate text-center text-xs">{{ app.name }}</span>
</div>
</div>
<ElEmpty
v-else-if="!loading"
:description="$t('dashboard-design.widgets.myApps.noData')"
:image-size="60"
/>
</ElScrollbar>
</div>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import type { Message } from '#/api/core/message';
import { computed, onMounted, ref } from 'vue';
import { Bell, CheckCheck, ExternalLink } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElDialog, ElEmpty, ElScrollbar, ElTag } from 'element-plus';
import {
getMessageListApi,
markAllAsReadApi,
markAsReadApi,
} from '#/api/core/message';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 实际消息数据
const messages = ref<Message[]>([]);
const loading = ref(false);
// 详情弹窗
const detailVisible = ref(false);
const currentMessage = ref<Message | null>(null);
// 未读数量
const unreadCount = computed(
() => messages.value.filter((m) => m.status === 'unread').length,
);
// 获取消息类型配置
const getTypeConfig = (type: string) => {
switch (type) {
case 'announcement': {
return {
type: 'success' as const,
label: $t('message.typeMap.announcement'),
};
}
case 'system': {
return { type: 'info' as const, label: $t('message.typeMap.system') };
}
case 'todo': {
return { type: 'warning' as const, label: $t('message.typeMap.todo') };
}
case 'workflow': {
return {
type: 'primary' as const,
label: $t('message.typeMap.workflow'),
};
}
default: {
return { type: 'info' as const, label: $t('message.type') };
}
}
};
// 跳转到审批链接
const handleGoToLink = (message: Message) => {
if (!message.link_type || !message.link_id) return;
// 根据 link_type 跳转到不同页面
let url = '';
if (message.link_type === 'workflow_task') {
// 跳转到待办任务页面,带上任务ID
url = `/app/workflow_center/workflow/pending?id=${message.link_id}`;
} else if (message.link_type === 'workflow_instance') {
// 跳转到我发起的页面,带上实例ID
url = `/app/workflow_center/workflow/initiated?id=${message.link_id}`;
}
if (url) {
window.open(url, '_blank');
}
};
// 格式化时间
const formatTime = (dateStr: string) => {
if (!dateStr) return '';
// 处理 "2025-12-01 21:47:29" 格式,替换空格为T以兼容所有浏览器
const date = new Date(dateStr.replace(' ', 'T'));
if (isNaN(date.getTime())) return dateStr;
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1)
return $t('dashboard-design.widgets.announcement.time.justNow');
if (minutes < 60)
return `${minutes}${$t('dashboard-design.widgets.announcement.time.minutesAgo')}`;
if (hours < 24)
return `${hours}${$t('dashboard-design.widgets.announcement.time.hoursAgo')}`;
if (days < 7)
return `${days}${$t('dashboard-design.widgets.announcement.time.daysAgo')}`;
return date.toLocaleDateString();
};
// 加载消息数据
const loadMessages = async () => {
loading.value = true;
try {
const limit = props.widget.props.limit || 5;
const res = await getMessageListApi({ page: 1, pageSize: limit });
messages.value = res.items || [];
} catch (error) {
console.error('Failed to load messages:', error);
} finally {
loading.value = false;
}
};
// 查看消息详情
const viewDetail = async (msg: Message) => {
currentMessage.value = msg;
detailVisible.value = true;
// 标记为已读
if (msg.status === 'unread') {
try {
await markAsReadApi(msg.id);
msg.status = 'read';
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
};
// 全部已读
const markAllRead = async () => {
if (unreadCount.value === 0) return;
try {
await markAllAsReadApi();
messages.value.forEach((msg) => {
msg.status = 'read';
});
} catch (error) {
console.error('Failed to mark all as read:', error);
}
};
onMounted(() => {
loadMessages();
});
</script>
<template>
<div class="notice-list flex h-full flex-col p-3">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Bell class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
<ElTag v-if="unreadCount > 0" type="danger" size="small" round>
{{ unreadCount }}
</ElTag>
</div>
<ElButton
v-if="unreadCount > 0"
type="primary"
text
size="small"
@click="markAllRead"
>
<CheckCheck class="mr-1 h-3.5 w-3.5" />
{{ $t('dashboard-design.widgets.announcement.markAllRead') }}
</ElButton>
</div>
<ElScrollbar class="flex-1">
<div v-if="messages.length > 0" class="space-y-2">
<div
v-for="msg in messages"
:key="msg.id"
class="flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
:class="{ 'opacity-60': msg.status === 'read' }"
@click="viewDetail(msg)"
>
<ElTag
:type="getTypeConfig(msg.msg_type).type"
size="small"
class="flex-shrink-0"
>
{{ getTypeConfig(msg.msg_type).label }}
</ElTag>
<div class="min-w-0 flex-1">
<div class="truncate text-sm">{{ msg.title }}</div>
<div class="text-muted-foreground mt-1 flex items-center text-xs">
<span v-if="msg.content" class="flex-1 truncate">{{
msg.content
}}</span>
<span class="ml-2 flex-shrink-0">{{
formatTime(msg.created_at)
}}</span>
</div>
</div>
</div>
</div>
<ElEmpty
v-else
:description="
$t('message.noData') ||
$t('dashboard-design.widgets.announcement.noData')
"
:image-size="60"
/>
</ElScrollbar>
<!-- 消息详情弹窗 -->
<ElDialog
v-model="detailVisible"
:title="currentMessage?.title"
width="600px"
destroy-on-close
append-to-body
>
<div v-if="currentMessage" class="min-h-[500px] space-y-4">
<div class="flex items-center gap-2">
<ElTag
:type="getTypeConfig(currentMessage.msg_type).type"
size="small"
>
{{ getTypeConfig(currentMessage.msg_type).label }}
</ElTag>
<span class="text-muted-foreground text-xs">{{
formatTime(currentMessage.created_at)
}}</span>
</div>
<div class="whitespace-pre-wrap text-sm leading-relaxed">
{{
currentMessage.content ||
$t('dashboard-design.widgets.announcement.noContent')
}}
</div>
<!-- 审批链接按钮 -->
<div
v-if="currentMessage.link_type && currentMessage.link_id"
class="mt-4"
>
<ElButton type="text" @click="handleGoToLink(currentMessage)">
<ExternalLink class="mr-1 h-3.5 w-3.5" />
查看详情
</ElButton>
</div>
<div
v-if="currentMessage.sender_name"
class="text-muted-foreground text-xs"
>
{{ $t('message.sender') || '发送者:'
}}{{ currentMessage.sender_name }}
</div>
</div>
</ElDialog>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Grid, IconifyIcon } from '@vben/icons';
import { useAppContextStore } from '#/store/app-context';
const props = defineProps<{
widget: DashboardWidget;
}>();
const router = useRouter();
const appContextStore = useAppContextStore();
// 计算网格样式
const gridStyle = computed(() => {
const cols = props.widget.props.columns || 4;
const rows = props.widget.props.rows || 2;
return {
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
};
});
// 计算总格子数
const totalCells = computed(() => {
const cols = props.widget.props.columns || 4;
const rows = props.widget.props.rows || 2;
return cols * rows;
});
// 获取指定位置的菜单
const getMenuAt = (index: number) => {
const menus = props.widget.props.menus || [];
return menus[index] || null;
};
// 图标颜色样式
const iconColorStyle = computed(() => {
const color = props.widget.props.iconColor;
return color ? { color } : {};
});
// 获取 item 背景样式(支持渐变色)
const getItemBgStyle = (menu: any) => {
if (!menu?.bgColor) return {};
if (menu.bgColor.includes('gradient')) {
return { background: menu.bgColor };
}
return { backgroundColor: menu.bgColor };
};
// 点击菜单项
const handleClick = (menu: any) => {
if (!menu || !menu.path) return;
// 外链
if (menu.path.startsWith('http://') || menu.path.startsWith('https://')) {
window.open(menu.path, '_blank');
return;
}
// 路由跳转(子应用模式下始终使用 /app/{code} 前缀)
const code = appContextStore.appCode;
const targetPath = code ? `/app/${code}${menu.path}` : menu.path;
router.push(targetPath);
};
</script>
<template>
<div class="quick-links flex h-full flex-col p-3">
<div class="mb-3 flex items-center gap-2">
<Grid class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<div class="grid flex-1 gap-2" :style="gridStyle">
<template v-for="idx in totalCells" :key="idx">
<div
v-if="getMenuAt(idx - 1)"
class="flex cursor-pointer flex-col items-center justify-center gap-1 rounded-lg p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:style="getItemBgStyle(getMenuAt(idx - 1))"
@click="handleClick(getMenuAt(idx - 1))"
>
<IconifyIcon
v-if="getMenuAt(idx - 1)?.icon"
:icon="getMenuAt(idx - 1).icon"
class="h-6 w-6"
:class="{ 'text-primary': !widget.props.iconColor }"
:style="iconColorStyle"
/>
<Grid
v-else
class="h-6 w-6"
:class="{ 'text-primary': !widget.props.iconColor }"
:style="iconColorStyle"
/>
<span
class="text-muted-foreground w-full truncate text-center text-xs"
>{{ getMenuAt(idx - 1)?.title }}</span
>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-1 rounded-lg p-2"
>
<!-- 空位占位 -->
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,294 @@
<script setup lang="ts">
import type {
RealtimeStats,
ServerMonitorResponse,
} from '#/api/core/server-monitor';
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Cpu, Database, HardDrive, Network } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElProgress } from 'element-plus';
import {
getRealtimeStatsApi,
getServerOverviewApi,
} from '#/api/core/server-monitor';
const props = defineProps<{
widget: DashboardWidget;
}>();
const loading = ref(false);
const serverData = ref<null | ServerMonitorResponse>(null);
const realtimeData = ref<null | RealtimeStats>(null);
let timer: null | ReturnType<typeof setInterval> = null;
// 刷新间隔
const refreshInterval = computed(
() => props.widget.props.refreshInterval || 5000,
);
// 区域背景色
function getAreaStyle(colorProp: string) {
const color = props.widget.props[colorProp];
if (!color) return {};
if (color.includes('gradient')) return { background: color };
return { backgroundColor: color };
}
// 格式化字节
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
// 格式化内存(后端返回 GB
function formatMemory(gb: number): string {
if (gb === 0) return '0 GB';
if (gb < 1) return `${(gb * 1024).toFixed(0)} MB`;
return `${gb.toFixed(1)} GB`;
}
// 格式化速度
function formatSpeed(bytesPerSecond: number): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
// 使用率颜色
function getProgressColor(percent: number): string {
if (percent >= 90) return 'var(--el-color-danger)';
if (percent >= 70) return 'var(--el-color-warning)';
return 'var(--el-color-success)';
}
// 格式化运行时间
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days > 0)
parts.push(`${days}${$t('dashboard-design.widgets.serverMonitor.days')}`);
if (hours > 0)
parts.push(`${hours}${$t('dashboard-design.widgets.serverMonitor.hours')}`);
if (minutes > 0)
parts.push(
`${minutes}${$t('dashboard-design.widgets.serverMonitor.minutes')}`,
);
return parts.join(' ') || '-';
}
// 加载数据
async function loadData(showLoading = false) {
if (showLoading) loading.value = true;
try {
const [overview, realtime] = await Promise.all([
getServerOverviewApi(),
getRealtimeStatsApi(),
]);
serverData.value = overview;
realtimeData.value = realtime;
} catch (error) {
console.error('Failed to load server monitor data:', error);
} finally {
if (showLoading) loading.value = false;
}
}
// 自动刷新
function startAutoRefresh() {
if (timer) return;
timer = setInterval(() => {
getRealtimeStatsApi()
.then((data) => {
realtimeData.value = data;
})
.catch(() => {});
}, refreshInterval.value);
}
function stopAutoRefresh() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMounted(async () => {
await loadData(true);
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
});
</script>
<template>
<div class="server-monitor flex h-full flex-col p-4" v-loading="loading">
<!-- 头部 -->
<div class="mb-6 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<span class="text-muted-foreground text-xs">
{{ serverData?.basic_info?.hostname || '-' }}
({{ serverData?.basic_info?.ip_address || '-' }})
</span>
</div>
<!-- 指标网格 -->
<div class="grid max-h-[150px] flex-1 grid-cols-2 gap-3 lg:grid-cols-4">
<!-- CPU -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.cpuBgColor }"
:style="getAreaStyle('cpuBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(59, 130, 246, 0.15)"
>
<Cpu class="h-4 w-4" style="color: var(--el-color-primary)" />
</div>
<span class="text-muted-foreground text-xs">CPU</span>
</div>
<div class="mb-1 text-xl font-bold">
{{ realtimeData?.cpu_percent?.toFixed(1) || '0.0' }}%
</div>
<ElProgress
:percentage="Number(realtimeData?.cpu_percent?.toFixed(1) || 0)"
:color="getProgressColor(realtimeData?.cpu_percent || 0)"
:show-text="false"
:stroke-width="4"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ serverData?.cpu_info?.physical_cores || 0
}}{{ $t('dashboard-design.widgets.serverMonitor.core') }}
{{ serverData?.cpu_info?.total_cores || 0
}}{{ $t('dashboard-design.widgets.serverMonitor.thread') }}
</div>
</div>
<!-- 内存 -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.memoryBgColor }"
:style="getAreaStyle('memoryBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(34, 197, 94, 0.15)"
>
<Database class="h-4 w-4" style="color: var(--el-color-success)" />
</div>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.memory')
}}</span>
</div>
<div class="mb-1 text-xl font-bold">
{{ realtimeData?.memory_percent?.toFixed(1) || '0.0' }}%
</div>
<ElProgress
:percentage="Number(realtimeData?.memory_percent?.toFixed(1) || 0)"
:color="getProgressColor(realtimeData?.memory_percent || 0)"
:show-text="false"
:stroke-width="4"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ formatMemory(realtimeData?.memory_details?.used || 0) }}
/ {{ formatMemory(realtimeData?.memory_details?.total || 0) }}
</div>
</div>
<!-- 磁盘 -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.diskBgColor }"
:style="getAreaStyle('diskBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(139, 92, 246, 0.15)"
>
<HardDrive class="h-4 w-4" style="color: var(--el-color-warning)" />
</div>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.disk')
}}</span>
</div>
<div class="space-y-0.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.read')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.disk_io?.read_speed || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.write')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.disk_io?.write_speed || 0)
}}</span>
</div>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('dashboard-design.widgets.serverMonitor.totalRW') }}:
{{ formatBytes(realtimeData?.disk_total?.read_bytes || 0) }} /
{{ formatBytes(realtimeData?.disk_total?.write_bytes || 0) }}
</div>
</div>
<!-- 网络 -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.networkBgColor }"
:style="getAreaStyle('networkBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(249, 115, 22, 0.15)"
>
<Network class="h-4 w-4" style="color: var(--el-color-danger)" />
</div>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.network')
}}</span>
</div>
<div class="space-y-0.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.upload')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.network_io?.upload_speed || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.download')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.network_io?.download_speed || 0)
}}</span>
</div>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('dashboard-design.widgets.serverMonitor.uptime') }}:
{{ formatUptime(serverData?.boot_time?.uptime_seconds || 0) }}
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,364 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
Cloud,
CloudDrizzle,
CloudFog,
CloudRain,
CloudSnow,
CloudSun,
Sun,
Zap,
} from '@vben/icons';
import { $t } from '@vben/locales';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 实时天气数据
const weatherData = ref<null | {
humidity: number;
temperature: number;
weatherCode: number;
windDirection: number;
windSpeed: number;
}>(null);
const error = ref(false);
const locatedCityName = ref('');
let timer: null | ReturnType<typeof setInterval> = null;
// 显示的城市名:优先手动配置,其次自动定位
const displayCityName = computed(() => {
return props.widget.props.cityName || locatedCityName.value || '-';
});
// 反向地理编码获取城市名
async function reverseGeocode(lat: number, lon: number) {
try {
const res = await fetch(
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=zh`,
);
if (!res.ok) return;
const data = await res.json();
locatedCityName.value =
data?.city || data?.locality || data?.principalSubdivision || '';
} catch {
// 反向编码失败不影响天气显示
}
}
// WMO Weather Code 映射
function getWeatherInfo(code: number): {
color: string;
icon: any;
label: string;
} {
// 晴天
if (code === 0)
return {
icon: Sun,
color: '#f59e0b',
label: $t('dashboard-design.widgets.weather.codes.clear'),
};
// 少云/多云
if (code === 1)
return {
icon: CloudSun,
color: '#60a5fa',
label: $t('dashboard-design.widgets.weather.codes.mainlyClear'),
};
if (code === 2)
return {
icon: CloudSun,
color: '#60a5fa',
label: $t('dashboard-design.widgets.weather.codes.partlyCloudy'),
};
if (code === 3)
return {
icon: Cloud,
color: '#9ca3af',
label: $t('dashboard-design.widgets.weather.codes.overcast'),
};
// 雾
if (code === 45 || code === 48)
return {
icon: CloudFog,
color: '#9ca3af',
label: $t('dashboard-design.widgets.weather.codes.fog'),
};
// 毛毛雨
if (code >= 51 && code <= 57)
return {
icon: CloudDrizzle,
color: '#60a5fa',
label: $t('dashboard-design.widgets.weather.codes.drizzle'),
};
// 雨
if (code >= 61 && code <= 67)
return {
icon: CloudRain,
color: '#3b82f6',
label: $t('dashboard-design.widgets.weather.codes.rain'),
};
// 雪
if (code >= 71 && code <= 77)
return {
icon: CloudSnow,
color: '#a5b4fc',
label: $t('dashboard-design.widgets.weather.codes.snow'),
};
// 阵雨
if (code >= 80 && code <= 82)
return {
icon: CloudRain,
color: '#3b82f6',
label: $t('dashboard-design.widgets.weather.codes.showers'),
};
// 阵雪
if (code >= 85 && code <= 86)
return {
icon: CloudSnow,
color: '#a5b4fc',
label: $t('dashboard-design.widgets.weather.codes.snowShowers'),
};
// 雷暴
if (code >= 95 && code <= 99)
return {
icon: Zap,
color: '#eab308',
label: $t('dashboard-design.widgets.weather.codes.thunderstorm'),
};
return {
icon: Sun,
color: '#f59e0b',
label: $t('dashboard-design.widgets.weather.codes.clear'),
};
}
// 风向角度转文字
function getWindDirection(degree: number): string {
const dirs = [
$t('dashboard-design.widgets.weather.windDir.n'),
$t('dashboard-design.widgets.weather.windDir.ne'),
$t('dashboard-design.widgets.weather.windDir.e'),
$t('dashboard-design.widgets.weather.windDir.se'),
$t('dashboard-design.widgets.weather.windDir.s'),
$t('dashboard-design.widgets.weather.windDir.sw'),
$t('dashboard-design.widgets.weather.windDir.w'),
$t('dashboard-design.widgets.weather.windDir.nw'),
];
const index = Math.round(degree / 45) % 8;
return dirs[index] || '';
}
// 当前天气信息
const currentWeather = computed(() => {
if (!weatherData.value) return null;
const info = getWeatherInfo(weatherData.value.weatherCode);
return {
...info,
temperature: Math.round(weatherData.value.temperature),
humidity: weatherData.value.humidity,
wind: `${getWindDirection(weatherData.value.windDirection)} ${weatherData.value.windSpeed.toFixed(0)} km/h`,
};
});
// 获取天气数据
async function fetchWeather() {
const lat = props.widget.props.latitude;
const lon = props.widget.props.longitude;
if (!lat || !lon) return;
try {
error.value = false;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m&timezone=auto`;
const res = await fetch(url);
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.current) {
weatherData.value = {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
weatherCode: data.current.weather_code,
windSpeed: data.current.wind_speed_10m,
windDirection: data.current.wind_direction_10m,
};
}
} catch {
error.value = true;
console.error('Failed to fetch weather data');
}
}
// 自动刷新
function startAutoRefresh() {
stopAutoRefresh();
const interval = (props.widget.props.refreshInterval || 30) * 60 * 1000; // 分钟转毫秒
timer = setInterval(fetchWeather, interval);
}
function stopAutoRefresh() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
// 监听经纬度变化重新获取
watch(
() => [props.widget.props.latitude, props.widget.props.longitude],
() => {
fetchWeather();
},
);
onMounted(async () => {
// 如果开启自动定位且没有经纬度
if (props.widget.props.autoLocate && !props.widget.props.latitude) {
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
timeout: 5000,
});
});
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
// 并行获取天气数据和城市名
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m&timezone=auto`;
const [res] = await Promise.all([fetch(url), reverseGeocode(lat, lon)]);
const data = await res.json();
if (data.current) {
weatherData.value = {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
weatherCode: data.current.weather_code,
windSpeed: data.current.wind_speed_10m,
windDirection: data.current.wind_direction_10m,
};
}
} catch {
// 定位失败,使用默认经纬度
await fetchWeather();
}
} else {
await fetchWeather();
}
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
});
</script>
<template>
<div class="weather-widget flex h-full flex-col p-3">
<div
v-if="widget.props.title"
class="text-muted-foreground mb-2 text-sm font-medium"
>
{{ widget.props.title }}
</div>
<!-- 加载/错误状态 -->
<div
v-if="!currentWeather && !error"
class="flex flex-1 items-center justify-center"
>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.weather.loading')
}}</span>
</div>
<div
v-else-if="error && !currentWeather"
class="flex flex-1 items-center justify-center"
>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.weather.error')
}}</span>
</div>
<!-- 天气数据 -->
<div v-else-if="currentWeather" class="flex flex-1 items-center gap-4">
<!-- 天气图标和温度 -->
<div class="flex items-center gap-3">
<component
:is="currentWeather.icon"
class="h-12 w-12"
:style="{ color: currentWeather.color }"
/>
<div>
<div class="temperature">{{ currentWeather.temperature }}°</div>
<div class="weather-text">
{{ currentWeather.label }}
</div>
</div>
</div>
<!-- 详细信息 -->
<div class="weather-details">
<div class="detail-item">
<span class="detail-label">{{
$t('dashboard-design.widgets.weather.city')
}}</span>
<span class="detail-value">{{ displayCityName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{
$t('dashboard-design.widgets.weather.humidity')
}}</span>
<span class="detail-value">{{ currentWeather.humidity }}%</span>
</div>
<div class="detail-item">
<span class="detail-label">{{
$t('dashboard-design.widgets.weather.wind')
}}</span>
<span class="detail-value">{{ currentWeather.wind }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.temperature {
font-size: 2rem;
font-weight: 600;
line-height: 1;
color: var(--el-text-color-primary);
}
.weather-text {
margin-top: 4px;
font-size: 0.875rem;
color: var(--el-text-color-secondary);
}
.weather-details {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 16px;
border-left: 1px solid var(--el-border-color-lighter);
}
.detail-item {
display: flex;
gap: 8px;
align-items: center;
font-size: 0.75rem;
}
.detail-label {
min-width: 32px;
color: var(--el-text-color-secondary);
}
.detail-value {
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Smile } from '@vben/icons';
import { $t } from '@vben/locales';
import { useUserStore } from '@vben/stores';
import { UserAvatar } from '#/components/user-avatar';
defineProps<{
widget: DashboardWidget;
}>();
const userStore = useUserStore();
// 用户信息
const userInfo = computed(() => userStore.userInfo);
// 用户名
const userName = computed(() => userStore.userInfo?.realName || '');
const currentTime = ref(new Date());
let timer: null | ReturnType<typeof setInterval> = null;
onMounted(() => {
timer = setInterval(() => {
currentTime.value = new Date();
}, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
const greeting = computed(() => {
const hour = currentTime.value.getHours();
if (hour < 6) return $t('dashboard-design.widgets.welcome.greeting.night');
if (hour < 9) return $t('dashboard-design.widgets.welcome.greeting.morning');
if (hour < 12)
return $t('dashboard-design.widgets.welcome.greeting.morning2');
if (hour < 14) return $t('dashboard-design.widgets.welcome.greeting.noon');
if (hour < 18)
return $t('dashboard-design.widgets.welcome.greeting.afternoon');
if (hour < 22) return $t('dashboard-design.widgets.welcome.greeting.evening');
return $t('dashboard-design.widgets.welcome.greeting.night');
});
const formattedTime = computed(() => {
return currentTime.value.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
});
const formattedDate = computed(() => {
return currentTime.value.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
});
});
</script>
<template>
<div class="welcome-card flex h-full items-center justify-between p-4">
<div class="flex items-center gap-4">
<!-- 用户头像 -->
<UserAvatar
v-if="userInfo"
:name="userName"
:avatar="userInfo.avatar"
:size="48"
:font-size="20"
:show-popover="false"
:shadow="false"
class="flex-shrink-0"
/>
<div
v-else
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full"
style="
background: linear-gradient(
135deg,
var(--el-color-primary-light-3),
var(--el-color-primary)
);
"
>
<Smile class="h-6 w-6 text-white" />
</div>
<div>
<div class="text-lg font-semibold">
{{ greeting }}{{ userName
}}{{ widget.props.title ? `${widget.props.title}` : '' }}
</div>
<div class="text-muted-foreground text-sm">
{{ widget.props.subtitle }}
</div>
</div>
</div>
<div v-if="widget.props.showTime" class="text-right">
<div class="text-2xl font-bold tabular-nums">{{ formattedTime }}</div>
<div class="text-muted-foreground text-sm">{{ formattedDate }}</div>
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,12 @@
export { default as DashboardRenderer } from './DashboardRenderer.vue';
export type {
DashboardConfig,
DashboardWidget,
DataSourceConfig,
DataSourceType,
WidgetMaterial,
WidgetType,
} from './store/dashboardDesignStore';
export { createRefreshTimer, fetchWidgetData } from './utils/dataFetcher';
@@ -0,0 +1,314 @@
import type {
DataSourceConfig,
FieldMapping,
} from '../store/dashboardDesignStore';
import { $t } from '@vben/locales';
import { requestClient } from '#/api/request';
/**
* 根据路径获取对象中的值
* @param obj 对象
* @param path 路径,如 'data.list' 或 'data.items[0].name'
*/
export function getValueByPath(obj: any, path: string): any {
if (!obj || !path) return obj;
const keys = path.replaceAll(/\[(\d+)\]/g, '.$1').split('.');
let result = obj;
for (const key of keys) {
if (result === null || result === undefined) return undefined;
result = result[key];
}
return result;
}
/**
* 根据路径设置对象中的值
*/
export function setValueByPath(obj: any, path: string, value: any): void {
if (!obj || !path) return;
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!;
if (current[key] === undefined) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]!] = value;
}
/**
* 应用字段映射
*/
export function applyFieldMappings(
data: any,
mappings: FieldMapping[] | undefined,
targetProps: Record<string, any>,
): Record<string, any> {
if (!mappings || mappings.length === 0) {
return { ...targetProps, ...data };
}
const result = { ...targetProps };
for (const mapping of mappings) {
const value = getValueByPath(data, mapping.source);
if (value !== undefined) {
setValueByPath(result, mapping.target, value);
}
}
return result;
}
/**
* 数据获取工具
*/
export async function fetchWidgetData(
dataSource: DataSourceConfig | undefined,
defaultProps: Record<string, any>,
params?: Record<string, any>,
): Promise<{ data: any; props: Record<string, any> }> {
if (!dataSource || dataSource.type === 'static') {
return { data: null, props: defaultProps };
}
// 通用数据源类型
if (dataSource.type === 'dataSource' && dataSource.dataSourceCode) {
try {
const response = await requestClient.get(
`/api/core/data-source/execute/${dataSource.dataSourceCode}`,
{ params: params && Object.keys(params).length > 0 ? params : undefined },
);
// 后端返回格式是 {data: ...},需要提取 data 字段
const rawData = response?.data ?? response;
// 数据源返回的数据可能是数组或对象(图表数据源返回 {xAxisData, seriesData} 格式)
const extractedData = dataSource.dataPath
? getValueByPath(rawData, dataSource.dataPath)
: rawData;
// 检查是否是图表数据格式(包含 xAxisData 或 seriesData 或 indicator 或 value
if (
extractedData &&
typeof extractedData === 'object' &&
!Array.isArray(extractedData) &&
('xAxisData' in extractedData ||
'seriesData' in extractedData ||
'indicator' in extractedData ||
'yAxisData' in extractedData ||
('value' in extractedData && 'max' in extractedData))
) {
// 图表数据格式,直接合并到 props
const mappedProps = { ...defaultProps, ...extractedData };
// 如果有额外的字段映射,也应用
if (dataSource.fieldMappings && dataSource.fieldMappings.length > 0) {
return {
data: extractedData,
props: applyFieldMappings(
extractedData,
dataSource.fieldMappings,
mappedProps,
),
};
}
return { data: extractedData, props: mappedProps };
}
// 普通数据,应用字段映射
const mappedProps = applyFieldMappings(
extractedData,
dataSource.fieldMappings,
defaultProps,
);
return { data: extractedData, props: mappedProps };
} catch (error) {
console.error('Failed to fetch data source:', error);
return { data: null, props: defaultProps };
}
}
// API 类型
if (dataSource.type === 'api' && dataSource.apiUrl) {
try {
const method = dataSource.apiMethod || 'GET';
const params = dataSource.apiParams || {};
const body = dataSource.apiBody || {};
const headers = dataSource.apiHeaders || {};
let response: any;
response = await (method === 'GET'
? requestClient.get(dataSource.apiUrl, {
params,
headers,
})
: requestClient.post(dataSource.apiUrl, body, {
params,
headers,
}));
// 根据 dataPath 提取数据
const extractedData = dataSource.dataPath
? getValueByPath(response, dataSource.dataPath)
: response;
// 应用字段映射
const mappedProps = applyFieldMappings(
extractedData,
dataSource.fieldMappings,
defaultProps,
);
return { data: extractedData, props: mappedProps };
} catch (error) {
console.error('Failed to fetch widget data:', error);
return { data: null, props: defaultProps };
}
}
return { data: null, props: defaultProps };
}
/**
* 创建自动刷新定时器
*/
export function createRefreshTimer(
dataSource: DataSourceConfig | undefined,
callback: () => void,
): (() => void) | null {
if (
!dataSource?.refreshEnabled ||
!dataSource.refreshInterval ||
dataSource.refreshInterval <= 0
) {
return null;
}
const timer = setInterval(callback, dataSource.refreshInterval * 1000);
return () => clearInterval(timer);
}
/**
* 获取组件支持的字段映射目标
*/
export function getWidgetFieldTargets(
widgetType: string,
): { key: string; label: string }[] {
const commonFields = [
{ key: 'title', label: $t('dashboard-design.attribute.fieldLabels.title') },
];
const fieldMap: Record<string, { key: string; label: string }[]> = {
'stat-card': [
...commonFields,
{
key: 'value',
label: $t('dashboard-design.attribute.fieldLabels.value'),
},
{
key: 'trend',
label: $t('dashboard-design.attribute.fieldLabels.trend'),
},
{
key: 'trendLabel',
label: $t('dashboard-design.attribute.fieldLabels.trendLabel'),
},
{
key: 'prefix',
label: $t('dashboard-design.attribute.fieldLabels.prefix'),
},
{
key: 'suffix',
label: $t('dashboard-design.attribute.fieldLabels.suffix'),
},
],
'progress-card': [
...commonFields,
{
key: 'percentage',
label: $t('dashboard-design.attribute.fieldLabels.percentage'),
},
],
'chart-line': [
...commonFields,
{
key: 'xAxisData',
label: $t('dashboard-design.attribute.fieldLabels.xAxisData'),
},
{
key: 'seriesData',
label: $t('dashboard-design.attribute.fieldLabels.seriesData'),
},
],
'chart-bar': [
...commonFields,
{
key: 'xAxisData',
label: $t('dashboard-design.attribute.fieldLabels.xAxisData'),
},
{
key: 'seriesData',
label: $t('dashboard-design.attribute.fieldLabels.seriesData'),
},
],
'chart-pie': [
...commonFields,
{
key: 'seriesData',
label: $t('dashboard-design.attribute.fieldLabels.seriesData'),
},
],
'chart-gauge': [
...commonFields,
{
key: 'value',
label: $t('dashboard-design.attribute.fieldLabels.currentValue'),
},
{ key: 'min', label: $t('dashboard-design.attribute.fieldLabels.min') },
{ key: 'max', label: $t('dashboard-design.attribute.fieldLabels.max') },
],
'todo-list': [
...commonFields,
{
key: 'items',
label: $t('dashboard-design.attribute.fieldLabels.listData'),
},
],
'notice-list': [
...commonFields,
{
key: 'items',
label: $t('dashboard-design.attribute.fieldLabels.listData'),
},
],
'ranking-list': [
...commonFields,
{
key: 'items',
label: $t('dashboard-design.attribute.fieldLabels.listData'),
},
],
'quick-links': [
...commonFields,
{
key: 'links',
label: $t('dashboard-design.attribute.fieldLabels.linkData'),
},
],
};
return fieldMap[widgetType] || commonFields;
}
+9
View File
@@ -32,11 +32,20 @@ const baseModules = import.meta.glob([
const businessModules = import.meta.glob([
'./langs/zh-CN/ai-platform.json',
'./langs/zh-CN/announcement.json',
'./langs/zh-CN/dashboard-design.json',
'./langs/zh-CN/dept.json',
'./langs/zh-CN/dict.json',
'./langs/zh-CN/file-manager.json',
'./langs/zh-CN/loginLog.json',
'./langs/zh-CN/menu.json',
'./langs/zh-CN/message.json',
'./langs/zh-CN/permission.json',
'./langs/zh-CN/post.json',
'./langs/zh-CN/role.json',
'./langs/zh-CN/server-monitor.json',
'./langs/zh-CN/system.json',
'./langs/zh-CN/system-config.json',
'./langs/zh-CN/ui-config.json',
'./langs/zh-CN/user.json',
]);
@@ -0,0 +1,22 @@
{
"name": "字典",
"title": "字典管理",
"dictName": "字典名称",
"dictCode": "字典编码",
"remark": "备注",
"remarkPlaceholder": "请输入备注",
"status": "状态",
"operation": "操作",
"edit": "编辑",
"codeFormatError": "字典编码只能包含字母、数字和下划线",
"selectDictFirst": "请先选择字典",
"noData": "暂无数据",
"itemName": "字典项",
"itemLabel": "标签",
"itemValue": "值",
"itemIcon": "图标",
"sort": "排序",
"isGlobal": "全局可见",
"globalTag": "全局",
"mainApp": "主应用"
}
@@ -9,9 +9,17 @@
"codex": "Codex",
"startChat": "开始聊天",
"systemManagement": "系统管理",
"systemTools": "系统工具",
"applicationManagement": "应用管理",
"systemMonitoring": "系统监控",
"userManagement": "用户管理",
"departmentManagement": "部门管理",
"positionManagement": "岗位管理",
"dictionaryManagement": "字典管理",
"fileManagement": "文件管理",
"serverMonitoring": "服务器监控",
"uiConfig": "界面配置",
"loginLog": "登录日志",
"roleManagement": "角色权限",
"permissionManagement": "API管理",
"menuManagement": "菜单管理",
@@ -0,0 +1,46 @@
{
"title": "系统配置",
"ssoConfig": "SSO配置",
"notifyConfig": "通知配置",
"modelConfig": "模型配置",
"save": "保存",
"reset": "恢复默认",
"resetConfirm": "确定要恢复该分组为默认配置吗?这将删除数据库中的自定义配置。",
"saveSuccess": "保存成功",
"saveError": "保存失败",
"resetSuccess": "已恢复为默认配置",
"resetError": "恢复失败",
"secretTip": "敏感字段会脱敏显示,留空不会修改原值。",
"groups": {
"oauth_gitee": "Gitee",
"oauth_github": "GitHub",
"oauth_qq": "QQ",
"oauth_google": "Google",
"oauth_wechat": "微信",
"oauth_microsoft": "Microsoft",
"notify_email": "邮件通知",
"notify_sms": "短信通知",
"notify_wechat_mp": "微信公众号"
},
"fields": {
"client_id": "Client ID",
"client_secret": "Client Secret",
"redirect_uri": "Web 回调地址",
"h5_redirect_uri": "H5 回调地址",
"app_id": "App ID",
"app_key": "App Key",
"app_secret": "App Secret",
"smtp_host": "SMTP 服务器",
"smtp_port": "SMTP 端口",
"smtp_user": "SMTP 用户名",
"smtp_password": "SMTP 密码",
"smtp_use_tls": "使用 TLS",
"smtp_from_name": "发件人名称",
"smtp_from_email": "发件人邮箱",
"provider": "服务商",
"providerAliyun": "阿里云",
"providerTencent": "腾讯云",
"todo_pc_url": "待办 PC 端跳转地址",
"todo_app_url": "待办移动端跳转地址"
}
}
@@ -0,0 +1,95 @@
{
"title": "界面配置",
"appConfig": "应用配置",
"styleConfig": "样式配置",
"logoConfig": "Logo配置",
"save": "保存",
"saveSuccess": "保存成功",
"saveError": "保存失败",
"app": {
"title": "应用配置",
"name": "应用名称",
"namePlaceholder": "请输入应用名称",
"defaultHomePath": "默认首页路径",
"defaultHomePathPlaceholder": "请输入默认首页路径",
"locale": "默认语言",
"dynamicTitle": "动态标题",
"dynamicTitleTip": "开启后页面标题会根据当前路由变化",
"watermark": "水印",
"watermarkTip": "开启后页面会显示水印",
"watermarkContent": "水印内容",
"watermarkContentPlaceholder": "请输入水印内容",
"enablePreferences": "启用偏好设置",
"enablePreferencesTip": "开启后用户可以在界面中修改偏好设置",
"layout": "布局模式",
"layoutOptions": {
"sidebar-nav": "侧边导航",
"header-nav": "顶部导航",
"mixed-nav": "混合导航",
"header-sidebar-nav": "顶部+侧边导航"
}
},
"theme": {
"mode": "主题模式",
"modeOptions": {
"light": "浅色",
"dark": "深色",
"auto": "跟随系统"
},
"colorPrimary": "主题色",
"radius": "圆角大小",
"builtinType": "内置主题",
"semiDarkSidebar": "深色侧边栏",
"semiDarkHeader": "深色顶栏"
},
"logo": {
"enable": "启用 Logo",
"source": "Logo 图片",
"sourcePlaceholder": "请输入 Logo 图片 URL 或上传",
"fit": "适应方式",
"fitOptions": {
"contain": "包含",
"cover": "覆盖",
"fill": "填充",
"none": "无",
"scale-down": "缩小"
}
},
"copyright": {
"enable": "启用版权",
"companyName": "公司名称",
"companyNamePlaceholder": "请输入公司名称",
"companySiteLink": "公司网站",
"companySiteLinkPlaceholder": "请输入公司网站链接",
"date": "版权年份",
"datePlaceholder": "请输入版权年份",
"icp": "ICP备案号",
"icpPlaceholder": "请输入ICP备案号",
"icpLink": "ICP备案链接",
"icpLinkPlaceholder": "请输入ICP备案链接",
"policeIcp": "公安备案号",
"policeIcpPlaceholder": "请输入公安备案号",
"policeIcpLink": "公安备案链接",
"policeIcpLinkPlaceholder": "请输入公安备案链接",
"loginOnly": "仅登录页显示",
"loginOnlyTip": "开启后版权信息仅在登录页面显示"
},
"loginConfig": {
"title": "登录配置",
"enableThirdPartyLogin": "启用第三方登录",
"enableThirdPartyLoginTip": "开启后登录页会显示第三方登录区域",
"enabledProviders": "启用的登录方式",
"enabledProvidersTip": "选择需要在登录页面显示的第三方登录方式",
"providers": {
"gitee": "Gitee",
"github": "GitHub",
"google": "Google",
"microsoft": "Microsoft",
"qq": "QQ",
"wechat": "微信",
"wecom": "企业微信",
"dingtalk": "钉钉",
"feishu": "飞书"
}
}
}
+23 -23
View File
@@ -11,28 +11,9 @@ import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales';
import { useAppContextStore } from '#/store/app-context';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
import { isLightMenuName } from './light-menu';
const lightMenuNames = new Set([
'AIAgent',
'AIKnowledgeDetail',
'AIModelConfig',
'AIPlatform',
'AIWorkflow',
'AIWorkflowRuns',
'AnnouncementList',
'AnnouncementManage',
'Codex',
'ControlCenter',
'KnowledgeBase',
'Message',
'MessageList',
'SystemManagement',
'SystemMenu',
'SystemPermission',
'SystemRole',
'UserManagement',
]);
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
function normalizeViewPath(path: string) {
const normalizedPath = path.replace(/^(\.\/|\.\.\/)+/, '');
@@ -85,7 +66,7 @@ function normalizeBackendRoute<
}
function isLightMenuRoute(route: { name?: string }) {
return !route.name || lightMenuNames.has(route.name);
return isLightMenuName(route.name);
}
function filterAvailableRoutes<
@@ -102,12 +83,16 @@ function filterAvailableRoutes<
layoutMap: ComponentRecordType,
): T[] {
return routes
.filter(isLightMenuRoute)
.map((route) => {
const normalizedRoute = normalizeBackendRoute(route);
const children = normalizedRoute.children
? filterAvailableRoutes(normalizedRoute.children, pageMap, layoutMap)
: undefined;
const routeAllowed = isLightMenuRoute(normalizedRoute);
if (!routeAllowed && !children?.length) {
return undefined;
}
if (
!normalizedRoute.component &&
@@ -117,6 +102,12 @@ function filterAvailableRoutes<
return undefined;
}
if (!routeAllowed && children?.length) {
const groupRoute = { ...normalizedRoute, children };
delete groupRoute.component;
return groupRoute;
}
if (!hasPageComponent(normalizedRoute.component, pageMap, layoutMap)) {
if (!children?.length) return undefined;
@@ -135,15 +126,24 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
'../views/_core/agent-chat/**/*.vue',
'../views/_core/announcement/index.vue',
'../views/_core/announcement/list.vue',
'../views/_core/account-settings/index.vue',
'../views/_core/authentication/login.vue',
'../views/_core/authentication/oauth-callback.vue',
'../views/_core/dept/index.vue',
'../views/_core/dict/index.vue',
'../views/_core/fallback/**/*.vue',
'../views/_core/file-manager/index.vue',
'../views/_core/file-preview/index.vue',
'../views/_core/login-log/index.vue',
'../views/_core/menu/index.vue',
'../views/_core/message/index.vue',
'../views/_core/page-render/index.vue',
'../views/_core/permission/index.vue',
'../views/_core/post/index.vue',
'../views/_core/role/index.vue',
'../views/_core/server-monitor/index.vue',
'../views/_core/system-config/index.vue',
'../views/_core/ui-config/index.vue',
'../views/_core/user/index.vue',
'../views/ai-platform/agent/editor/index.vue',
'../views/ai-platform/agent/index.vue',
+54
View File
@@ -0,0 +1,54 @@
export const LIGHT_MENU_NAMES = new Set([
'AIAgent',
'AIKnowledgeDetail',
'AIModelConfig',
'AIPlatform',
'AIWorkflow',
'AIWorkflowRuns',
'AccountSettings',
'AnnouncementList',
'AnnouncementManage',
'Codex',
'ControlCenter',
'FileManager',
'KnowledgeBase',
'LoginLog',
'Message',
'MessageList',
'ServerMonitor',
'SystemConfigManager',
'SystemDept',
'SystemDict',
'SystemFileManager',
'SystemLoginLog',
'SystemManagement',
'SystemMenu',
'SystemPost',
'SystemPermission',
'SystemRole',
'UIConfigManager',
'UserManagement',
'userSettings',
]);
export function isLightMenuName(name?: string) {
return !name || LIGHT_MENU_NAMES.has(name);
}
export function filterLightMenuTree<
T extends { children?: T[]; name?: string },
>(routes: T[]): T[] {
return routes
.map((route) => {
const children = route.children
? filterLightMenuTree(route.children)
: undefined;
if (!isLightMenuName(route.name) && !children?.length) {
return undefined;
}
return children ? { ...route, children } : route;
})
.filter(Boolean) as T[];
}
@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'About' });
const labels = {
author: $t('about.author'),
basicInfo: $t('about.basicInfo'),
buildTime: $t('about.buildTime'),
devDependencies: $t('about.devDependencies'),
docUrl: $t('about.docUrl'),
github: $t('about.github'),
homepage: $t('about.homepage'),
license: $t('about.license'),
previewUrl: $t('about.previewUrl'),
productionDependencies: $t('about.productionDependencies'),
version: $t('about.version'),
viewDetails: $t('about.viewDetails'),
};
</script>
<template>
<About :labels="labels" />
</template>
@@ -0,0 +1,330 @@
<script lang="ts" setup>
import type { ApiTokenApi } from '#/api/core/api-token';
import { computed, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { Copy, KeyRound, Plus, Trash2 } from '@vben/icons';
import {
ElAlert,
ElButton,
ElDatePicker,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
ElTag,
ElTooltip,
} from 'element-plus';
import {
createApiTokenApi,
getApiTokenListApi,
revokeApiTokenApi,
} from '#/api/core/api-token';
import { ZqDialog } from '#/components/zq-dialog';
defineOptions({ name: 'ApiTokenManagement' });
const tokens = ref<ApiTokenApi.TokenItem[]>([]);
const loading = ref(false);
const createDialogVisible = ref(false);
const createLoading = ref(false);
const createForm = ref<ApiTokenApi.CreateTokenRequest>({
name: '',
expires_at: null,
description: '',
});
const createdToken = ref<null | string>(null);
const showTokenDialog = ref(false);
const tokenCopied = ref(false);
async function loadTokens() {
loading.value = true;
try {
tokens.value = await getApiTokenListApi();
} catch {
ElMessage.error($t('apiToken.loadError'));
} finally {
loading.value = false;
}
}
function openCreateDialog() {
createForm.value = { name: '', expires_at: null, description: '' };
createDialogVisible.value = true;
}
async function handleCreate() {
if (!createForm.value.name.trim()) {
ElMessage.warning($t('apiToken.nameRequired'));
return;
}
createLoading.value = true;
try {
const payload: ApiTokenApi.CreateTokenRequest = {
name: createForm.value.name.trim(),
description: createForm.value.description || undefined,
};
if (createForm.value.expires_at) {
payload.expires_at = new Date(
createForm.value.expires_at,
).toISOString();
}
const result = await createApiTokenApi(payload);
createdToken.value = result.token;
tokenCopied.value = false;
createDialogVisible.value = false;
showTokenDialog.value = true;
await loadTokens();
} catch {
ElMessage.error($t('apiToken.createError'));
} finally {
createLoading.value = false;
}
}
async function handleRevoke(token: ApiTokenApi.TokenItem) {
try {
await ElMessageBox.confirm(
$t('apiToken.revokeConfirm', [token.name]),
$t('apiToken.revokeTitle'),
{ type: 'warning', confirmButtonText: $t('apiToken.confirmRevoke') },
);
await revokeApiTokenApi(token.id);
ElMessage.success($t('apiToken.revokeSuccess'));
await loadTokens();
} catch {
// cancelled
}
}
async function copyToken() {
if (!createdToken.value) return;
try {
await navigator.clipboard.writeText(createdToken.value);
tokenCopied.value = true;
ElMessage.success($t('apiToken.copySuccess'));
} catch {
ElMessage.error($t('apiToken.copyError'));
}
}
function getExpirationStatus(token: ApiTokenApi.TokenItem) {
if (!token.expires_at) {
return { label: $t('apiToken.neverExpires'), type: 'success' as const };
}
const now = new Date();
const expiresAt = new Date(token.expires_at);
if (expiresAt < now) {
return { label: $t('apiToken.expired'), type: 'danger' as const };
}
const daysLeft = Math.ceil(
(expiresAt.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysLeft <= 7) {
return {
label: $t('apiToken.expiresSoon', [daysLeft]),
type: 'warning' as const,
};
}
return { label: token.expires_at, type: 'info' as const };
}
function formatDate(dateStr?: null | string) {
if (!dateStr) return '-';
return dateStr;
}
const expirationShortcuts = computed(() => [
{
text: $t('apiToken.days7'),
value: () => {
const d = new Date();
d.setDate(d.getDate() + 7);
return d;
},
},
{
text: $t('apiToken.days30'),
value: () => {
const d = new Date();
d.setDate(d.getDate() + 30);
return d;
},
},
{
text: $t('apiToken.days60'),
value: () => {
const d = new Date();
d.setDate(d.getDate() + 60);
return d;
},
},
{
text: $t('apiToken.days90'),
value: () => {
const d = new Date();
d.setDate(d.getDate() + 90);
return d;
},
},
{
text: $t('apiToken.days365'),
value: () => {
const d = new Date();
d.setFullYear(d.getFullYear() + 1);
return d;
},
},
]);
onMounted(() => {
loadTokens();
});
</script>
<template>
<div class="api-token-management">
<!-- Header -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-[var(--el-text-color-secondary)]">
{{ $t('apiToken.description') }}
</p>
<ElButton type="primary" @click="openCreateDialog">
<Plus class="mr-1 h-4 w-4" />
{{ $t('apiToken.createToken') }}
</ElButton>
</div>
<!-- Token List -->
<div v-if="tokens.length > 0" class="space-y-3">
<div
v-for="token in tokens"
:key="token.id"
class="flex items-center justify-between rounded-lg border border-[var(--el-border-color)] p-4 transition-colors hover:bg-[var(--el-fill-color-light)]"
>
<div class="flex-1">
<div class="flex items-center gap-2">
<KeyRound class="h-4 w-4 text-[var(--el-color-primary)]" />
<span class="font-medium text-[var(--el-text-color-primary)]">
{{ token.name }}
</span>
<ElTag
:type="getExpirationStatus(token).type"
size="small"
effect="light"
>
{{ getExpirationStatus(token).label }}
</ElTag>
</div>
<div
class="mt-2 flex items-center gap-4 text-xs text-[var(--el-text-color-secondary)]"
>
<span>{{ token.token_prefix }}</span>
<span v-if="token.description">{{ token.description }}</span>
<span>
{{ $t('apiToken.createdAt') }}:
{{ formatDate(token.sys_create_datetime) }}
</span>
<span>
{{ $t('apiToken.lastUsed') }}:
{{ formatDate(token.last_used_at) }}
</span>
</div>
</div>
<ElTooltip :content="$t('apiToken.revokeToken')" placement="top">
<ElButton
type="danger"
text
circle
@click="handleRevoke(token)"
>
<Trash2 class="h-4 w-4" />
</ElButton>
</ElTooltip>
</div>
</div>
<!-- Empty State -->
<ElEmpty v-else-if="!loading" :description="$t('apiToken.empty')" />
<!-- Create Token Dialog -->
<ZqDialog
v-model="createDialogVisible"
:title="$t('apiToken.createToken')"
:confirm-loading="createLoading"
width="480px"
@confirm="handleCreate"
>
<ElForm label-position="top">
<ElFormItem :label="$t('apiToken.tokenName')" required>
<ElInput
v-model="createForm.name"
:placeholder="$t('apiToken.tokenNamePlaceholder')"
maxlength="100"
show-word-limit
/>
</ElFormItem>
<ElFormItem :label="$t('apiToken.expirationDate')">
<ElDatePicker
v-model="createForm.expires_at"
type="datetime"
:placeholder="$t('apiToken.neverExpiresHint')"
:shortcuts="expirationShortcuts"
:disabled-date="(date: Date) => date < new Date()"
class="w-full"
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('apiToken.tokenDescription')">
<ElInput
v-model="createForm.description"
type="textarea"
:rows="2"
:placeholder="$t('apiToken.tokenDescriptionPlaceholder')"
maxlength="500"
show-word-limit
/>
</ElFormItem>
</ElForm>
</ZqDialog>
<!-- Token Display Dialog -->
<ZqDialog
v-model="showTokenDialog"
:title="$t('apiToken.tokenCreated')"
width="560px"
:show-footer="false"
>
<ElAlert
:title="$t('apiToken.tokenWarning')"
type="warning"
show-icon
:closable="false"
class="mb-4"
/>
<div
class="flex items-center gap-2 rounded-lg bg-[var(--el-fill-color)] p-3"
>
<code
class="flex-1 break-all text-sm text-[var(--el-text-color-primary)]"
>
{{ createdToken }}
</code>
<ElButton type="primary" size="small" @click="copyToken">
<Copy class="mr-1 h-3.5 w-3.5" />
{{ tokenCopied ? $t('apiToken.copied') : $t('apiToken.copy') }}
</ElButton>
</div>
</ZqDialog>
</div>
</template>
@@ -0,0 +1,165 @@
import type { VbenFormSchema } from '#/adapter/form';
import { $t } from '@vben/locales';
import { z } from '#/adapter/form';
/**
* 获取性别选项
*/
export function getGenderOptions() {
return [
{ label: $t('user.unknown'), value: 0 },
{ label: $t('user.male'), value: 1 },
{ label: $t('user.female'), value: 2 },
];
}
/**
* 获取基本信息表单配置
*/
export function getProfileFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('user.userName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('user.userName'), 2]))
.max(64, $t('ui.formRules.maxLength', [$t('user.userName'), 64]))
.optional()
.or(z.literal('')),
},
{
component: 'ImageSelector',
componentProps: {
enableCrop: true,
cropShape: 'circle',
maxSize: 2,
placeholder: $t('user.selectAvatar'),
},
fieldName: 'avatar',
label: $t('user.avatar'),
help: $t('user.avatarHelp'),
},
{
component: 'Input',
fieldName: 'email',
label: $t('user.email'),
rules: z
.string()
.email($t('user.emailFormatError'))
.max(255, $t('ui.formRules.maxLength', [$t('user.email'), 255]))
.optional()
.or(z.literal('')),
},
{
component: 'Input',
fieldName: 'mobile',
label: $t('user.mobile'),
rules: z
.string()
.regex(/^1[3-9]\d{9}$/, $t('user.mobileFormatError'))
.optional()
.or(z.literal('')),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: getGenderOptions(),
isButton: true,
},
defaultValue: 0,
fieldName: 'gender',
label: $t('user.gender'),
},
{
component: 'DatePicker',
componentProps: {
placeholder: $t('user.selectBirthday'),
valueFormat: 'YYYY-MM-DD',
},
fieldName: 'birthday',
label: $t('user.birthday'),
},
{
component: 'Input',
fieldName: 'city',
label: $t('user.city'),
rules: z
.string()
.max(100, $t('ui.formRules.maxLength', [$t('user.city'), 100]))
.optional()
.or(z.literal('')),
},
{
component: 'Input',
fieldName: 'address',
label: $t('user.address'),
rules: z
.string()
.max(200, $t('ui.formRules.maxLength', [$t('user.address'), 200]))
.optional()
.or(z.literal('')),
},
{
component: 'Textarea',
componentProps: {
placeholder: $t('user.bioPlaceholder'),
rows: 3,
},
fieldName: 'bio',
label: $t('user.bio'),
},
];
}
/**
* 获取密码修改表单配置
*/
export function getPasswordFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
componentProps: {
type: 'password',
showPassword: true,
placeholder: $t('user.oldPasswordPlaceholder'),
},
fieldName: 'old_password',
label: $t('user.oldPassword'),
rules: z
.string()
.min(1, $t('ui.formRules.required', [$t('user.oldPassword')])),
},
{
component: 'Input',
componentProps: {
type: 'password',
showPassword: true,
placeholder: $t('user.newPasswordPlaceholder'),
},
fieldName: 'new_password',
label: $t('user.newPassword'),
rules: z
.string()
.min(6, $t('ui.formRules.minLength', [$t('user.newPassword'), 6]))
.max(20, $t('ui.formRules.maxLength', [$t('user.newPassword'), 20])),
},
{
component: 'Input',
componentProps: {
type: 'password',
showPassword: true,
placeholder: $t('user.confirmPasswordPlaceholder'),
},
fieldName: 'confirm_password',
label: $t('user.confirmPassword'),
rules: z
.string()
.min(1, $t('ui.formRules.required', [$t('user.confirmPassword')])),
},
];
}
@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { DeviceApi } from '#/api/core/device';
import { computed } from 'vue';
import { Monitor, Smartphone } from '@vben/icons';
import { ElButton, ElTag } from 'element-plus';
interface Props {
device: DeviceApi.DeviceInfo;
isCurrent?: boolean;
}
interface Emits {
(e: 'rename', device: DeviceApi.DeviceInfo): void;
(e: 'logout', device: DeviceApi.DeviceInfo): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// 设备显示名称
const deviceDisplayName = computed(() => {
if (props.device.device_name) {
return props.device.device_name;
}
return `${props.device.browser_type || 'Unknown'} · ${props.device.os_type || 'Unknown'}`;
});
// 格式化时间
function formatTime(timeStr: string) {
try {
const date = new Date(timeStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 30) return `${days}天前`;
return date.toLocaleDateString('zh-CN');
} catch {
return timeStr;
}
}
function handleRename() {
emit('rename', props.device);
}
function handleLogout() {
emit('logout', props.device);
}
</script>
<template>
<div
class="device-item border-b border-gray-100 pb-4 last:border-0 last:pb-0"
>
<div class="flex items-start justify-between">
<div class="flex items-start gap-3">
<!-- 设备图标 -->
<div class="mt-1">
<Smartphone
v-if="device.device_type === 'mobile'"
class="size-6 text-gray-600"
/>
<Monitor
v-else-if="device.device_type === 'tablet'"
class="size-6 text-gray-600"
/>
<Monitor v-else class="size-6 text-gray-600" />
</div>
<!-- 设备信息 -->
<div class="flex-1">
<div class="mb-1 flex items-center gap-2">
<span class="text-base font-semibold">
{{ deviceDisplayName }}
</span>
<ElTag v-if="isCurrent" type="success" size="small">
当前设备
</ElTag>
<ElTag
v-if="device.is_online"
type="success"
size="small"
effect="plain"
>
在线
</ElTag>
</div>
<div class="space-y-1 text-sm text-gray-600">
<div class="flex items-center gap-4">
<span>{{ device.browser_type }} · {{ device.os_type }}</span>
</div>
<div class="flex items-center gap-4">
<span>IP: {{ device.ip_address }}</span>
</div>
<div v-if="device.last_active_time" class="flex items-center gap-4">
<span>最后活跃: {{ formatTime(device.last_active_time) }}</span>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-2">
<ElButton size="small" @click="handleRename"> 重命名 </ElButton>
<ElButton
v-if="!isCurrent"
type="danger"
size="small"
plain
@click="handleLogout"
>
强制登出
</ElButton>
</div>
</div>
</div>
</template>
<style scoped>
.device-item {
padding-top: 1rem;
}
.device-item:first-child {
padding-top: 0;
}
</style>
@@ -0,0 +1,229 @@
<script setup lang="ts">
import type { DeviceApi } from '#/api/core/device';
import { onMounted, reactive, ref } from 'vue';
import { Monitor } from '@vben/icons';
import {
ElButton,
ElCard,
ElDialog,
ElEmpty,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
import {
getDeviceListApi,
logoutDeviceApi,
logoutOtherDevicesApi,
renameDeviceApi,
} from '#/api/core/device';
import DeviceItem from './device-item.vue';
const loading = ref(false);
const deviceList = reactive<DeviceApi.DeviceListResponse>({
current_device: undefined,
online_devices: [],
total_count: 0,
});
const renameDialogVisible = ref(false);
const renameForm = reactive({
device_id: '',
device_name: '',
});
// 加载设备列表
async function loadDevices() {
loading.value = true;
try {
const res = await getDeviceListApi();
Object.assign(deviceList, res);
} catch {
ElMessage.error('加载设备列表失败');
} finally {
loading.value = false;
}
}
// 重命名设备
function handleRename(device: DeviceApi.DeviceInfo) {
renameForm.device_id = device.device_id;
renameForm.device_name = device.device_name || '';
renameDialogVisible.value = true;
}
// 确认重命名
async function handleRenameConfirm() {
if (!renameForm.device_name.trim()) {
ElMessage.warning('请输入设备名称');
return;
}
try {
await renameDeviceApi(renameForm.device_id, {
device_name: renameForm.device_name,
});
ElMessage.success('重命名成功');
renameDialogVisible.value = false;
await loadDevices();
} catch {
ElMessage.error('重命名失败');
}
}
// 登出指定设备
async function handleLogoutDevice(device: DeviceApi.DeviceInfo) {
try {
await ElMessageBox.confirm(
`确定要强制登出该设备吗?该设备将在下次刷新时被踢出。`,
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
);
await logoutDeviceApi(device.device_id);
ElMessage.success('已强制登出该设备');
await loadDevices();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败');
}
}
}
// 登出所有其他设备
async function handleLogoutAllDevices() {
try {
await ElMessageBox.confirm(
`确定要登出所有其他设备吗?这将强制所有其他设备下次刷新时重新登录。`,
'确认操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
},
);
await logoutOtherDevicesApi();
ElMessage.success('已登出所有其他设备');
await loadDevices();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败');
}
}
}
onMounted(() => {
loadDevices();
});
</script>
<template>
<div class="device-management">
<!-- 统计信息 -->
<ElCard class="mb-4">
<template #header>
<div class="flex items-center justify-between">
<span class="text-lg font-semibold">设备管理</span>
<ElButton
v-if="deviceList.online_devices.length > 0"
type="danger"
plain
size="small"
@click="handleLogoutAllDevices"
>
登出所有其他设备
</ElButton>
</div>
</template>
<div class="flex gap-8 text-sm">
<div>
<span class="text-gray-500">当前在线:</span>
<span class="text-primary ml-2 text-xl font-bold">
{{ deviceList.total_count }}
</span>
<span class="ml-1 text-gray-500">台设备</span>
</div>
</div>
</ElCard>
<!-- 当前设备 -->
<ElCard v-if="deviceList.current_device" class="mb-4">
<template #header>
<div class="flex items-center">
<Monitor class="mr-2 size-5 text-green-500" />
<span class="font-semibold">当前设备 (你正在使用)</span>
</div>
</template>
<DeviceItem
:device="deviceList.current_device"
:is-current="true"
@rename="handleRename"
/>
</ElCard>
<!-- 其他在线设备 -->
<ElCard v-if="deviceList.online_devices.length > 0">
<template #header>
<div class="flex items-center">
<Monitor class="mr-2 size-5 text-blue-500" />
<span class="font-semibold">其他在线设备</span>
</div>
</template>
<div class="space-y-4">
<DeviceItem
v-for="device in deviceList.online_devices"
:key="device.device_id"
:device="device"
@rename="handleRename"
@logout="handleLogoutDevice"
/>
</div>
</ElCard>
<!-- 无其他设备提示 -->
<ElEmpty
v-if="!loading && deviceList.online_devices.length === 0"
description="暂无其他设备登录"
/>
<!-- 重命名对话框 -->
<ElDialog v-model="renameDialogVisible" title="重命名设备" width="400px">
<ElForm :model="renameForm" label-width="80px">
<ElFormItem label="设备名称">
<ElInput
v-model="renameForm.device_name"
placeholder="请输入设备名称,如:办公室电脑"
maxlength="50"
show-word-limit
/>
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="renameDialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleRenameConfirm"> 确定 </ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.device-management {
max-width: 800px;
}
</style>
@@ -0,0 +1,184 @@
<script lang="ts" setup>
import type { User } from '#/api/core';
import type { CardListItem, CardListOptions } from '#/components/card-list';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElCard, ElMessage } from 'element-plus';
import { getCurrentUserProfileApi } from '#/api/core';
import { CardList } from '#/components/card-list';
import ApiTokenManagement from './api-token-management.vue';
import DeviceManagement from './device-management.vue';
import PasswordForm from './modules/password-form.vue';
import ProfileForm from './modules/profile-form.vue';
defineOptions({ name: 'AccountSettings' });
// 菜单项类型
interface SettingMenuItem extends CardListItem {
id: string;
name: string;
key: 'apiToken' | 'device' | 'password' | 'profile';
}
// 菜单项数据
const menuItems = ref<SettingMenuItem[]>([
{
id: 'profile',
name: $t('user.basicInfo'),
key: 'profile',
},
{
id: 'password',
name: $t('user.changePassword'),
key: 'password',
},
{
id: 'device',
name: $t('apiToken.deviceManagement'),
key: 'device',
},
{
id: 'apiToken',
name: $t('apiToken.title'),
key: 'apiToken',
},
]);
const loading = ref(false);
const selectedMenuId = ref<string>('profile');
const userProfile = ref<null | User>(null);
// CardList 配置
const cardListOptions: CardListOptions<SettingMenuItem> = {
searchFields: [{ field: 'name' }],
titleField: 'name',
displayMode: 'center', // 一行居中显示
};
/**
* 处理菜单选择
*/
function handleMenuSelect(id: string | undefined) {
selectedMenuId.value = id || 'profile';
}
/**
* 加载当前用户信息
*/
async function loadUserProfile() {
loading.value = true;
try {
const data = await getCurrentUserProfileApi();
userProfile.value = data;
} catch {
ElMessage.error($t('user.loadProfileError'));
} finally {
loading.value = false;
}
}
/**
* 处理表单成功回调
*/
async function handleFormSuccess() {
await loadUserProfile();
}
onMounted(() => {
loadUserProfile();
});
</script>
<template>
<Page auto-content-height>
<div class="flex h-full">
<!-- 左侧菜单 -->
<div class="w-1/6">
<CardList
:items="menuItems"
:selected-id="selectedMenuId"
:options="cardListOptions"
:loading="false"
class="account-settings-menu"
@select="handleMenuSelect"
>
<template #item="{ item }">
<div class="text-sm font-medium">{{ item.name }}</div>
</template>
</CardList>
</div>
<!-- 右侧表单 -->
<div class="flex-1">
<ElCard shadow="never" class="h-full">
<template #header>
<div class="card-header">
<span>
{{
selectedMenuId === 'profile'
? $t('user.basicInfo')
: selectedMenuId === 'password'
? $t('user.changePassword')
: selectedMenuId === 'device'
? $t('apiToken.deviceManagement')
: $t('apiToken.title')
}}
</span>
</div>
</template>
<!-- 基本信息表单 -->
<template v-if="selectedMenuId === 'profile'">
<ProfileForm
:user-profile="userProfile"
@success="handleFormSuccess"
/>
</template>
<!-- 修改密码表单 -->
<template v-else-if="selectedMenuId === 'password'">
<PasswordForm @success="handleFormSuccess" />
</template>
<!-- 设备管理 -->
<template v-else-if="selectedMenuId === 'device'">
<DeviceManagement />
</template>
<!-- API Token 管理 -->
<template v-else-if="selectedMenuId === 'apiToken'">
<ApiTokenManagement />
</template>
</ElCard>
</div>
</div>
</Page>
</template>
<style scoped>
.account-settings-menu :deep(.el-card__body) {
padding: 16px;
}
/* 隐藏搜索和添加按钮 */
.account-settings-menu :deep(.mb-4.flex) {
display: none;
}
.account-settings-menu :deep(.el-form-item__label) {
font-weight: 500;
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,168 @@
<script lang="ts" setup>
import type { UserChangePasswordInput } from '#/api/core';
import { reactive, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
import { changePasswordApi } from '#/api/core';
defineOptions({ name: 'PasswordForm' });
const emit = defineEmits<{
success: [];
}>();
const loading = ref(false);
// 密码修改表单数据
const passwordForm = reactive<UserChangePasswordInput>({
old_password: '',
new_password: '',
confirm_password: '',
});
// 表单引用
const passwordFormRef = ref<InstanceType<typeof ElForm>>();
// 表单验证规则
const passwordRules = {
old_password: [
{
required: true,
message: $t('ui.formRules.required', [$t('user.oldPassword')]),
trigger: 'blur',
},
],
new_password: [
{
required: true,
message: $t('ui.formRules.required', [$t('user.newPassword')]),
trigger: 'blur',
},
{
min: 6,
max: 20,
message: `${$t('ui.formRules.minLength', [
$t('user.newPassword'),
6,
])}${$t('ui.formRules.maxLength', [$t('user.newPassword'), 20])}`,
trigger: 'blur',
},
],
confirm_password: [
{
required: true,
message: $t('ui.formRules.required', [$t('user.confirmPassword')]),
trigger: 'blur',
},
{
validator: (_rule: any, value: string, callback: Function) => {
if (value === passwordForm.new_password) {
callback();
} else {
callback(new Error($t('user.passwordNotMatch')));
}
},
trigger: 'blur',
},
],
};
/**
* 修改密码
*/
async function handleChangePassword() {
if (!passwordFormRef.value) return;
await passwordFormRef.value.validate(async (valid) => {
if (!valid) return;
// 验证新密码和确认密码是否一致
if (passwordForm.new_password !== passwordForm.confirm_password) {
ElMessage.error($t('user.passwordNotMatch'));
return;
}
loading.value = true;
try {
await changePasswordApi({ ...passwordForm });
ElMessage.success($t('user.changePasswordSuccess'));
// 清空表单
Object.assign(passwordForm, {
old_password: '',
new_password: '',
confirm_password: '',
});
passwordFormRef.value?.resetFields();
emit('success');
} catch (error: any) {
ElMessage.error(error?.message || $t('user.changePasswordError'));
} finally {
loading.value = false;
}
});
}
/**
* 重置表单
*/
function handleReset() {
passwordFormRef.value?.resetFields();
}
</script>
<template>
<ElForm
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="120px"
label-position="right"
>
<ElFormItem :label="$t('user.oldPassword')" prop="old_password">
<ElInput
v-model="passwordForm.old_password"
type="password"
:placeholder="$t('user.oldPasswordPlaceholder')"
show-password
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('user.newPassword')" prop="new_password">
<ElInput
v-model="passwordForm.new_password"
type="password"
:placeholder="$t('user.newPasswordPlaceholder')"
show-password
clearable
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ $t('account-settings.passwordLengthHint') }}
</p>
</ElFormItem>
<ElFormItem :label="$t('user.confirmPassword')" prop="confirm_password">
<ElInput
v-model="passwordForm.confirm_password"
type="password"
:placeholder="$t('user.confirmPasswordPlaceholder')"
show-password
clearable
/>
</ElFormItem>
<!-- 保存按钮 -->
<ElFormItem>
<ElButton type="primary" :loading="loading" @click="handleChangePassword">
{{ $t('common.save') }}
</ElButton>
<ElButton @click="handleReset">
{{ $t('common.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,293 @@
<script lang="ts" setup>
import type { User, UserProfileUpdateInput } from '#/api/core';
import { reactive, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElDatePicker,
ElDivider,
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElRadio,
ElRadioGroup,
} from 'element-plus';
import { patchUserProfileApi } from '#/api/core';
import { ImageSelector } from '#/components/zq-form/image-selector';
defineOptions({ name: 'ProfileForm' });
const props = withDefaults(defineProps<Props>(), {
userProfile: null,
});
const emit = defineEmits<{
success: [];
}>();
interface Props {
userProfile?: null | User;
}
const loading = ref(false);
// 基本信息表单数据
const profileForm = reactive<UserProfileUpdateInput>({
name: '',
email: '',
mobile: '',
avatar: '',
gender: 0,
birthday: '',
city: '',
address: '',
bio: '',
});
// 表单引用
const profileFormRef = ref<InstanceType<typeof ElForm>>();
// 性别选项
const genderOptions = [
{ label: $t('user.unknown'), value: 0 },
{ label: $t('user.male'), value: 1 },
{ label: $t('user.female'), value: 2 },
];
// 表单验证规则
const profileRules = {
name: [
{
min: 2,
max: 64,
message: `${$t('ui.formRules.minLength', [$t('user.userName'), 2])}${$t(
'ui.formRules.maxLength',
[$t('user.userName'), 64],
)}`,
trigger: 'blur',
},
],
email: [
{
type: 'email' as const,
message: $t('user.emailFormatError'),
trigger: 'blur',
},
],
mobile: [
{
pattern: /^1[3-9]\d{9}$/,
message: $t('user.mobileFormatError'),
trigger: 'blur',
},
],
};
/**
* 加载用户信息
*/
function loadUserProfile() {
if (props.userProfile) {
Object.assign(profileForm, {
name: props.userProfile.name || '',
email: props.userProfile.email || '',
mobile: props.userProfile.mobile || '',
avatar: props.userProfile.avatar || '',
gender: props.userProfile.gender ?? 0,
birthday: props.userProfile.birthday || '',
city: props.userProfile.city || '',
address: props.userProfile.address || '',
bio: props.userProfile.bio || '',
});
}
}
/**
* 保存基本信息
*/
async function handleSaveProfile() {
if (!profileFormRef.value) return;
await profileFormRef.value.validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
await patchUserProfileApi({ ...profileForm });
ElMessage.success($t('user.updateProfileSuccess'));
// 通知父组件重新加载用户信息
emit('success');
} catch (error: any) {
ElMessage.error(error?.message || $t('user.updateProfileError'));
} finally {
loading.value = false;
}
});
}
/**
* 头像上传成功回调
*/
function handleAvatarChange(value: string | string[] | undefined) {
if (!value) {
profileForm.avatar = '';
return;
}
profileForm.avatar = Array.isArray(value) ? value[0] || '' : value || '';
}
/**
* 重置表单
*/
function handleReset() {
loadUserProfile();
}
// 监听 userProfile 变化,自动更新表单
watch(
() => props.userProfile,
(newVal) => {
if (newVal) {
Object.assign(profileForm, {
name: newVal.name || '',
email: newVal.email || '',
mobile: newVal.mobile || '',
avatar: newVal.avatar || '',
gender: newVal.gender ?? 0,
birthday: newVal.birthday || '',
city: newVal.city || '',
address: newVal.address || '',
bio: newVal.bio || '',
});
}
},
{ immediate: true },
);
// 初始化加载用户信息
loadUserProfile();
</script>
<template>
<ElForm
ref="profileFormRef"
:model="profileForm"
:rules="profileRules"
label-width="120px"
label-position="right"
>
<!-- 头像区域 -->
<ElFormItem :label="$t('user.avatar')">
<div class="flex items-center gap-6">
<div class="flex-1">
<ImageSelector
v-model="profileForm.avatar"
:enable-crop="true"
crop-shape="circle"
:max-size="2"
:size="100"
:placeholder="$t('user.selectAvatar')"
@update:model-value="handleAvatarChange"
/>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ $t('user.avatarHelp') }}
</p>
</div>
</div>
</ElFormItem>
<ElDivider />
<!-- 基本信息 -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
<ElFormItem :label="$t('user.userName')" prop="name">
<ElInput
v-model="profileForm.name"
:placeholder="$t('user.userName')"
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('user.email')" prop="email">
<ElInput
v-model="profileForm.email"
type="email"
:placeholder="$t('user.email')"
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('user.mobile')" prop="mobile">
<ElInput
v-model="profileForm.mobile"
:placeholder="$t('user.mobile')"
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('user.gender')">
<ElRadioGroup v-model="profileForm.gender">
<ElRadio
v-for="option in genderOptions"
:key="option.value"
:label="option.value"
>
{{ option.label }}
</ElRadio>
</ElRadioGroup>
</ElFormItem>
<ElFormItem :label="$t('user.birthday')">
<ElDatePicker
v-model="profileForm.birthday"
type="date"
:placeholder="$t('user.selectBirthday')"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</ElFormItem>
<ElFormItem :label="$t('user.city')">
<ElInput
v-model="profileForm.city"
:placeholder="$t('user.city')"
clearable
/>
</ElFormItem>
</div>
<ElFormItem :label="$t('user.address')">
<ElInput
v-model="profileForm.address"
:placeholder="$t('user.address')"
clearable
/>
</ElFormItem>
<ElFormItem :label="$t('user.bio')">
<ElInput
v-model="profileForm.bio"
type="textarea"
:rows="4"
:placeholder="$t('user.bioPlaceholder')"
:maxlength="500"
show-word-limit
/>
</ElFormItem>
<!-- 保存按钮 -->
<ElFormItem>
<ElButton type="primary" :loading="loading" @click="handleSaveProfile">
{{ $t('common.save') }}
</ElButton>
<ElButton @click="handleReset">
{{ $t('common.reset') }}
</ElButton>
</ElFormItem>
</ElForm>
</template>
@@ -0,0 +1,74 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn } from '#/adapter/vxe-table';
import type { DeptUser } from '#/api/core/dept';
import { $t } from '@vben/locales';
/**
* 获取搜索表单的字段配置
*/
export function useSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('system.user.userName'),
},
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.account'),
},
];
}
/**
* 获取用户表格列配置
*/
export function useUserColumns(
onActionClick?: OnActionClickFn<DeptUser>,
): VxeTableGridOptions<DeptUser>['columns'] {
return [
{
type: 'checkbox',
minWidth: 60,
align: 'center',
fixed: 'left',
},
{
field: 'username',
title: $t('system.user.account'),
minWidth: 120,
},
{
field: 'name',
title: $t('system.user.userName'),
minWidth: 120,
},
{
field: 'email',
title: $t('system.user.email'),
minWidth: 180,
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: $t('system.user.userName'),
onClick: onActionClick,
},
name: 'CellOperation',
options: ['edit', 'delete'],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.user.operation'),
minWidth: 150,
},
];
}
@@ -0,0 +1,159 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
import { addDeptUsersApi, removeDeptUsersApi } from '#/api/core/dept';
import { UserListPanel } from '#/components/user-list-panel';
import { UserSelector } from '#/components/zq-form/user-selector';
import DeptTree from './modules/dept-tree.vue';
defineOptions({ name: 'SystemDept' });
const currentDeptId = ref<string>();
const tempSelectedUsers = ref<Set<string>>(new Set());
const userListPanelRef = ref<InstanceType<typeof UserListPanel>>();
/**
* 部门选择事件
*/
function onDeptSelect(deptIds: string[] | undefined) {
currentDeptId.value = deptIds?.[0];
tempSelectedUsers.value.clear();
}
/**
* 处理用户选择
*/
function handleUserSelect(userId: string, _user: any) {
if (tempSelectedUsers.value.has(userId)) {
tempSelectedUsers.value.delete(userId);
} else {
tempSelectedUsers.value.add(userId);
}
}
/**
* 处理移除用户
*/
function handleRemoveUser(userId: string) {
tempSelectedUsers.value.delete(userId);
}
/**
* 新增用户到部门(作为 UserSelector 的 onConfirm 回调)
*/
async function handleAddUsers(userIds: string | string[]) {
if (!currentDeptId.value) {
ElMessage.warning($t('dept.selectDeptFirst') || '请先选择部门');
throw new Error('请先选择部门');
}
const userIdsArray = Array.isArray(userIds) ? userIds : [userIds];
if (userIdsArray.length === 0) {
ElMessage.warning($t('dept.selectUsersFirst') || '请先选择用户');
throw new Error('请先选择用户');
}
await addDeptUsersApi(currentDeptId.value, {
user_ids: userIdsArray,
});
ElMessage.success($t('dept.addUsersSuccess') || '添加成功');
// 刷新用户列表
userListPanelRef.value?.reload();
}
/**
* 从部门删除用户
*/
async function handleRemoveUsers() {
if (!currentDeptId.value) {
ElMessage.warning($t('dept.selectDeptFirst') || '请先选择部门');
return;
}
if (tempSelectedUsers.value.size === 0) {
ElMessage.warning($t('dept.selectUsersFirst') || '请先选择用户');
return;
}
const userIds = [...tempSelectedUsers.value];
const confirmMessage =
$t('dept.removeUsersConfirm', [tempSelectedUsers.value.size]) ||
`确定要删除选中的 ${tempSelectedUsers.value.size} 个用户吗?`;
try {
await ElMessageBox.confirm(confirmMessage, $t('common.delete') || '删除', {
confirmButtonText: $t('common.confirm') || '确定',
cancelButtonText: $t('common.cancel') || '取消',
type: 'warning',
});
await removeDeptUsersApi(currentDeptId.value, {
user_ids: userIds,
});
ElMessage.success($t('dept.removeUsersSuccess') || '删除成功');
tempSelectedUsers.value.clear();
// 刷新用户列表
userListPanelRef.value?.reload();
} catch (error) {
if (error !== 'cancel') {
console.error('Failed to remove users:', error);
ElMessage.error($t('dept.removeUsersFailed') || '删除失败');
}
}
}
</script>
<template>
<Page auto-content-height>
<div class="flex h-full">
<!-- 部门树 -->
<div class="w-1/6">
<DeptTree @select="onDeptSelect" />
</div>
<!-- 主内容区用户列表 -->
<div class="w-5/6">
<UserListPanel
ref="userListPanelRef"
:data-source="currentDeptId ? 'dept' : 'all'"
:source-id="currentDeptId"
:temp-selected-users="tempSelectedUsers"
:filterable="true"
:multiple="true"
:selectable="true"
:show-selected-tags="false"
:show-border="false"
@user-select="handleUserSelect"
@remove-user="handleRemoveUser"
>
<template #title>
<div class="flex items-center gap-2">
<UserSelector
:multiple="true"
:disabled="!currentDeptId"
display-mode="button"
:placeholder="$t('common.add') || '新增'"
:on-confirm="handleAddUsers"
/>
<ElButton
type="danger"
:disabled="!currentDeptId || tempSelectedUsers.size === 0"
@click="handleRemoveUsers"
>
{{ $t('common.delete') || '删除' }}
</ElButton>
</div>
</template>
</UserListPanel>
</div>
</div>
</Page>
</template>
@@ -0,0 +1,209 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { Dept } from '#/api/core/dept';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import { useVbenForm, z } from '#/adapter/form';
import { createDeptApi, updateDeptApi } from '#/api/core/dept';
import { ZqDialog } from '#/components/zq-dialog';
const emit = defineEmits(['success']);
const formData = ref<Dept>();
const visible = ref(false);
const confirmLoading = ref(false);
/**
* 获取部门类型选项
*/
function getDeptTypeOptions() {
return [
{ label: $t('dept.deptTypeOptions.company'), value: 'company' },
{ label: $t('dept.deptTypeOptions.department'), value: 'department' },
{ label: $t('dept.deptTypeOptions.team'), value: 'team' },
{ label: $t('dept.deptTypeOptions.other'), value: 'other' },
];
}
function getFormSchema(): VbenFormSchema[] {
return [
{
component: 'DeptSelector',
componentProps: {
allowClear: true,
class: 'w-full',
labelField: 'name',
valueField: 'id',
},
fieldName: 'parent_id',
label: $t('dept.parentDept'),
},
{
component: 'Input',
fieldName: 'name',
label: $t('dept.deptName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('dept.deptName'), 2]))
.max(64, $t('ui.formRules.maxLength', [$t('dept.deptName'), 64])),
},
{
component: 'Input',
fieldName: 'code',
label: $t('dept.deptCode'),
help: $t('dept.deptCodeHelp'),
rules: z
.string()
.max(32, $t('ui.formRules.maxLength', [$t('dept.deptCode'), 32]))
.regex(/^[\w-]*$/, $t('dept.deptCodeFormatError'))
.optional(),
},
{
component: 'Select',
componentProps: {
options: getDeptTypeOptions(),
},
defaultValue: 'department',
fieldName: 'dept_type',
label: $t('dept.deptType'),
},
{
component: 'UserSelector',
componentProps: {
clearable: true,
multiple: false,
},
fieldName: 'lead_id',
label: $t('dept.lead'),
},
{
component: 'Input',
fieldName: 'phone',
label: $t('dept.phone'),
help: $t('dept.phoneHelp'),
rules: z
.string()
.max(20, $t('ui.formRules.maxLength', [$t('dept.phone'), 20]))
.regex(/^[\d\-+()\s]*$/, $t('dept.phoneFormatError'))
.optional(),
},
{
component: 'Input',
fieldName: 'email',
label: $t('dept.email'),
help: $t('dept.emailHelp'),
rules: z.string().email($t('dept.emailFormatError')).optional(),
},
{
component: 'RadioGroup',
componentProps: {
buttonStyle: 'solid',
options: [
{ label: $t('common.enabled'), value: true },
{ label: $t('common.disabled'), value: false },
],
isButton: true,
},
defaultValue: true,
fieldName: 'status',
label: $t('dept.status'),
},
{
component: 'InputNumber',
componentProps: {
min: 0,
max: 9999,
class: 'w-full',
},
defaultValue: 1,
fieldName: 'sort',
label: $t('dept.sort'),
},
{
component: 'Textarea',
componentProps: {
maxLength: 200,
rows: 3,
showCount: true,
placeholder: $t('dept.descriptionPlaceholder'),
},
fieldName: 'description',
label: $t('dept.description'),
help: $t('dept.descriptionHelp'),
rules: z
.string()
.max(200, $t('ui.formRules.maxLength', [$t('dept.description'), 200]))
.optional(),
},
];
}
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: getFormSchema(),
showDefaultActions: false,
});
function resetForm() {
formApi.resetForm();
formApi.setValues(formData.value || {});
}
const getTitle = computed(() => {
return formData.value?.id
? $t('common.ui.actionTitle.edit', [$t('dept.name')])
: $t('common.ui.actionTitle.add', [$t('dept.name')]);
});
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
confirmLoading.value = true;
const data = await formApi.getValues();
try {
await (formData.value?.id
? updateDeptApi(formData.value.id, data as any)
: createDeptApi(data as any));
visible.value = false;
emit('success', data);
} finally {
confirmLoading.value = false;
}
}
}
function open(data?: Dept) {
visible.value = true;
if (data) {
formData.value = data;
formApi.setValues(formData.value);
} else {
formData.value = undefined;
formApi.resetForm();
}
}
defineExpose({
open,
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="getTitle"
:confirm-loading="confirmLoading"
@confirm="onSubmit"
>
<Form class="mx-4" />
<template #footer-left>
<ElButton type="primary" @click="resetForm">
{{ $t('common.reset') }}
</ElButton>
</template>
</ZqDialog>
</template>
@@ -0,0 +1,501 @@
<script lang="ts" setup>
import type { DeptTreeNode } from '#/api/core/dept';
import { computed, onMounted, ref, watch } from 'vue';
import { IconifyIcon, Loader, Plus, Search } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElInput,
ElMessage,
ElMessageBox,
ElSkeleton,
ElSkeletonItem,
ElTooltip,
} from 'element-plus';
import {
deleteDeptApi,
getDeptByParentApi,
getDeptDetailApi,
searchDeptApi,
} from '#/api/core/dept';
import DeptFormModal from './dept-form-modal.vue';
const emit = defineEmits<{
select: [deptIds: string[] | undefined, hasChildren?: boolean];
}>();
const treeData = ref<DeptTreeNode[]>([]);
const loading = ref(false);
const selectedDeptId = ref<string>();
const searchKeyword = ref<string>('');
const hoveredDeptId = ref<string>();
const expandedDeptIds = ref<Set<string>>(new Set());
const loadingDeptIds = ref<Set<string>>(new Set());
const searchResults = ref<DeptTreeNode[]>([]);
const isSearching = ref(false);
const currentOperatingDeptId = ref<null | string>(null);
const currentOperatingParentId = ref<null | string>(null);
const deptFormModalRef = ref<InstanceType<typeof DeptFormModal>>();
/**
* 加载顶级部门数据(只加载第一级)
*/
async function fetchDeptList() {
try {
loading.value = true;
// 只获取顶级部门
const data = await getDeptByParentApi();
treeData.value = Array.isArray(data) ? data : [];
// 自动选中第一个部门
if (treeData.value.length > 0 && !selectedDeptId.value) {
const firstDept = treeData.value.at(0);
if (firstDept) {
selectedDeptId.value = firstDept.id;
emit('select', [firstDept.id], hasChildren(firstDept));
}
}
} finally {
loading.value = false;
}
}
/**
* 加载子部门(懒加载)
*/
async function loadChildren(parentId: string) {
try {
loadingDeptIds.value.add(parentId);
const data = await getDeptByParentApi(parentId);
// 更新树数据中的子部门
function updateNodeChildren(nodes: DeptTreeNode[], targetId: string) {
for (const node of nodes) {
if (node.id === targetId) {
node.children = Array.isArray(data) ? data : [];
return true;
}
if (
node.children &&
node.children.length > 0 &&
updateNodeChildren(node.children, targetId)
) {
return true;
}
}
return false;
}
updateNodeChildren(treeData.value, parentId);
} catch {
ElMessage.error($t('ui.actionMessage.loadError'));
} finally {
loadingDeptIds.value.delete(parentId);
}
}
/**
* 切换节点展开/折叠
*/
async function toggleNodeExpanded(dept: DeptTreeNode) {
if (expandedDeptIds.value.has(dept.id)) {
expandedDeptIds.value.delete(dept.id);
} else {
expandedDeptIds.value.add(dept.id);
// 如果子部门未加载,则加载
if (!dept.children || dept.children.length === 0) {
await loadChildren(dept.id);
}
}
}
/**
* 选择部门
*/
function onDeptSelect(dept: DeptTreeNode) {
selectedDeptId.value = dept.id;
emit('select', [dept.id], hasChildren(dept));
}
/**
* 添加部门
*/
function onAddDept() {
currentOperatingDeptId.value = null;
currentOperatingParentId.value = null;
deptFormModalRef.value?.open();
}
/**
* 编辑部门
*/
function onEditDept(dept: DeptTreeNode) {
currentOperatingDeptId.value = dept.id;
deptFormModalRef.value?.open(dept);
}
/**
* 在当前部门下新建子部门
*/
function onAddChildDept(dept: DeptTreeNode) {
currentOperatingDeptId.value = null;
currentOperatingParentId.value = dept.id;
deptFormModalRef.value?.open({ parent_id: dept.id } as any);
}
/**
* 删除部门
*/
async function onDeleteDept(dept: DeptTreeNode) {
ElMessageBox.confirm(
$t('ui.actionMessage.deleteConfirm', [dept.name]),
$t('common.delete'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
showClose: false,
},
)
.then(async () => {
try {
await deleteDeptApi(dept.id);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [dept.name]));
fetchDeptList();
} catch {
ElMessage.error($t('ui.actionMessage.deleteError'));
}
})
.catch(() => {
// 用户取消了操作
});
}
/**
* 在树中查找并更新节点数据
*/
function updateNodeInTree(
nodes: DeptTreeNode[],
deptId: string,
updatedData: Partial<DeptTreeNode>,
): boolean {
for (const node of nodes) {
if (node.id === deptId) {
// 找到节点,更新数据(保留 children)
Object.assign(node, { ...updatedData, children: node.children });
return true;
}
if (
node.children &&
node.children.length > 0 &&
updateNodeInTree(node.children, deptId, updatedData)
) {
return true;
}
}
return false;
}
/**
* 表单成功回调
*/
async function onDeptFormSuccess(formData?: any) {
const isUpdate = currentOperatingDeptId.value !== null;
try {
if (isUpdate) {
// 修改操作:只刷新当前节点数据
const updatedDept = await getDeptDetailApi(currentOperatingDeptId.value!);
updateNodeInTree(
treeData.value,
currentOperatingDeptId.value!,
updatedDept,
);
ElMessage.success($t('dept.updateSuccess'));
} else {
// 新增操作:刷新父节点的子列表
// 优先使用表单提交的parent_id,其次使用记录的parent_id
const parentId = formData?.parent_id || currentOperatingParentId.value;
if (parentId) {
// 如果父节点已展开,重新加载其子部门
if (expandedDeptIds.value.has(parentId)) {
await loadChildren(parentId);
}
} else {
// 顶级部门,重新加载顶级列表
await fetchDeptList();
}
}
} catch (error) {
console.error('刷新部门数据失败:', error);
// 失败时重新加载整个树
await fetchDeptList();
} finally {
// 重置操作标记
currentOperatingDeptId.value = null;
currentOperatingParentId.value = null;
}
}
/**
* 检查部门是否有子部门
*/
function hasChildren(dept: DeptTreeNode): boolean {
// 首先检查 child_count 字段(后端返回的子部门数量)
if ('child_count' in dept && typeof dept.child_count === 'number') {
return dept.child_count > 0;
}
// 如果没有 child_count 字段,则检查 children 属性
if (!dept.children) {
return false; // 没有信息表示没有子部门
}
return dept.children.length > 0;
}
/**
* 自动展开搜索结果中的所有节点
*/
function autoExpandSearchResults(nodes: DeptTreeNode[]) {
nodes.forEach((node) => {
expandedDeptIds.value.add(node.id);
if (node.children && node.children.length > 0) {
autoExpandSearchResults(node.children);
}
});
}
/**
* 防抖搜索定时器
*/
let searchTimer: null | ReturnType<typeof setTimeout> = null;
/**
* 监听搜索文本变化,执行后端搜索
*/
watch(searchKeyword, (newVal) => {
// 清除之前的定时器
if (searchTimer) {
clearTimeout(searchTimer);
}
if (!newVal.trim()) {
searchResults.value = [];
isSearching.value = false;
return;
}
// 设置新的防抖定时器
searchTimer = setTimeout(async () => {
isSearching.value = true;
try {
const results = await searchDeptApi(newVal);
searchResults.value = results || [];
// 自动展开搜索结果中的所有节点,显示完整路径
if (results && results.length > 0) {
autoExpandSearchResults(results);
}
} catch (error) {
console.error($t('dept.searchFailed'), error);
searchResults.value = [];
} finally {
isSearching.value = false;
}
}, 300);
});
/**
* 过滤树数据:如果有搜索结果则使用搜索结果,否则使用完整树
*/
const filteredTreeData = computed(() => {
if (searchKeyword.value.trim() && searchResults.value.length > 0) {
return searchResults.value;
}
return treeData.value;
});
/**
* 渲染树形列表
*/
const renderTreeList = (nodes: DeptTreeNode[], level: number = 0): any[] => {
return nodes.flatMap((node) => [
{ node, level, isNode: true },
...(expandedDeptIds.value.has(node.id) &&
node.children &&
node.children.length > 0
? renderTreeList(node.children, level + 1)
: []),
]);
};
const flattenedTree = computed(() => renderTreeList(filteredTreeData.value));
onMounted(() => {
fetchDeptList();
});
</script>
<template>
<ElCard
style="border: none"
class="mr-[10px] flex h-full flex-col"
shadow="never"
>
<DeptFormModal ref="deptFormModalRef" @success="onDeptFormSuccess" />
<!-- 搜索和添加区域 -->
<div class="mb-4 flex gap-2">
<ElInput
v-model="searchKeyword"
:placeholder="$t('common.search')"
clearable
:prefix-icon="Search"
/>
<ElButton :icon="Plus" @click="onAddDept" />
</div>
<!-- 部门树列表 -->
<div class="flex-1 overflow-auto">
<ElSkeleton :loading="loading || isSearching" animated :count="8">
<template #template>
<div class="space-y-1">
<div v-for="i in 8" :key="i" class="dept-skeleton-item">
<ElSkeletonItem
variant="text"
style="width: 100%; height: 40px"
/>
</div>
</div>
</template>
<template #default>
<div class="space-y-2">
<div
v-for="(item, index) in flattenedTree"
:key="`${item.node.id}-${index}`"
class="dept-item flex cursor-pointer items-center rounded-[8px] px-3 py-2 transition-colors"
:class="[
selectedDeptId === item.node.id
? 'bg-primary/15 dark:bg-accent text-primary'
: 'hover:bg-[var(--el-fill-color-light)]',
]"
:style="{ paddingLeft: `calc(12px + ${item.level * 20}px)` }"
@mouseenter="hoveredDeptId = item.node.id"
@mouseleave="hoveredDeptId = undefined"
@click="onDeptSelect(item.node)"
>
<div class="flex min-w-0 flex-1 items-center gap-1">
<!-- 展开/折叠按钮 -->
<div
v-if="hasChildren(item.node)"
class="hover:text-primary flex w-5 flex-shrink-0 cursor-pointer items-center justify-center"
@click.stop="toggleNodeExpanded(item.node)"
>
<Loader
v-if="loadingDeptIds.has(item.node.id)"
class="size-4 animate-spin"
/>
<IconifyIcon
v-else
icon="ep:caret-right"
class="size-4 transform transition-transform"
:class="
expandedDeptIds.has(item.node.id) ? 'rotate-90' : ''
"
/>
</div>
<div v-else class="w-5 flex-shrink-0"></div>
<!-- 部门名称 -->
<div class="truncate text-sm" :title="item.node.name">
{{ item.node.name }}
</div>
</div>
<!-- 操作图标 -->
<div
v-if="hoveredDeptId === item.node.id"
class="ml-2 flex flex-shrink-0 gap-0.5"
@click.stop
>
<ElTooltip :content="$t('dept.addChildDept')" placement="top">
<ElButton
type="primary"
text
size="small"
circle
@click="onAddChildDept(item.node)"
>
<IconifyIcon icon="ep:plus" class="size-4" />
</ElButton>
</ElTooltip>
<ElTooltip :content="$t('dept.edit')" placement="top">
<ElButton
type="primary"
text
size="small"
circle
style="margin-left: 0"
@click="onEditDept(item.node)"
>
<IconifyIcon icon="ep:edit" class="size-4" />
</ElButton>
</ElTooltip>
<ElButton
type="danger"
text
size="small"
circle
style="margin-left: 0"
:title="$t('common.delete')"
@click="onDeleteDept(item.node)"
>
<IconifyIcon icon="ep:delete" class="size-4" />
</ElButton>
</div>
</div>
</div>
</template>
</ElSkeleton>
</div>
</ElCard>
</template>
<style scoped>
/* 输入框前置图标样式 */
:deep(.el-input__icon) {
cursor: pointer;
}
/* 文本按钮样式 */
:deep(.el-button--text) {
padding: 0 4px;
}
/* 让 ElCard 的 body 参与 flex 布局,使列表区域可滚动 */
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 骨架屏样式 */
.dept-skeleton-item {
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
}
/* 部门项样式 */
.dept-item {
min-height: 40px;
}
</style>
@@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { DeptUser } from '#/api/core/dept';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { useVbenForm } from '#/adapter/form';
import { ZqDialog } from '#/components/zq-dialog';
const emit = defineEmits<{ success: [] }>();
const userData = ref<DeptUser>();
const visible = ref(false);
const formSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.account'),
componentProps: { disabled: true },
},
{
component: 'Input',
fieldName: 'name',
label: $t('system.user.userName'),
componentProps: { disabled: true },
},
{
component: 'Input',
fieldName: 'email',
label: $t('system.user.email'),
componentProps: { disabled: true },
},
{
component: 'Input',
fieldName: 'mobile',
label: $t('system.user.mobile'),
componentProps: { disabled: true },
},
];
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: formSchema,
showDefaultActions: false,
});
const getModalTitle = computed(() =>
$t('ui.actionTitle.view', [$t('system.user.name')]),
);
function onConfirm() {
visible.value = false;
emit('success');
}
function open(data: DeptUser) {
visible.value = true;
userData.value = data;
formApi.setValues(userData.value);
}
defineExpose({
open,
});
</script>
<template>
<ZqDialog v-model="visible" :title="getModalTitle" @confirm="onConfirm">
<Form class="mx-4" />
</ZqDialog>
</template>
@@ -0,0 +1,194 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn } from '#/adapter/vxe-table';
import type { DictItem } from '#/api/core/dict';
import { $t } from '@vben/locales';
import { z } from '#/adapter/form';
/**
* 获取字典搜索表单的字段配置
*/
export function useDictSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('dict.dictName'),
},
{
component: 'Input',
fieldName: 'code',
label: $t('dict.dictCode'),
},
];
}
/**
* 获取字典表单配置
*/
export function useDictFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('dict.dictName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('dict.dictName'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('dict.dictName'), 100])),
},
{
component: 'Input',
fieldName: 'code',
label: $t('dict.dictCode'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('dict.dictCode'), 2]))
.max(100, $t('ui.formRules.maxLength', [$t('dict.dictCode'), 100]))
.regex(/^\w+$/, $t('dict.codeFormatError')),
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: $t('common.enabled'), value: true },
{ label: $t('common.disabled'), value: false },
],
},
defaultValue: true,
fieldName: 'status',
label: $t('dict.status'),
},
];
}
/**
* 获取字典项搜索表单配置
*/
export function useDictItemSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'label',
label: $t('dict.itemLabel'),
},
{
component: 'Input',
fieldName: 'value',
label: $t('dict.itemValue'),
},
];
}
/**
* 获取字典项表单配置
*/
export function useDictItemFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'label',
label: $t('dict.itemLabel'),
rules: z
.string()
.min(1, $t('ui.formRules.required', [$t('dict.itemLabel')]))
.max(100, $t('ui.formRules.maxLength', [$t('dict.itemLabel'), 100])),
},
{
component: 'Input',
fieldName: 'value',
label: $t('dict.itemValue'),
rules: z
.string()
.min(1, $t('ui.formRules.required', [$t('dict.itemValue')]))
.max(100, $t('ui.formRules.maxLength', [$t('dict.itemValue'), 100])),
},
// {
// component: 'Input',
// fieldName: 'icon',
// label: $t('dict.itemIcon'),
// rules: z
// .string()
// .max(100, $t('ui.formRules.maxLength', [$t('dict.itemIcon'), 100]))
// .optional(),
// },
{
component: 'Textarea',
componentProps: {
placeholder: $t('dict.remarkPlaceholder'),
rows: 3,
},
fieldName: 'remark',
label: $t('dict.remark'),
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: $t('common.enabled'), value: true },
{ label: $t('common.disabled'), value: false },
],
},
defaultValue: true,
fieldName: 'status',
label: $t('dict.status'),
},
];
}
/**
* 获取字典项表格列配置
*/
export function useDictItemColumns(
onActionClick?: OnActionClickFn<DictItem>,
): VxeTableGridOptions<DictItem>['columns'] {
return [
{
field: 'label',
title: $t('dict.itemLabel'),
minWidth: 120,
},
{
field: 'value',
title: $t('dict.itemValue'),
minWidth: 120,
},
// {
// field: 'icon',
// title: $t('dict.itemIcon'),
// minWidth: 100,
// },
{
field: 'status',
title: $t('dict.status'),
minWidth: 80,
cellRender: {
name: 'CellStatus',
attrs: {
onClick: onActionClick,
},
},
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'label',
nameTitle: $t('dict.itemLabel'),
onClick: onActionClick,
},
name: 'CellOperation',
options: ['edit', 'delete'],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.user.operation'),
minWidth: 150,
},
];
}
@@ -0,0 +1,39 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { FuPage } from '#/components/fu-page';
import DictItemList from './modules/dict-item-list.vue';
import DictList from './modules/dict-list.vue';
defineOptions({ name: 'SystemDict' });
const currentDictId = ref<string>();
/**
* 字典选择事件
*/
function onDictSelect(dictId: string | undefined) {
currentDictId.value = dictId;
}
</script>
<template>
<FuPage
auto-content-height
:left-padding="false"
:right-padding="false"
:left-width="250"
>
<template #left>
<div class="h-full px-4 py-4">
<DictList @select="onDictSelect" />
</div>
</template>
<template #right>
<div class="h-full px-2 py-2">
<DictItemList :dict-id="currentDictId" />
</div>
</template>
</FuPage>
</template>
@@ -0,0 +1,99 @@
<script lang="ts" setup>
import type { Dict } from '#/api/core/dict';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElButton, ElSwitch } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createDictApi, updateDictApi } from '#/api/core/dict';
import { ZqDialog } from '#/components/zq-dialog';
import { useAppContextStore } from '#/store/app-context';
import { useDictFormSchema } from '../data';
const emit = defineEmits(['success']);
const appContextStore = useAppContextStore();
const isGlobal = ref(false);
const formData = ref<Dict>();
const visible = ref(false);
const confirmLoading = ref(false);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('dict.name')])
: $t('ui.actionTitle.create', [$t('dict.name')]);
});
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: useDictFormSchema(),
showDefaultActions: false,
});
function resetForm() {
formApi.resetForm();
formApi.setValues(formData.value || {});
}
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
confirmLoading.value = true;
const data = await formApi.getValues();
try {
if (!formData.value?.id) {
data.application_id = appContextStore.currentApp?.id;
}
data.is_global = isGlobal.value;
await (formData.value?.id
? updateDictApi(formData.value.id, data)
: createDictApi(data));
visible.value = false;
emit('success');
} finally {
confirmLoading.value = false;
}
}
}
function open(data?: Dict) {
visible.value = true;
if (data) {
formData.value = data;
isGlobal.value = data.is_global ?? false;
formApi.setValues(formData.value);
} else {
formData.value = undefined;
isGlobal.value = false;
formApi.resetForm();
}
}
defineExpose({
open,
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="getTitle"
:confirm-loading="confirmLoading"
@confirm="onSubmit"
width="500px"
>
<Form class="mx-4" />
<div class="mx-4 mb-2 flex items-center gap-2">
<span class="text-sm">{{ $t('dict.isGlobal') }}</span>
<ElSwitch v-model="isGlobal" />
</div>
<template #footer-left>
<ElButton type="primary" @click="resetForm">
{{ $t('common.reset') }}
</ElButton>
</template>
</ZqDialog>
</template>
@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { DictItem } from '#/api/core/dict';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createDictItemApi, updateDictItemApi } from '#/api/core/dict';
import { ZqDialog } from '#/components/zq-dialog';
import { useDictItemFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<DictItem>();
const dictId = ref<string>();
const visible = ref(false);
const confirmLoading = ref(false);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('dict.itemName')])
: $t('ui.actionTitle.create', [$t('dict.itemName')]);
});
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: useDictItemFormSchema(),
showDefaultActions: false,
});
function resetForm() {
formApi.resetForm();
formApi.setValues(formData.value || {});
}
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
confirmLoading.value = true;
const data = await formApi.getValues();
try {
const payload = {
...data,
dict_id: dictId.value,
};
await (formData.value?.id
? updateDictItemApi(formData.value.id, payload)
: createDictItemApi(payload));
visible.value = false;
emit('success');
} finally {
confirmLoading.value = false;
}
}
}
function open(data?: any) {
visible.value = true;
if (data?.id) {
formData.value = data;
dictId.value = data.dict_id;
formApi.setValues(formData.value);
} else {
formData.value = undefined;
dictId.value = data?.dictId;
formApi.resetForm();
}
}
defineExpose({
open,
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="getTitle"
:confirm-loading="confirmLoading"
@confirm="onSubmit"
>
<Form class="mx-4" />
<template #footer-left>
<ElButton type="primary" @click="resetForm">
{{ $t('common.reset') }}
</ElButton>
</template>
</ZqDialog>
</template>
@@ -0,0 +1,683 @@
<script lang="ts" setup>
import type { DictItem } from '#/api/core/dict';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { IconifyIcon, Plus, Search } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElEmpty,
ElInput,
ElMessage,
ElMessageBox,
ElScrollbar,
ElSkeleton,
ElSkeletonItem,
ElTooltip,
} from 'element-plus';
import {
createDictItemApi,
deleteDictItemApi,
getDictItemListApi,
updateDictItemApi,
} from '#/api/core/dict';
const props = defineProps<{
dictId?: string;
}>();
const dictItemList = ref<DictItem[]>([]);
const loading = ref(false);
const isLoadingMore = ref(false);
const savingNewItemId = ref<null | string>(null);
const savingEditItem = ref(false);
const searchKeyword = ref<string>('');
const newItemList = ref<
Array<{
icon: string;
id: string;
label: string;
remark: string;
sort: number;
status: boolean;
value: string;
}>
>([]);
const editingItemId = ref<null | string>(null);
const currentPage = ref(1);
const pageSize = ref(20);
const totalItems = ref(0);
const hasLoadedMore = ref(false);
let newItemCounter = 0;
const editingFormData = ref<Partial<DictItem>>({});
// 是否还有更多数据
const hasMoreData = computed(() => {
const totalLoaded = currentPage.value * pageSize.value;
return totalLoaded < totalItems.value;
});
// 计算过滤后的字典项列表
const filteredDictItemList = computed(() => {
if (!searchKeyword.value.trim()) {
return dictItemList.value;
}
const keyword = searchKeyword.value.toLowerCase();
return dictItemList.value.filter(
(item) =>
item.label?.toLowerCase().includes(keyword) ||
false ||
item.value?.toLowerCase().includes(keyword) ||
false,
);
});
async function fetchDictItemList(isLoadMore = false) {
if (!props.dictId) {
dictItemList.value = [];
return;
}
if (isLoadMore) {
isLoadingMore.value = true;
} else {
loading.value = true;
currentPage.value = 1;
hasLoadedMore.value = false;
}
try {
const response = await getDictItemListApi({
page: currentPage.value,
pageSize: pageSize.value,
dict_id: props.dictId,
});
dictItemList.value =
currentPage.value === 1
? response.items || []
: [...dictItemList.value, ...(response.items || [])];
totalItems.value = response.total || 0;
} finally {
if (isLoadMore) {
isLoadingMore.value = false;
} else {
loading.value = false;
}
}
}
/**
* 重置并重新加载
*/
function reload() {
currentPage.value = 1;
hasLoadedMore.value = false;
fetchDictItemList();
}
/**
* 处理滚动到底部
*/
async function handleScrollToBottom() {
if (isLoadingMore.value || !hasMoreData.value || loading.value) {
return;
}
hasLoadedMore.value = true;
currentPage.value += 1;
await fetchDictItemList(true);
}
/**
* 打开添加字典项输入行
*/
function onAddDictItem() {
// 自动计算排序值:最大排序值 + 1
const existingSorts = [
...dictItemList.value.map((item) => item.sort || 0),
...newItemList.value.map((item) => item.sort || 0),
];
const maxSort = existingSorts.length > 0 ? Math.max(...existingSorts) : 0;
newItemCounter++;
newItemList.value.unshift({
id: `new_${Date.now()}_${newItemCounter}`,
label: '',
value: '',
icon: '',
sort: maxSort + 1,
status: true,
remark: '',
});
// 滚动到顶部
nextTick(() => {
const scrollbar = document.querySelector('.dict-item-scrollbar');
if (scrollbar) {
const scrollElement = scrollbar.querySelector('.el-scrollbar__wrap');
if (scrollElement) {
scrollElement.scrollTop = 0;
}
}
});
}
/**
* 保存新字典项
*/
async function onSaveNewItem(newItem: {
icon: string;
id: string;
label: string;
remark: string;
sort: number;
status: boolean;
value: string;
}) {
if (!newItem.label || !newItem.value) {
ElMessage.warning(
$t('ui.formRules.required', [
`${$t('dict.itemLabel')}/${$t('dict.itemValue')}`,
]),
);
return;
}
try {
savingNewItemId.value = newItem.id;
await createDictItemApi({
dict_id: props.dictId!,
label: newItem.label,
value: newItem.value,
icon: newItem.icon,
sort: newItem.sort,
status: newItem.status,
remark: newItem.remark,
});
ElMessage.success(
$t('ui.actionMessage.createSuccess', [$t('dict.itemName')]),
);
newItemList.value = newItemList.value.filter((i) => i.id !== newItem.id);
await reload();
} catch {
ElMessage.error($t('ui.actionMessage.createError'));
} finally {
savingNewItemId.value = null;
}
}
/**
* 取消新增
*/
function onCancelNewItem(newItemId: string) {
newItemList.value = newItemList.value.filter((i) => i.id !== newItemId);
}
/**
* 打开编辑字典项(在当前行编辑)
*/
function onEditDictItem(item: DictItem) {
editingItemId.value = item.id;
editingFormData.value = { ...item };
}
/**
* 保存编辑的字典项
*/
async function onSaveEditItem() {
if (!editingFormData.value.label || !editingFormData.value.value) {
ElMessage.warning(
$t('ui.formRules.required', [
`${$t('dict.itemLabel')}/${$t('dict.itemValue')}`,
]),
);
return;
}
try {
savingEditItem.value = true;
await updateDictItemApi(editingItemId.value!, editingFormData.value);
ElMessage.success(
$t('ui.actionMessage.updateSuccess', [$t('dict.itemName')]),
);
editingItemId.value = null;
editingFormData.value = {};
await reload();
} catch {
ElMessage.error($t('ui.actionMessage.updateError'));
} finally {
savingEditItem.value = false;
}
}
/**
* 取消编辑
*/
function onCancelEdit() {
editingItemId.value = null;
editingFormData.value = {};
}
/**
* 删除字典项
*/
async function onDeleteDictItem(item: DictItem) {
ElMessageBox.confirm(
$t('ui.actionMessage.deleteConfirm', [item.label || item.value]),
$t('common.delete'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
showClose: false,
},
)
.then(async () => {
try {
await deleteDictItemApi(item.id);
ElMessage.success(
$t('ui.actionMessage.deleteSuccess', [item.label || item.value]),
);
await reload();
} catch {
ElMessage.error($t('ui.actionMessage.deleteError'));
}
})
.catch(() => {
// 用户取消了操作
});
}
// 监听 dictId 变化
watch(
() => props.dictId,
() => {
reload();
},
);
// 监听搜索文本变化
watch(searchKeyword, () => {
reload();
});
onMounted(() => {
reload();
});
</script>
<template>
<ElCard
shadow="never"
style="border: none"
class="flex h-full flex-col"
:body-style="{
display: 'flex',
flexDirection: 'column',
flex: 1,
padding: '0',
overflow: 'hidden',
minHeight: 0,
}"
>
<template #header>
<div class="flex w-full items-center justify-between">
<span>{{ $t('dict.itemName') }}</span>
<div class="flex items-center gap-2">
<ElInput
v-model="searchKeyword"
:placeholder="$t('common.search')"
clearable
:prefix-icon="Search"
:disabled="!dictId"
class="w-40"
/>
<ElTooltip :content="$t('common.add')" placement="top">
<ElButton :icon="Plus" :disabled="!dictId" @click="onAddDictItem" />
</ElTooltip>
</div>
</div>
</template>
<!-- 骨架屏加载状态 -->
<div v-if="dictId && loading" class="p-4">
<ElSkeleton :rows="10" animated>
<template #template>
<div class="space-y-2">
<div
v-for="i in 10"
:key="i"
class="rounded-lg border border-gray-200 bg-white p-4"
>
<div class="flex items-end gap-4">
<div class="flex flex-1 flex-col">
<ElSkeletonItem
variant="text"
style="width: 60px; height: 16px; margin-bottom: 8px"
/>
<ElSkeletonItem
variant="button"
style="width: 100%; height: 32px"
/>
</div>
<div class="flex flex-1 flex-col">
<ElSkeletonItem
variant="text"
style="width: 60px; height: 16px; margin-bottom: 8px"
/>
<ElSkeletonItem
variant="button"
style="width: 100%; height: 32px"
/>
</div>
<div class="flex w-20 flex-col">
<ElSkeletonItem
variant="text"
style="width: 40px; height: 16px; margin-bottom: 8px"
/>
<ElSkeletonItem
variant="button"
style="width: 100%; height: 32px"
/>
</div>
<div class="flex flex-shrink-0 items-center justify-end gap-2">
<ElSkeletonItem
variant="circle"
style="width: 32px; height: 32px"
/>
<ElSkeletonItem
variant="circle"
style="width: 32px; height: 32px"
/>
</div>
</div>
</div>
</div>
</template>
</ElSkeleton>
</div>
<!-- 空状态 - 居中显示 -->
<div
v-else-if="
dictId &&
filteredDictItemList.length === 0 &&
newItemList.length === 0 &&
!loading
"
class="flex h-full items-center justify-center"
>
<ElEmpty :description="$t('dict.noData')" />
</div>
<!-- 字典项列表 - 卡片式布局 -->
<ElScrollbar
v-else-if="dictId"
class="dict-item-scrollbar"
:distance="40"
@end-reached="handleScrollToBottom"
>
<div class="p-4">
<div class="space-y-2">
<!-- 新增行 -->
<div
v-for="newItem in newItemList"
:key="newItem.id"
class="border-primary rounded-lg border bg-blue-50 p-4"
>
<!-- 标签、值、排序和操作在同一行 -->
<div class="flex items-end gap-4">
<!-- 标签 -->
<div class="flex flex-1 flex-col">
<label class="mb-1 text-xs font-medium text-gray-600">
{{ $t('dict.itemLabel') }} *
</label>
<ElInput
v-model="newItem.label"
:placeholder="$t('dict.itemLabel')"
class="text-sm"
/>
</div>
<!-- 值 -->
<div class="flex flex-1 flex-col">
<label class="mb-1 text-xs font-medium text-gray-600">
{{ $t('dict.itemValue') }} *
</label>
<ElInput
v-model="newItem.value"
:placeholder="$t('dict.itemValue')"
class="text-sm"
/>
</div>
<!-- 排序 -->
<div class="flex w-20 flex-col">
<label class="mb-1 text-xs font-medium text-gray-600">
{{ $t('dict.sort') }}
</label>
<ElInput
v-model.number="newItem.sort"
type="number"
:placeholder="$t('dict.sort')"
class="text-sm"
/>
</div>
<!-- 操作按钮 -->
<div class="flex flex-shrink-0 items-center justify-end gap-2">
<ElTooltip :content="$t('common.save')" placement="top">
<ElButton
type="success"
text
size="small"
:loading="savingNewItemId === newItem.id"
@click="onSaveNewItem(newItem)"
>
<IconifyIcon
v-if="savingNewItemId !== newItem.id"
icon="ep:check"
class="size-4"
/>
</ElButton>
</ElTooltip>
<ElTooltip :content="$t('common.cancel')" placement="top">
<ElButton
type="warning"
text
size="small"
:disabled="savingNewItemId === newItem.id"
@click="onCancelNewItem(newItem.id)"
>
<IconifyIcon icon="ep:close" class="size-4" />
</ElButton>
</ElTooltip>
</div>
</div>
</div>
<!-- 现有项列表 -->
<div
v-for="item in filteredDictItemList"
:key="item.id"
class="rounded-lg border bg-white p-4 transition-all"
:class="[
editingItemId === item.id
? 'border-primary bg-blue-50'
: 'hover:border-primary border-gray-200 hover:shadow-md',
]"
>
<!-- 标签、值、排序和操作在同一行 -->
<div class="flex items-end gap-4">
<!-- 标签 -->
<div class="flex flex-1 flex-col">
<label class="mb-1 text-xs font-medium text-gray-600">
{{ $t('dict.itemLabel') }}
</label>
<ElInput
v-if="editingItemId === item.id"
v-model="editingFormData.label"
:placeholder="$t('dict.itemLabel')"
class="text-sm"
/>
<ElInput
v-else
:model-value="item.label"
:placeholder="$t('dict.itemLabel')"
disabled
class="text-sm"
/>
</div>
<!-- 值 -->
<div class="flex flex-1 flex-col">
<label class="mb-1 text-xs font-medium text-gray-600">
{{ $t('dict.itemValue') }}
</label>
<ElInput
v-if="editingItemId === item.id"
v-model="editingFormData.value"
:placeholder="$t('dict.itemValue')"
class="text-sm"
/>
<ElInput
v-else
:model-value="item.value"
:placeholder="$t('dict.itemValue')"
disabled
class="text-sm"
/>
</div>
<!-- 排序 -->
<div class="flex w-20 flex-col">
<label class="mb-1 text-xs font-medium text-gray-600">
{{ $t('dict.sort') }}
</label>
<ElInput
v-if="editingItemId === item.id"
v-model.number="editingFormData.sort"
type="number"
class="text-sm"
/>
<ElInput
v-else
:model-value="item.sort"
type="number"
disabled
class="text-sm"
/>
</div>
<!-- 操作按钮 -->
<div class="flex flex-shrink-0 items-center justify-end">
<template v-if="editingItemId === item.id">
<!-- 编辑状态下的保存和取消 -->
<ElTooltip :content="$t('common.save')" placement="top">
<ElButton
type="success"
text
size="small"
:loading="savingEditItem"
@click="onSaveEditItem"
>
<IconifyIcon
v-if="!savingEditItem"
icon="ep:check"
class="size-4"
/>
</ElButton>
</ElTooltip>
<ElTooltip :content="$t('common.cancel')" placement="top">
<ElButton
type="warning"
text
size="small"
:disabled="savingEditItem"
@click="onCancelEdit"
>
<IconifyIcon icon="ep:close" class="size-4" />
</ElButton>
</ElTooltip>
</template>
<template v-else>
<!-- 正常状态下的编辑和删除 -->
<ElTooltip :content="$t('dict.edit')" placement="top">
<ElButton
type="primary"
text
size="small"
@click="onEditDictItem(item)"
>
<IconifyIcon icon="ep:edit" class="size-4" />
</ElButton>
</ElTooltip>
<ElTooltip :content="$t('common.delete')" placement="top">
<ElButton
type="danger"
text
size="small"
@click="onDeleteDictItem(item)"
>
<IconifyIcon icon="ep:delete" class="size-4" />
</ElButton>
</ElTooltip>
</template>
</div>
</div>
</div>
</div>
<!-- 加载更多提示 -->
<div v-if="isLoadingMore" class="flex items-center justify-center py-4">
<div class="loading-spinner"></div>
<span class="ml-2 text-sm text-gray-500">{{
$t('common.loading')
}}</span>
</div>
<!-- 无更多数据提示 -->
<div
v-else-if="
filteredDictItemList.length > 0 && !hasMoreData && hasLoadedMore
"
class="py-4 text-center text-sm text-gray-500"
>
{{ $t('common.noMore') || 'No more data' }}
</div>
</div>
</ElScrollbar>
<!-- 未选择字典时的空状态 -->
<div v-else class="flex h-full items-center justify-center">
<ElEmpty :description="$t('dict.selectDictFirst')" />
</div>
</ElCard>
</template>
<style scoped>
.dict-item-scrollbar {
flex: 1;
min-height: 0;
overflow: hidden;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--el-color-primary);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,297 @@
<script lang="ts" setup>
import type { Dict } from '#/api/core/dict';
import { computed, onMounted, ref } from 'vue';
import { IconifyIcon, Plus, Search } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElInput,
ElMessage,
ElMessageBox,
ElSkeleton,
ElSkeletonItem,
ElTag,
ElTooltip,
} from 'element-plus';
import { deleteDictApi, getDictListApi } from '#/api/core/dict';
import { useAppContextStore } from '#/store/app-context';
import DictFormModal from './dict-form-modal.vue';
const emit = defineEmits<{
select: [dictId: string | undefined];
}>();
const appContextStore = useAppContextStore();
const dictList = ref<Dict[]>([]);
const loading = ref(false);
const selectedDictId = ref<string>();
const searchKeyword = ref<string>('');
const hoveredDictId = ref<string>();
const dictFormModalRef = ref<InstanceType<typeof DictFormModal>>();
// 计算过滤后的字典列表
const filteredDictList = computed(() => {
if (!searchKeyword.value.trim()) {
return dictList.value;
}
const keyword = searchKeyword.value.toLowerCase();
return dictList.value.filter(
(dict) =>
dict.name.toLowerCase().includes(keyword) ||
dict.code.toLowerCase().includes(keyword),
);
});
async function fetchDictList() {
try {
loading.value = true;
const response = await getDictListApi({
page: 1,
pageSize: 100,
applicationId: appContextStore.currentApp?.id,
});
dictList.value = response.items || [];
// 自动选中第一个字典
if (dictList.value.length > 0 && !selectedDictId.value) {
const firstDict = dictList.value.at(0);
if (firstDict) {
selectedDictId.value = firstDict.id;
emit('select', firstDict.id);
}
}
} finally {
loading.value = false;
}
}
/**
* 处理字典选择
*/
function onDictSelect(dictId: string) {
selectedDictId.value = dictId;
emit('select', dictId);
}
/**
* 打开添加字典对话框
*/
function onAddDict() {
dictFormModalRef.value?.open();
}
/**
* 打开编辑字典对话框
*/
function onEditDict(dict: Dict, e?: Event) {
e?.stopPropagation();
dictFormModalRef.value?.open(dict);
}
/**
* 删除字典
*/
async function onDeleteDict(dict: Dict, e?: Event) {
e?.stopPropagation();
ElMessageBox.confirm(
$t('ui.actionMessage.deleteConfirm', [dict.name]),
$t('common.delete'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
showClose: false,
},
)
.then(async () => {
try {
await deleteDictApi(dict.id);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [dict.name]));
// 如果删除的是当前选中的字典,清除选中状态
if (selectedDictId.value === dict.id) {
selectedDictId.value = undefined;
emit('select', undefined);
}
await fetchDictList();
} catch {
ElMessage.error($t('ui.actionMessage.deleteError'));
}
})
.catch(() => {
// 用户取消了操作
});
}
/**
* 添加字典成功后的回调
*/
async function onDictFormSuccess() {
ElMessage.success($t('ui.actionMessage.createSuccess', [$t('dict.name')]));
await fetchDictList();
}
onMounted(() => {
fetchDictList();
});
</script>
<template>
<ElCard shadow="never" style="border: none" class="flex h-full flex-col">
<DictFormModal ref="dictFormModalRef" @success="onDictFormSuccess" />
<!-- 搜索和添加区域 -->
<div class="mb-4 flex gap-2">
<ElInput
v-model="searchKeyword"
:placeholder="$t('common.search')"
clearable
:prefix-icon="Search"
/>
<ElButton :icon="Plus" @click="onAddDict" />
</div>
<!-- 字典列表 -->
<div class="flex-1 overflow-auto">
<ElSkeleton :loading="loading" animated :count="8">
<template #template>
<div class="space-y-1">
<div v-for="i in 8" :key="i" class="dict-skeleton-item">
<ElSkeletonItem
variant="text"
style="width: 100%; height: 40px"
/>
</div>
</div>
</template>
<template #default>
<div class="space-y-2">
<div
v-for="dict in filteredDictList"
:key="dict.id"
class="dict-item cursor-pointer rounded-[8px] px-3 py-2 transition-colors"
:class="[
selectedDictId === dict.id
? 'bg-primary/15 dark:bg-accent text-primary'
: 'hover:bg-[var(--el-fill-color-light)]',
]"
@mouseenter="hoveredDictId = dict.id"
@mouseleave="hoveredDictId = undefined"
@click="onDictSelect(dict.id)"
>
<!-- 上半部分字典名称和操作按钮 -->
<div class="mb-1 flex min-h-6 items-center justify-between">
<div
class="flex min-w-0 flex-1 items-center justify-between gap-1"
>
<div class="truncate text-sm font-medium" :title="dict.name">
{{ dict.name }}
</div>
<ElTag
v-if="dict.is_global"
size="small"
type="warning"
class="flex-shrink-0"
>
{{ $t('dict.globalTag') }}
</ElTag>
</div>
<!-- 操作图标 -->
<div
v-if="hoveredDictId === dict.id"
class="ml-2 flex flex-shrink-0 gap-0.5"
@click.stop
>
<ElTooltip :content="$t('dict.edit')" placement="top">
<ElButton
type="primary"
text
size="small"
circle
@click="onEditDict(dict, $event)"
>
<IconifyIcon icon="ep:edit" class="size-4" />
</ElButton>
</ElTooltip>
<ElButton
type="danger"
text
size="small"
circle
style="margin-left: 0"
:title="$t('common.delete')"
@click="onDeleteDict(dict, $event)"
>
<IconifyIcon icon="ep:delete" class="size-4" />
</ElButton>
</div>
</div>
<!-- 下半部分详细信息 -->
<div class="flex items-center gap-2 text-xs opacity-70">
<!-- 字典编码 -->
<span class="truncate" :title="dict.code">
{{ dict.code }}
</span>
<!-- 应用名称 -->
<span class="text-gray-400">|</span>
<span
class="truncate"
:title="dict.application_name || $t('dict.mainApp')"
>
{{ dict.application_name || $t('dict.mainApp') }}
</span>
<!-- 分隔符 -->
<span class="text-gray-400">|</span>
<!-- 状态 -->
<span v-if="dict.status" class="flex-shrink-0">
{{ $t('common.enabled') }}
</span>
<span v-else class="flex-shrink-0">
{{ $t('common.disabled') }}
</span>
</div>
</div>
</div>
</template>
</ElSkeleton>
</div>
</ElCard>
</template>
<style scoped>
/* 输入框前置图标样式 */
:deep(.el-input__icon) {
cursor: pointer;
}
/* 文本按钮样式 */
:deep(.el-button--text) {
padding: 0 4px;
}
/* 骨架屏样式 */
.dict-skeleton-item {
box-sizing: border-box;
width: 100%;
padding: 8px 12px;
}
/* 字典项样式 */
.dict-item {
min-height: 56px;
}
</style>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { createFolder } from '#/api/core/file';
import { useFileManager } from '../composables/useFileManager';
const { createFolderDialogVisible, currentFolderId, fetchFiles } =
useFileManager();
const form = ref({
name: '',
});
const formRef = ref<any>(null);
const loading = ref(false);
// 监听对话框打开,重置表单
watch(createFolderDialogVisible, (val) => {
if (val) {
form.value.name = '';
}
});
const handleClose = () => {
createFolderDialogVisible.value = false;
};
const handleSubmit = async () => {
if (!form.value.name) {
ElMessage.warning($t('file-manager.pleaseEnterFolderName'));
return;
}
loading.value = true;
try {
await createFolder({
name: form.value.name,
parent_id: currentFolderId.value || undefined,
});
ElMessage.success($t('file-manager.createSuccess'));
createFolderDialogVisible.value = false;
// 刷新列表
fetchFiles();
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
</script>
<template>
<ElDialog
v-model="createFolderDialogVisible"
:title="$t('file-manager.newFolder')"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
>
<ElForm ref="formRef" :model="form" @submit.prevent="handleSubmit">
<ElFormItem :label="$t('file-manager.folderName')">
<ElInput
v-model="form.name"
:placeholder="$t('file-manager.pleaseEnterFolderName')"
autofocus
@keyup.enter="handleSubmit"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleClose">
{{ $t('file-manager.cancel') }}
</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSubmit">
{{ $t('file-manager.confirm') }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
@@ -0,0 +1,547 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { Folder, MoreVertical } from '@vben/icons';
import { $t } from '@vben/locales';
import { useUserStore } from '@vben/stores';
import {
ElCheckbox,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElMessage,
ElMessageBox,
ElImageViewer as ImageViewer,
ElInfiniteScroll as vInfiniteScroll,
} from 'element-plus';
import { deleteItem } from '#/api/core/file';
import { getFileTypeIcon } from '#/assets/file-icons';
import { getFileUrl } from '#/composables/useFileUrl';
import { useFileManager } from '../composables/useFileManager';
import RenameDialog from './RenameDialog.vue';
const {
currentFolderId,
viewMode,
fileList,
loading,
loadingMore,
noMore,
selectedFileIds,
navigateToFolder,
fetchFiles,
loadMore,
clearSelection,
} = useFileManager();
const renameDialogVisible = ref(false);
const currentItem = ref<any>(null);
// 图片预览状态
const previewVisible = ref(false);
const previewUrlList = ref<string[]>([]);
const previewInitialIndex = ref(0);
// 图片URL缓存映射
const imageUrlMap = ref<Map<string, string>>(new Map());
// 获取图片URL(从缓存中获取)
function getImageUrl(id: string): string {
return imageUrlMap.value.get(id) || '';
}
// 批量加载文件列表中所有图片的URL
async function loadImageUrls() {
const images = fileList.value.filter(
(f) => isImage(f.file_ext) && f.id && !imageUrlMap.value.has(f.id),
);
if (images.length === 0) return;
// 并发批量获取所有图片URL
const results = await Promise.allSettled(
images.map(async (img) => {
const url = await getFileUrl(img.id!);
return { id: img.id!, url };
}),
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.url) {
imageUrlMap.value.set(result.value.id, result.value.url);
}
}
}
// 全选相关计算属性
const isAllSelected = computed(() => {
return (
fileList.value.length > 0 &&
selectedFileIds.value.size === fileList.value.length
);
});
const isIndeterminate = computed(() => {
return (
selectedFileIds.value.size > 0 &&
selectedFileIds.value.size < fileList.value.length
);
});
// 处理全选
const handleSelectAll = (val: any) => {
if (val) {
fileList.value.forEach((item) => selectedFileIds.value.add(item.id!));
} else {
clearSelection();
}
};
// 监听文件夹变化,自动刷新列表并清空选中
watch(currentFolderId, () => {
clearSelection();
imageUrlMap.value.clear();
fetchFiles();
});
// 监听文件列表变化,加载图片URL
watch(
fileList,
() => {
loadImageUrls();
},
{ immediate: true },
);
onMounted(fetchFiles);
// 处理 Grid/List 选中
const handleGridSelect = (id: string, value: boolean) => {
if (value) {
selectedFileIds.value.add(id);
} else {
selectedFileIds.value.delete(id);
}
};
const isImage = (ext?: string) => {
if (!ext) return false;
const extension = ext.toLowerCase().replace('.', '');
return ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp'].includes(
extension,
);
};
const isFolder = (item: any) => {
return (item.file_type || item.fileType || item.type) === 'folder';
};
// 单击选中/取消选中
const handleItemClick = (item: any) => {
const id = item.id!;
if (selectedFileIds.value.has(id)) {
selectedFileIds.value.delete(id);
} else {
selectedFileIds.value.clear();
selectedFileIds.value.add(id);
}
};
// 双击打开
const handleItemOpen = async (item: any) => {
const type = item.file_type || item.fileType || item.type;
if (type === 'folder') {
navigateToFolder(item.id, item.name);
} else if (isImage(item.file_ext)) {
const images = fileList.value.filter((f) => isImage(f.file_ext));
const urls = await Promise.all(images.map((img) => getFileUrl(img.id!)));
previewUrlList.value = urls;
const index = images.findIndex((img) => img.id === item.id);
previewInitialIndex.value = index === -1 ? 0 : index;
previewVisible.value = true;
} else {
const query = new URLSearchParams({
name: item.name || '',
ext: item.file_ext || '',
});
window.open(`/file-preview/${item.id}?${query.toString()}`, '_blank');
}
};
const closePreview = () => {
previewVisible.value = false;
};
const handleDownload = async (item: any) => {
if (isFolder(item)) {
ElMessage.warning($t('file-manager.folderDownloadNotSupported'));
return;
}
try {
// 获取带 token 的 URL
const tokenUrl = await getFileUrl(item.id);
// 添加 download 参数
const downloadUrl = `${tokenUrl + (tokenUrl.includes('?') ? '&' : '?')}download=true`;
// 使用 fetch 获取文件 blob
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`下载失败: ${response.status}`);
}
const blob = await response.blob();
// 创建 blob URL 并下载
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = item.name || 'download';
document.body.append(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('下载文件失败:', error);
ElMessage.error($t('file-manager.downloadFailed') || '下载文件失败');
}
};
const handleDelete = async (item: any) => {
try {
await ElMessageBox.confirm(
$t('file-manager.deleteConfirm', { name: item.name }),
$t('file-manager.tip'),
{
type: 'warning',
confirmButtonText: $t('file-manager.confirm'),
cancelButtonText: $t('file-manager.cancel'),
},
);
await deleteItem(item.id);
ElMessage.success($t('file-manager.deleteSuccess'));
fetchFiles();
} catch (error) {
if (error !== 'cancel') {
console.error(error);
}
}
};
const openRenameDialog = (item: any) => {
currentItem.value = item;
renameDialogVisible.value = true;
};
const handleAction = (action: string, item: any) => {
switch (action) {
case 'delete': {
handleDelete(item);
break;
}
case 'download': {
handleDownload(item);
break;
}
case 'open': {
handleItemOpen(item);
break;
}
case 'rename': {
openRenameDialog(item);
break;
}
}
};
// 判断当前用户是否可以操作该文件(重命名/删除)
const userStore = useUserStore();
debugger;
const canOperate = (item: any) => {
// 系统文件夹不可操作
if (item.is_system) return false;
// 超管可以操作所有文件
if (userStore.userRoles?.includes('super')) return true;
// 普通用户只能操作自己的文件
return item.sys_creator_id === userStore.userInfo?.userId;
};
// 格式化大小
const formatSize = (size?: number) => {
if (size === undefined || size === null) return '-';
if (size === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let s = size;
while (s >= 1024 && i < units.length - 1) {
s /= 1024;
i++;
}
return `${s.toFixed(2)} ${units[i]}`;
};
</script>
<template>
<div
v-loading="loading"
v-infinite-scroll="loadMore"
:infinite-scroll-disabled="noMore || loadingMore"
:infinite-scroll-distance="200"
class="h-full w-full overflow-y-auto"
>
<div
v-if="fileList.length === 0 && !loading"
class="flex h-full items-center justify-center"
>
<ElEmpty :description="$t('file-manager.noFiles')" />
</div>
<!-- Grid View -->
<template v-else-if="viewMode === 'grid'">
<div class="bg-background sticky top-0 z-20 flex items-center px-4 py-2">
<ElCheckbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleSelectAll"
>
{{ $t('file-manager.selectAll') }}
{{ selectedFileIds.size > 0 ? `(${selectedFileIds.size})` : '' }}
</ElCheckbox>
</div>
<div
class="grid grid-cols-2 gap-4 px-4 py-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
<div
v-for="item in fileList"
:key="item.id"
class="border-border bg-card hover:bg-accent group relative flex cursor-pointer flex-col rounded-lg border p-4 transition-colors"
:class="{
'ring-primary bg-accent ring-2': selectedFileIds.has(item.id!),
}"
@click="handleItemClick(item)"
@dblclick="handleItemOpen(item)"
>
<!-- Checkbox for Grid -->
<div class="absolute left-2 top-2 z-10" @click.stop>
<ElCheckbox
:model-value="selectedFileIds.has(item.id!)"
@change="(val) => handleGridSelect(item.id!, Boolean(val))"
/>
</div>
<!-- 操作按钮 -->
<div class="absolute right-2 top-2 z-10" @click.stop>
<ElDropdown trigger="click">
<button
class="invisible rounded-full p-1 hover:bg-gray-200 group-hover:visible dark:hover:bg-gray-700"
>
<MoreVertical class="size-4 text-gray-500" />
</button>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleAction('open', item)">
{{ $t('file-manager.open') }}
</ElDropdownItem>
<ElDropdownItem
v-if="!isFolder(item)"
@click="handleAction('download', item)"
>
{{ $t('file-manager.download') }}
</ElDropdownItem>
<ElDropdownItem
v-if="canOperate(item)"
divided
@click="handleAction('rename', item)"
>
{{ $t('file-manager.rename') }}
</ElDropdownItem>
<ElDropdownItem
v-if="canOperate(item)"
class="text-red-500"
@click="handleAction('delete', item)"
>
{{ $t('file-manager.delete') }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
<div
class="flex flex-1 items-center justify-center overflow-hidden py-4"
>
<template v-if="isImage(item.file_ext)">
<img
v-if="getImageUrl(item.id!)"
:src="getImageUrl(item.id!)"
class="h-16 w-full object-contain"
loading="lazy"
/>
<div
v-else
class="bg-muted h-16 w-full animate-pulse rounded"
></div>
</template>
<Folder v-else-if="isFolder(item)" class="size-16 text-blue-500" />
<img v-else :src="getFileTypeIcon(item.file_ext)" class="size-16" />
</div>
<div class="px-2 text-center">
<span
class="block truncate text-sm font-medium"
:title="item.name"
>{{ item.name }}</span
>
</div>
</div>
</div>
</template>
<!-- List View -->
<template v-else>
<div class="bg-background sticky top-0 z-20 flex items-center px-4 py-2">
<ElCheckbox
:model-value="isAllSelected"
:indeterminate="isIndeterminate"
@change="handleSelectAll"
>
{{ $t('file-manager.selectAll') }}
{{ selectedFileIds.size > 0 ? `(${selectedFileIds.size})` : '' }}
</ElCheckbox>
</div>
<!-- 列表表头 -->
<div
class="text-muted-foreground flex items-center gap-3 border-b border-[var(--el-border-color)] px-4 py-2 text-xs font-medium"
>
<div class="w-8"></div>
<div class="w-8"></div>
<div class="min-w-0 flex-1">{{ $t('file-manager.name') }}</div>
<div class="hidden w-24 text-right sm:block">
{{ $t('file-manager.size') }}
</div>
<div class="hidden w-40 text-right md:block">
{{ $t('file-manager.modifiedTime') }}
</div>
<div class="w-8"></div>
</div>
<!-- 列表行 -->
<div class="px-4">
<div
v-for="item in fileList"
:key="item.id"
class="hover:bg-accent group flex cursor-pointer items-center gap-3 rounded-md px-0 py-2 transition-colors"
:class="{
'bg-accent': selectedFileIds.has(item.id!),
}"
@click="handleItemClick(item)"
@dblclick="handleItemOpen(item)"
>
<!-- Checkbox -->
<div class="w-8 flex-shrink-0 text-center" @click.stop>
<ElCheckbox
:model-value="selectedFileIds.has(item.id!)"
@change="(val) => handleGridSelect(item.id!, Boolean(val))"
/>
</div>
<!-- 图标 -->
<div class="flex w-8 flex-shrink-0 items-center justify-center">
<template v-if="isImage(item.file_ext)">
<img
v-if="getImageUrl(item.id!)"
:src="getImageUrl(item.id!)"
class="size-6 rounded object-cover"
loading="lazy"
/>
<div v-else class="bg-muted size-6 animate-pulse rounded"></div>
</template>
<Folder v-else-if="isFolder(item)" class="size-6 text-blue-500" />
<img v-else :src="getFileTypeIcon(item.file_ext)" class="size-6" />
</div>
<!-- 文件名 -->
<div class="min-w-0 flex-1">
<span class="block truncate text-sm" :title="item.name">{{
item.name
}}</span>
</div>
<!-- 大小 -->
<div
class="text-muted-foreground hidden w-24 flex-shrink-0 text-right text-xs sm:block"
>
{{ isFolder(item) ? '-' : formatSize(item.file_size || item.size) }}
</div>
<!-- 修改时间 -->
<div
class="text-muted-foreground hidden w-40 flex-shrink-0 text-right text-xs md:block"
>
{{ item.updated_time?.substring(0, 16) }}
</div>
<!-- 操作 -->
<div class="w-8 flex-shrink-0 text-center" @click.stop>
<ElDropdown trigger="click">
<button
class="invisible rounded-full p-1 hover:bg-gray-200 group-hover:visible dark:hover:bg-gray-700"
>
<MoreVertical class="size-4 text-gray-500" />
</button>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleAction('open', item)">
{{ $t('file-manager.open') }}
</ElDropdownItem>
<ElDropdownItem
v-if="!isFolder(item)"
@click="handleAction('download', item)"
>
{{ $t('file-manager.download') }}
</ElDropdownItem>
<ElDropdownItem
v-if="canOperate(item)"
divided
@click="handleAction('rename', item)"
>
{{ $t('file-manager.rename') }}
</ElDropdownItem>
<ElDropdownItem
v-if="canOperate(item)"
class="text-red-500"
@click="handleAction('delete', item)"
>
{{ $t('file-manager.delete') }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</div>
</template>
<!-- 无限滚动加载提示 -->
<div
v-if="fileList.length > 0"
class="text-muted-foreground flex items-center justify-center py-4 text-xs"
>
<template v-if="loadingMore">
<span class="mr-2 animate-spin">&#9696;</span>
{{ $t('common.loading') }}
</template>
<template v-else-if="noMore">
{{ $t('common.noMore') }}
</template>
</div>
<RenameDialog v-model:visible="renameDialogVisible" :item="currentItem" />
<ImageViewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewInitialIndex"
@close="closePreview"
/>
</div>
</template>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
ChevronRight,
Home,
LayoutGrid,
List,
Plus,
RotateCw,
Search,
Trash2,
Upload,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElBreadcrumb,
ElBreadcrumbItem,
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElInput,
ElMessage,
ElMessageBox,
ElProgress,
} from 'element-plus';
import { batchDelete, uploadFile } from '#/api/core/file';
import { useFileManager } from '../composables/useFileManager';
const {
viewMode,
breadcrumbs,
currentFolderId,
selectedFileIds,
navigateToFolder,
openCreateFolderDialog,
fetchFiles,
clearSelection,
} = useFileManager();
const fileInputRef = ref<HTMLInputElement | null>(null);
// ... (rest of the code)
const deleting = ref(false);
const handleBatchDelete = async () => {
if (selectedFileIds.value.size === 0) return;
try {
await ElMessageBox.confirm(
$t('file-manager.batchDeleteConfirm', {
count: selectedFileIds.value.size,
}),
$t('file-manager.tip'),
{
type: 'warning',
confirmButtonText: $t('file-manager.confirm'),
cancelButtonText: $t('file-manager.cancel'),
},
);
deleting.value = true;
await batchDelete({ ids: [...selectedFileIds.value] });
ElMessage.success($t('file-manager.deleteSuccess'));
clearSelection();
fetchFiles();
} catch (error) {
if (error !== 'cancel') {
console.error(error);
}
} finally {
deleting.value = false;
}
};
const folderInputRef = ref<HTMLInputElement | null>(null);
const uploading = ref(false);
const uploadProgress = ref(0);
const uploadingFileName = ref('');
const uploadedCount = ref(0);
const totalUploadCount = ref(0);
const uploadStatusText = computed(() => {
if (!uploading.value) return '';
if (totalUploadCount.value <= 1) return uploadingFileName.value;
return `(${uploadedCount.value + 1}/${totalUploadCount.value}) ${uploadingFileName.value}`;
});
const handleBreadcrumbClick = (id: null | string, name: string) => {
navigateToFolder(id, name);
};
const handleCreateFolder = () => {
openCreateFolderDialog();
};
const handleUploadFile = () => {
fileInputRef.value?.click();
};
const handleUploadFolder = () => {
folderInputRef.value?.click();
};
const handleFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) return;
uploading.value = true;
uploadProgress.value = 0;
uploadedCount.value = 0;
totalUploadCount.value = files.length;
let successCount = 0;
let failCount = 0;
try {
for (const file of files) {
if (!file) continue;
uploadingFileName.value = file.name;
uploadProgress.value = 0;
try {
await uploadFile(file, {
parentId: currentFolderId.value || undefined,
onProgress: (e) => {
uploadProgress.value = e.percentage;
},
});
successCount++;
uploadedCount.value = successCount + failCount;
} catch (error) {
console.error(`Failed to upload ${file.name}`, error);
failCount++;
uploadedCount.value = successCount + failCount;
}
}
if (successCount > 0) {
ElMessage.success(
$t('file-manager.uploadSuccess', { count: successCount }),
);
fetchFiles();
}
if (failCount > 0) {
ElMessage.error($t('file-manager.uploadFailed', { count: failCount }));
}
} catch (error) {
console.error(error);
ElMessage.error($t('file-manager.uploadError'));
} finally {
uploading.value = false;
uploadProgress.value = 0;
uploadingFileName.value = '';
target.value = '';
}
};
</script>
<template>
<div class="border-border border-b">
<div class="flex items-center justify-between px-4 py-3">
<!-- Hidden Inputs -->
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFileChange"
/>
<input
ref="folderInputRef"
type="file"
webkitdirectory
class="hidden"
@change="handleFileChange"
/>
<!-- Breadcrumbs -->
<div class="mr-4 flex flex-1 items-center overflow-hidden">
<ElBreadcrumb :separator-icon="ChevronRight">
<ElBreadcrumbItem
v-for="(item, index) in breadcrumbs"
:key="item.id || 'root'"
>
<span
class="hover:text-primary flex cursor-pointer items-center gap-1"
:class="{
'text-foreground font-bold': index === breadcrumbs.length - 1,
}"
@click="handleBreadcrumbClick(item.id, item.name)"
>
<Home v-if="index === 0" class="size-4" />
{{ item.name }}
</span>
</ElBreadcrumbItem>
</ElBreadcrumb>
</div>
<!-- Actions -->
<div class="flex flex-shrink-0 items-center gap-2 sm:gap-3">
<ElInput :placeholder="$t('file-manager.search')" class="w-32 sm:w-48">
<template #prefix>
<Search class="size-4" />
</template>
</ElInput>
<div class="flex items-center rounded-lg border p-1">
<button
class="hover:bg-accent p-2"
:class="{ 'bg-accent text-primary': viewMode === 'list' }"
@click="viewMode = 'list'"
:title="$t('file-manager.listView')"
>
<List class="size-4" />
</button>
<button
class="hover:bg-accent p-2"
:class="{ 'bg-accent text-primary': viewMode === 'grid' }"
@click="viewMode = 'grid'"
:title="$t('file-manager.gridView')"
>
<LayoutGrid class="size-4" />
</button>
</div>
<ElButton circle @click="fetchFiles">
<template #icon>
<RotateCw class="size-4" :class="{ 'animate-spin': uploading }" />
</template>
</ElButton>
<ElButton
v-if="selectedFileIds.size > 0"
type="danger"
plain
:loading="deleting"
@click="handleBatchDelete"
>
<Trash2 v-if="!deleting" class="mr-2 size-4" />
{{ $t('file-manager.batchDelete') }}
</ElButton>
<ElButton
type="primary"
plain
@click="handleCreateFolder"
class="hidden sm:flex"
>
<Plus class="mr-2 size-4" /> {{ $t('file-manager.newFolder') }}
</ElButton>
<ElDropdown trigger="click">
<ElButton type="primary" :loading="uploading" class="hidden sm:flex">
<Upload class="mr-2 size-4" /> {{ $t('file-manager.upload') }}
</ElButton>
<ElButton
circle
type="primary"
:loading="uploading"
class="flex sm:hidden"
>
<Plus class="size-4" />
</ElButton>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleUploadFile">
{{ $t('file-manager.uploadFile') }}
</ElDropdownItem>
<ElDropdownItem @click="handleUploadFolder">
{{ $t('file-manager.uploadFolder') }}
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
<!-- 上传进度条 -->
<div v-if="uploading" class="flex items-center gap-3 px-4 py-2">
<span class="text-muted-foreground max-w-48 truncate text-xs">
{{ uploadStatusText }}
</span>
<ElProgress
:percentage="uploadProgress"
:stroke-width="6"
class="flex-1"
/>
</div>
</div>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { Cloud, Folder, FolderOpen } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElTree } from 'element-plus';
import { getFileList } from '#/api/core/file';
import { useFileManager } from '../composables/useFileManager';
const { navigateToFolder } = useFileManager();
interface TreeData {
id: null | string;
name: string;
isLeaf?: boolean;
}
const props = {
label: 'name',
children: 'children',
isLeaf: 'isLeaf',
};
const loadNode = async (node: any, resolve: (data: TreeData[]) => void) => {
if (node.level === 0) {
return resolve([{ id: 'root', name: $t('file-manager.myFiles') }]);
}
try {
const parentId = node.data.id === 'root' ? null : node.data.id;
const res = await getFileList({
parent_id: parentId,
type: 'folder',
page: 1,
pageSize: 100, // 暂时获取所有
});
const folders = res.items.map((item: any) => ({
id: item.id!,
name: item.name,
isLeaf: !item.has_children,
}));
// 如果没有子文件夹,标记当前节点为叶子节点,防止无限循环展开
if (folders.length === 0) {
node.isLeaf = true;
if (node.data) {
node.data.isLeaf = true;
}
}
resolve(folders);
} catch (error) {
console.error('Failed to load folders', error);
resolve([]);
}
};
const handleNodeClick = (data: TreeData) => {
const folderId = data.id === 'root' ? null : data.id;
navigateToFolder(folderId, data.name);
};
</script>
<template>
<div class="bg-background flex h-full w-64 flex-col rounded-[10px]">
<div class="text-foreground flex items-center gap-2 p-4 text-sm font-bold">
<Cloud class="text-primary size-5" />
{{ $t('file-manager.fileManagement') }}
</div>
<div class="flex-1 overflow-y-auto p-2">
<ElTree
lazy
:load="loadNode"
:props="props"
:expand-on-click-node="false"
node-key="id"
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ node }">
<span class="flex items-center gap-2 py-1">
<FolderOpen v-if="node.expanded" class="size-4 text-blue-500" />
<Folder v-else class="size-4 text-gray-500" />
<span class="truncate text-sm">{{ node.label }}</span>
</span>
</template>
</ElTree>
</div>
</div>
</template>
<style scoped lang="less">
/* 调整树节点高度 */
:deep(.el-tree-node__content) {
height: 34px;
line-height: 34px;
border-radius: 6px;
margin-bottom: 4px;
}
/* 调整展开/收起图标的对齐 */
:deep(.el-tree-node__expand-icon) {
padding: 6px;
}
/* 选中节点的背景色 - 使用primary色 */
:deep(.el-tree-node.is-current > .el-tree-node__content) {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
/* 悬停效果 */
:deep(.el-tree-node__content:hover) {
background-color: var(--el-fill-color-light);
}
/* 选中节点悬停时保持primary色 */
:deep(.el-tree-node.is-current > .el-tree-node__content:hover) {
background-color: var(--el-color-primary-light-8);
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElMessage,
} from 'element-plus';
import { renameItem } from '#/api/core/file';
import { useFileManager } from '../composables/useFileManager';
const props = defineProps<{
item: null | { id: string; name: string; type: string };
visible: boolean;
}>();
const emit = defineEmits(['update:visible', 'success']);
const { fetchFiles } = useFileManager();
const loading = ref(false);
const form = ref({
name: '',
});
const formRef = ref<any>(null);
watch(
() => props.visible,
(val) => {
if (val && props.item) {
form.value.name = props.item.name;
}
},
);
const handleClose = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
if (!props.item) return;
if (!form.value.name) {
ElMessage.warning($t('file-manager.pleaseEnterName'));
return;
}
loading.value = true;
try {
await renameItem(props.item.id, {
name: form.value.name,
});
ElMessage.success($t('file-manager.renameSuccess'));
emit('update:visible', false);
emit('success');
fetchFiles();
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
</script>
<template>
<ElDialog
:model-value="visible"
:title="$t('file-manager.rename')"
width="400px"
:close-on-click-modal="false"
@close="handleClose"
>
<ElForm ref="formRef" :model="form" @submit.prevent="handleSubmit">
<ElFormItem :label="$t('file-manager.name')">
<ElInput
v-model="form.name"
:placeholder="$t('file-manager.pleaseEnterName')"
autofocus
@keyup.enter="handleSubmit"
/>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="handleClose">
{{ $t('file-manager.cancel') }}
</ElButton>
<ElButton type="primary" :loading="loading" @click="handleSubmit">
{{ $t('file-manager.confirm') }}
</ElButton>
</div>
</template>
</ElDialog>
</template>
@@ -0,0 +1,129 @@
import type { SystemFileManagerApi } from '#/api/core/file';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { getFileList } from '#/api/core/file';
// 使用单例模式或者在顶层组件 provide
const currentFolderId = ref<null | string>(null);
const viewMode = ref<'grid' | 'list'>('grid');
const selectedFileIds = ref<Set<string>>(new Set());
const fileList = ref<SystemFileManagerApi.FileItem[]>([]);
const loading = ref(false);
const loadingMore = ref(false);
const breadcrumbs = ref<Array<{ id: null | string; name: string }>>([
{ id: null, name: $t('file-manager.myFiles') },
]);
const createFolderDialogVisible = ref(false);
// 无限滚动分页状态
const currentPage = ref(1);
const pageSize = ref(50);
const total = ref(0);
const noMore = computed(() => fileList.value.length >= total.value);
export function useFileManager() {
const toggleViewMode = () => {
viewMode.value = viewMode.value === 'grid' ? 'list' : 'grid';
};
const fetchFiles = async () => {
currentPage.value = 1;
loading.value = true;
try {
const res = await getFileList({
parent_id: currentFolderId.value,
page: 1,
pageSize: pageSize.value,
});
fileList.value = res.items;
total.value = res.total;
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
const loadMore = async () => {
if (noMore.value || loadingMore.value || loading.value) return;
loadingMore.value = true;
currentPage.value++;
try {
const res = await getFileList({
parent_id: currentFolderId.value,
page: currentPage.value,
pageSize: pageSize.value,
});
fileList.value = [...fileList.value, ...res.items];
total.value = res.total;
} catch (error) {
console.error(error);
currentPage.value--;
} finally {
loadingMore.value = false;
}
};
const navigateToFolder = (folderId: null | string, folderName?: string) => {
currentFolderId.value = folderId;
selectedFileIds.value.clear();
// 更新面包屑 (这里只是简单的逻辑,实际可能需要根据树结构查找完整路径)
if (folderId === null) {
breadcrumbs.value = [{ id: null, name: $t('file-manager.myFiles') }];
} else if (folderName) {
// 如果是点击面包屑导航回去,需要截断
const index = breadcrumbs.value.findIndex((b) => b.id === folderId);
if (index === -1) {
// 进入新文件夹
breadcrumbs.value.push({ id: folderId, name: folderName });
} else {
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1);
}
}
// fetchFiles 由 FileList.vue 中的 watch(currentFolderId) 自动触发
};
const toggleSelection = (id: string, multi: boolean) => {
if (multi) {
if (selectedFileIds.value.has(id)) {
selectedFileIds.value.delete(id);
} else {
selectedFileIds.value.add(id);
}
} else {
selectedFileIds.value.clear();
selectedFileIds.value.add(id);
}
};
const clearSelection = () => {
selectedFileIds.value.clear();
};
const openCreateFolderDialog = () => {
createFolderDialogVisible.value = true;
};
return {
currentFolderId,
viewMode,
selectedFileIds,
fileList,
loading,
loadingMore,
noMore,
breadcrumbs,
createFolderDialogVisible,
toggleViewMode,
navigateToFolder,
toggleSelection,
clearSelection,
fetchFiles,
loadMore,
openCreateFolderDialog,
};
}
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { Page } from '@vben/common-ui';
import CreateFolderDialog from './components/CreateFolderDialog.vue';
import FileList from './components/FileList.vue';
import FileToolbar from './components/FileToolbar.vue';
</script>
<template>
<Page auto-content-height>
<div
class="bg-background flex h-full w-full flex-col overflow-hidden rounded-[10px]"
>
<FileToolbar />
<div class="flex-1 overflow-hidden">
<FileList />
</div>
<CreateFolderDialog />
</div>
</Page>
</template>
@@ -0,0 +1,242 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn, VxeTableGridOptions } from '#/adapter/vxe-table';
import type { LoginLog } from '#/api/core/login-log';
import { $t } from '@vben/locales';
/**
* 获取登录状态选项
*/
export function getStatusOptions() {
return [
{ type: 'danger', label: $t('loginLog.statusFailed'), value: 0 },
{ type: 'success', label: $t('loginLog.statusSuccess'), value: 1 },
];
}
/**
* 获取失败原因选项
*/
export function getFailureReasonOptions() {
return [
{ label: $t('loginLog.failureReasonUnknown'), value: 0 },
{ label: $t('loginLog.failureReasonUserNotExist'), value: 1 },
{ label: $t('loginLog.failureReasonPasswordError'), value: 2 },
{ label: $t('loginLog.failureReasonUserDisabled'), value: 3 },
{ label: $t('loginLog.failureReasonUserLocked'), value: 4 },
{ label: $t('loginLog.failureReasonUserInactive'), value: 5 },
{ label: $t('loginLog.failureReasonAccountAbnormal'), value: 6 },
{ label: $t('loginLog.failureReasonOther'), value: 7 },
];
}
/**
* 获取设备类型选项
*/
export function getDeviceTypeOptions() {
return [
{ label: $t('loginLog.deviceTypeDesktop'), value: 'desktop' },
{ label: $t('loginLog.deviceTypeMobile'), value: 'mobile' },
{ label: $t('loginLog.deviceTypeTablet'), value: 'tablet' },
{ label: $t('loginLog.deviceTypeOther'), value: 'other' },
];
}
/**
* 获取登录方式选项
*/
export function getLoginTypeOptions() {
return [
{ label: '密码登录', value: 'password', type: 'info' },
{ label: '验证码登录', value: 'code', type: 'info' },
{ label: '二维码登录', value: 'qrcode', type: 'info' },
{ label: 'Gitee', value: 'gitee', type: 'success' },
{ label: 'GitHub', value: 'github', type: 'success' },
{ label: 'QQ', value: 'qq', type: 'success' },
{ label: 'Google', value: 'google', type: 'success' },
{ label: '微信', value: 'wechat', type: 'success' },
{ label: '微软', value: 'microsoft', type: 'success' },
{ label: '钉钉', value: 'dingtalk', type: 'success' },
{ label: '飞书', value: 'feishu', type: 'success' },
];
}
/**
* 获取搜索表单的字段配置
*/
export function useSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'username',
label: $t('loginLog.username'),
componentProps: {
placeholder: $t('loginLog.searchPlaceholder', [
$t('loginLog.username'),
]),
},
},
{
component: 'Select',
fieldName: 'status',
label: $t('loginLog.status'),
componentProps: {
placeholder: $t('loginLog.selectStatus'),
options: getStatusOptions(),
clearable: true,
},
},
{
component: 'Select',
fieldName: 'login_type',
label: '登录方式',
componentProps: {
placeholder: '请选择登录方式',
options: getLoginTypeOptions(),
clearable: true,
},
},
];
}
/**
* 获取表格列配置
*/
export function useColumns(
onActionClick?: OnActionClickFn<LoginLog>,
): VxeTableGridOptions<LoginLog>['columns'] {
return [
{
type: 'checkbox',
minWidth: 60,
align: 'center',
fixed: 'left',
},
{
field: 'username',
title: $t('loginLog.username'),
minWidth: 120,
fixed: 'left',
},
{
field: 'status',
title: $t('loginLog.status'),
minWidth: 100,
cellRender: {
name: 'CellTag',
options: getStatusOptions(),
},
},
{
field: 'login_type',
title: '登录方式',
minWidth: 120,
cellRender: {
name: 'CellTag',
options: getLoginTypeOptions(),
},
},
{
field: 'login_ip',
title: $t('loginLog.loginIp'),
minWidth: 140,
},
{
field: 'ip_location',
title: $t('loginLog.ipLocation'),
minWidth: 150,
},
{
field: 'failure_reason',
title: $t('loginLog.failureReason'),
minWidth: 120,
cellRender: {
name: 'CellTag',
options: getFailureReasonOptions().map((opt) => ({
...opt,
type: 'danger',
})),
},
visible: false,
},
{
field: 'failure_message',
title: $t('loginLog.failureMessage'),
minWidth: 180,
showOverflow: 'tooltip',
visible: false,
},
{
field: 'browser_type',
title: $t('loginLog.browserType'),
minWidth: 120,
},
{
field: 'os_type',
title: $t('loginLog.osType'),
minWidth: 120,
},
{
field: 'device_type',
title: $t('loginLog.deviceType'),
minWidth: 100,
cellRender: {
name: 'CellTag',
options: getDeviceTypeOptions().map((opt) => ({
...opt,
type: 'info',
})),
},
},
{
field: 'duration',
title: $t('loginLog.durationSeconds'),
minWidth: 120,
visible: false,
},
{
field: 'remark',
title: $t('loginLog.remark'),
minWidth: 150,
showOverflow: 'tooltip',
visible: false,
},
{
field: 'sys_create_datetime',
title: $t('loginLog.loginTime'),
minWidth: 180,
sortable: true,
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'username',
nameTitle: $t('loginLog.username'),
onClick: onActionClick,
},
name: 'CellOperation',
options: [
{
code: 'detail',
text: $t('loginLog.detail'),
icon: 'ep:document',
},
{
code: 'delete',
text: $t('common.delete'),
icon: 'ep:delete',
},
],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('loginLog.operation'),
minWidth: 150,
},
];
}
@@ -0,0 +1,191 @@
<script lang="ts" setup>
import type {
OnActionClickParams,
VxeTableGridOptions,
} from '#/adapter/vxe-table';
import type { LoginLog } from '#/api/core/login-log';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
batchDeleteLoginLogApi,
deleteLoginLogApi,
getLoginLogDetailApi,
getLoginLogListApi,
} from '#/api/core/login-log';
import { useColumns, useSearchFormSchema } from './data';
import DetailDrawer from './modules/detail-drawer.vue';
defineOptions({ name: 'SystemLoginLog' });
const selectedRows = ref<LoginLog[]>([]);
const detailDrawerRef = ref();
const currentLog = ref<LoginLog>();
/**
* 查看详情
*/
async function onDetail(row: LoginLog) {
try {
const log = await getLoginLogDetailApi(row.id);
currentLog.value = log;
detailDrawerRef.value?.open();
} catch (error) {
ElMessage.error($t('loginLog.getDetailError'));
console.error($t('loginLog.getDetailError'), error);
}
}
/**
* 删除单条日志
*/
function onDelete(row: LoginLog) {
ElMessageBox.confirm(
$t('loginLog.deleteConfirm', [row.username]),
$t('common.delete'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
},
)
.then(async () => {
try {
await deleteLoginLogApi(row.id);
ElMessage.success($t('loginLog.deleteSuccess'));
refreshGrid();
} catch {
ElMessage.error($t('loginLog.deleteError'));
}
})
.catch(() => {
// 用户取消了操作
});
}
/**
* 批量删除日志
*/
function onBatchDelete() {
if (selectedRows.value.length === 0) {
ElMessage.warning($t('loginLog.selectLogsToDelete'));
return;
}
const usernames = selectedRows.value
.map((row: LoginLog) => row.username)
.join('、');
const confirmMessage = $t('loginLog.batchDeleteConfirm', [
selectedRows.value.length,
usernames,
]);
ElMessageBox.confirm(confirmMessage, $t('loginLog.batchDeleteTitle'), {
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
})
.then(async () => {
try {
const ids = selectedRows.value.map((row: LoginLog) => row.id);
await batchDeleteLoginLogApi(ids);
ElMessage.success($t('loginLog.deleteSuccess'));
selectedRows.value = [];
refreshGrid();
} catch {
ElMessage.error($t('loginLog.deleteError'));
}
})
.catch(() => {
// 用户取消了操作
});
}
/**
* 表格操作按钮的回调函数
*/
function onActionClick({ code, row }: OnActionClickParams<LoginLog>) {
switch (code) {
case 'delete': {
onDelete(row);
break;
}
case 'detail': {
onDetail(row);
break;
}
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useSearchFormSchema(),
submitOnChange: true,
},
gridEvents: {
checkboxAll: ({ records }: { records: LoginLog[] }) => {
selectedRows.value = records;
},
checkboxChange: ({ records }: { records: LoginLog[] }) => {
selectedRows.value = records;
},
},
gridOptions: {
columns: useColumns(onActionClick),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const params = {
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
};
return await getLoginLogListApi(params);
},
},
},
checkboxConfig: {
reserve: true,
trigger: 'default',
},
toolbarConfig: {
custom: true,
export: false,
refresh: { code: 'query' },
search: true,
zoom: true,
},
} as VxeTableGridOptions<LoginLog>,
});
/**
* 刷新表格
*/
function refreshGrid() {
gridApi.query();
}
</script>
<template>
<Page auto-content-height>
<DetailDrawer ref="detailDrawerRef" :log="currentLog" />
<Grid>
<template #table-title>
<ElButton type="danger" plain @click="onBatchDelete">
{{ $t('loginLog.batchDelete') }}
{{ selectedRows.length > 0 ? `(${selectedRows.length})` : '' }}
</ElButton>
</template>
</Grid>
</Page>
</template>
@@ -0,0 +1,180 @@
<script lang="ts" setup>
import type { LoginLog } from '#/api/core/login-log';
import { computed } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus';
import {
getDeviceTypeOptions,
getFailureReasonOptions,
getStatusOptions,
} from '../data';
interface Props {
log?: LoginLog;
}
const props = defineProps<Props>();
const [Drawer, drawerApi] = useVbenDrawer({
title: $t('loginLog.detailTitle'),
footer: false,
loading: false,
});
/**
* 获取登录状态显示
*/
const statusDisplay = computed(() => {
if (!props.log) return '';
const option = getStatusOptions().find(
(opt) => opt.value === props.log?.status,
);
return option?.label || '';
});
/**
* 获取登录状态类型
*/
const statusType = computed(() => {
if (!props.log) return 'info';
const option = getStatusOptions().find(
(opt) => opt.value === props.log?.status,
);
return option?.type || 'info';
});
/**
* 获取失败原因显示
*/
const failureReasonDisplay = computed(() => {
if (!props.log || props.log.failure_reason === undefined) return '';
const option = getFailureReasonOptions().find(
(opt) => opt.value === props.log?.failure_reason,
);
return option?.label || '';
});
/**
* 获取设备类型显示
*/
const deviceTypeDisplay = computed(() => {
if (!props.log) return '';
const option = getDeviceTypeOptions().find(
(opt) => opt.value === props.log?.device_type,
);
return option?.label || props.log.device_type || '';
});
/**
* 格式化时长
*/
function formatDuration(seconds?: number) {
if (!seconds) return $t('loginLog.formatZeroSeconds');
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const parts = [];
if (hours > 0) parts.push($t('loginLog.formatHours', [hours]));
if (minutes > 0) parts.push($t('loginLog.formatMinutes', [minutes]));
if (secs > 0) parts.push($t('loginLog.formatSeconds', [secs]));
return parts.join('');
}
defineExpose({
open: drawerApi.open,
close: drawerApi.close,
});
</script>
<template>
<Drawer>
<template v-if="log">
<ElDescriptions :column="1" border>
<ElDescriptionsItem :label="$t('loginLog.username')">
{{ log.username }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('loginLog.userId')" v-if="log.user_id">
{{ log.user_id }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('loginLog.status')">
<ElTag :type="statusType as any">{{ statusDisplay }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.failureReason')"
v-if="log.status === 0 && log.failure_reason !== undefined"
>
<ElTag type="danger">{{ failureReasonDisplay }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.failureMessage')"
v-if="log.status === 0 && log.failure_message"
>
{{ log.failure_message }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('loginLog.loginIp')">
{{ log.login_ip }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.ipLocation')"
v-if="log.ip_location"
>
{{ log.ip_location }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.browserType')"
v-if="log.browser_type"
>
{{ log.browser_type }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('loginLog.osType')" v-if="log.os_type">
{{ log.os_type }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.deviceType')"
v-if="log.device_type"
>
<ElTag type="info">{{ deviceTypeDisplay }}</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.duration')"
v-if="log.duration"
>
{{ formatDuration(log.duration) }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.sessionId')"
v-if="log.session_id"
>
{{ log.session_id }}
</ElDescriptionsItem>
<ElDescriptionsItem
:label="$t('loginLog.userAgent')"
v-if="log.user_agent"
>
<div class="max-w-full break-all text-sm">
{{ log.user_agent }}
</div>
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('loginLog.remark')" v-if="log.remark">
{{ log.remark }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('loginLog.loginTime')">
{{ log.sys_create_datetime }}
</ElDescriptionsItem>
</ElDescriptions>
</template>
<template v-else>
<div class="py-8 text-center text-gray-500">
{{ $t('loginLog.noData') }}
</div>
</template>
</Drawer>
</template>
@@ -20,6 +20,7 @@ import {
getAllMenuTreeApi,
} from '#/api/core/menu';
import { ZqDialog } from '#/components/zq-dialog';
import { filterLightMenuTree } from '#/router/light-menu';
import { useAppContextStore } from '#/store/app-context';
const emit = defineEmits<{
@@ -51,8 +52,8 @@ function processMenuData(menus: any[]): any[] {
async function getMenuListProcessed() {
const applicationId = appContextStore.currentApp?.id;
const data = await getAllMenuTreeApi(applicationId);
menuTreeData.value = data;
return processMenuData(data);
menuTreeData.value = filterLightMenuTree(data);
return processMenuData(menuTreeData.value);
}
/**
@@ -26,6 +26,7 @@ import {
getAllMenuTreeApi,
updateMenuApi,
} from '#/api/core/menu';
import { filterLightMenuTree } from '#/router/light-menu';
import { useAppContextStore } from '#/store/app-context';
import { getMenuTypeOptions } from '../data';
@@ -57,7 +58,7 @@ function processMenuData(menus: any[]): any[] {
async function getMenuListProcessed() {
const applicationId = appContextStore.currentApp?.id;
const data = await getAllMenuTreeApi(applicationId);
return processMenuData(data);
return processMenuData(filterLightMenuTree(data));
}
/**
@@ -25,6 +25,7 @@ import {
searchMenuApi,
} from '#/api/core/menu';
import { ZqTabs } from '#/components/zq-tabs';
import { filterLightMenuTree } from '#/router/light-menu';
import { useAppContextStore } from '#/store/app-context';
import MenuFormModal from './menu-form-modal.vue';
@@ -138,7 +139,9 @@ async function fetchMenuList(autoSelectFirst = false) {
// 获取完整的菜单树(全量加载),传入当前应用ID
const applicationId = appContextStore.currentApp?.id;
const data = await getAllMenuTreeApi(applicationId, false);
treeData.value = transformMenuTreeData(Array.isArray(data) ? data : []);
treeData.value = transformMenuTreeData(
filterLightMenuTree(Array.isArray(data) ? data : []),
);
// 如果需要自动选中第一个菜单
if (autoSelectFirst && treeData.value.length > 0) {
@@ -307,7 +310,9 @@ watch(searchKeyword, (newVal) => {
try {
const results = await searchMenuApi(newVal);
// 转换搜索结果数据结构
const transformedResults = transformMenuData(results || []);
const transformedResults = transformMenuData(
filterLightMenuTree(results || []),
);
searchResults.value = transformedResults;
// 自动展开搜索结果中的所有节点,显示完整路径
if (transformedResults && transformedResults.length > 0) {
@@ -1,28 +1,33 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ElEmpty, ElMessage, ElScrollbar } from 'element-plus';
import AiAgentHome from './AiAgentHome.vue';
import { getPageByCodeApi } from '#/api/online-dev/page-manager';
import DashboardRenderer from '#/components/dashboard-design/DashboardRenderer.vue';
defineOptions({ name: 'PageRender' });
const AI_AGENT_HOME_CODE = 'main_home';
const route = useRoute();
const loading = ref(false);
const pageConfig = ref<string>('');
const pageName = ref('');
const pageCode = ref('');
const isHomePage = computed(() => pageCode.value === AI_AGENT_HOME_CODE);
// 获取页面编码
function getPageCode(): string {
// 优先从 query 获取
if (route.query.pageCode) {
return route.query.pageCode as string;
}
// 其次从 params 获取
if (route.params.code) {
return route.params.code as string;
}
// 最后从路径中提取(路径格式:/page-render/xxx
const pathParts = route.path.split('/').filter(Boolean);
const length = pathParts.length;
if (length >= 2 && pathParts[length - 2] === 'page-render') {
@@ -32,6 +37,7 @@ function getPageCode(): string {
return '';
}
// 加载页面数据
async function loadPageData() {
const code = getPageCode();
if (!code) {
@@ -43,9 +49,12 @@ async function loadPageData() {
loading.value = true;
try {
if (!isHomePage.value) {
ElMessage.warning('轻量版已停用在线页面渲染');
}
const page = await getPageByCodeApi(code);
pageName.value = page.name;
pageConfig.value =
page.page_config && Object.keys(page.page_config).length > 0
? JSON.stringify(page.page_config)
: '';
} catch (error: any) {
ElMessage.error(error?.message || '加载页面失败');
} finally {
@@ -57,6 +66,7 @@ onMounted(() => {
loadPageData();
});
// 监听路由变化
watch(
() => route.fullPath,
() => {
@@ -67,16 +77,9 @@ watch(
<template>
<ElScrollbar class="rounded-[8px] py-3">
<div
v-loading="loading"
class="min-h-[calc(100vh-120px)] rounded-[8px] px-3"
>
<AiAgentHome v-if="isHomePage" />
<ElEmpty
v-else-if="!loading"
description="轻量版已移除在线页面渲染"
class="py-20"
/>
<div v-loading="loading" class="h-[calc(100vh-120px)] rounded-[8px] px-3">
<DashboardRenderer v-if="pageConfig" :config="pageConfig" />
<ElEmpty v-else-if="!loading" description="暂无页面配置" />
</div>
</ElScrollbar>
</template>
@@ -16,6 +16,7 @@ import {
} from 'element-plus';
import { getAllMenuTreeApi, searchMenuApi } from '#/api/core/menu';
import { filterLightMenuTree } from '#/router/light-menu';
import { useAppContextStore } from '#/store/app-context';
const emit = defineEmits<{
@@ -45,7 +46,7 @@ async function fetchMenuList() {
// 获取当前应用ID,子应用只显示子应用的菜单
const applicationId = appContextStore.currentApp?.id;
const data = await getAllMenuTreeApi(applicationId, false); // 不使用缓存,确保获取最新数据
treeData.value = Array.isArray(data) ? data : [];
treeData.value = filterLightMenuTree(Array.isArray(data) ? data : []);
// 自动展开第一级
if (treeData.value.length > 0) {
@@ -157,7 +158,7 @@ watch(searchKeyword, (newVal) => {
isSearching.value = true;
try {
const results = await searchMenuApi(newVal);
searchResults.value = results || [];
searchResults.value = filterLightMenuTree(results || []);
// 自动展开搜索结果中的所有节点,显示完整路径
if (searchResults.value && searchResults.value.length > 0) {
autoExpandSearchResults(searchResults.value);
@@ -0,0 +1,187 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { VbenFormSchema } from '#/adapter/form';
import type { OnActionClickFn } from '#/adapter/vxe-table';
import type { Post } from '#/api/core/post';
import { $t } from '@vben/locales';
import { z } from '#/adapter/form';
/**
* 获取搜索表单的字段配置
*/
export function useSearchFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('system.user.userName'),
},
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.account'),
},
];
}
/**
* 获取岗位类型选项
*/
export function getPostTypeOptions() {
return [
{ label: $t('post.types.management'), value: 0 },
{ label: $t('post.types.technical'), value: 1 },
{ label: $t('post.types.business'), value: 2 },
{ label: $t('post.types.functional'), value: 3 },
{ label: $t('post.types.other'), value: 4 },
];
}
/**
* 获取岗位级别选项
*/
export function getPostLevelOptions() {
return [
{ label: $t('post.levels.senior'), value: 0 },
{ label: $t('post.levels.middle'), value: 1 },
{ label: $t('post.levels.basic'), value: 2 },
{ label: $t('post.levels.staff'), value: 3 },
];
}
/**
* 获取岗位树列配置
*/
export function usePostTreeColumns(
onActionClick?: OnActionClickFn<Post>,
): VxeTableGridOptions<Post>['columns'] {
return [
{
field: 'name',
title: $t('post.postName'),
minWidth: 150,
},
];
}
export function useFormSchema(): VbenFormSchema[] {
return [
{
component: 'Input',
fieldName: 'name',
label: $t('post.postName'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('post.postName'), 2]))
.max(64, $t('ui.formRules.maxLength', [$t('post.postName'), 64])),
},
{
component: 'Input',
fieldName: 'code',
label: $t('post.postCode'),
rules: z
.string()
.min(2, $t('ui.formRules.minLength', [$t('post.postCode'), 2]))
.max(32, $t('ui.formRules.maxLength', [$t('post.postCode'), 32]))
.regex(/^[\w-]+$/, $t('post.codeFormatError')),
},
{
component: 'Select',
componentProps: {
options: getPostTypeOptions(),
},
defaultValue: 4,
fieldName: 'post_type',
label: $t('post.postType'),
},
{
component: 'Select',
componentProps: {
options: getPostLevelOptions(),
},
defaultValue: 3,
fieldName: 'post_level',
label: $t('post.postLevel'),
},
{
component: 'DeptSelector',
componentProps: {
placeholder: $t('post.selectDepartment'),
},
fieldName: 'dept_id',
label: $t('post.department'),
},
{
component: 'Textarea',
componentProps: {
placeholder: $t('post.descriptionPlaceholder'),
rows: 3,
},
fieldName: 'description',
label: $t('post.description'),
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: $t('common.enabled'), value: true },
{ label: $t('common.disabled'), value: false },
],
},
defaultValue: true,
fieldName: 'status',
label: $t('post.status'),
},
];
}
/**
* 获取用户表格列配置
*/
export function useUserColumns(
onActionClick?: OnActionClickFn<Post>,
): VxeTableGridOptions<Post>['columns'] {
return [
{
type: 'checkbox',
minWidth: 60,
align: 'center',
fixed: 'left',
},
{
field: 'username',
title: $t('system.user.account'),
minWidth: 120,
},
{
field: 'name',
title: $t('system.user.userName'),
minWidth: 120,
},
{
field: 'email',
title: $t('system.user.email'),
minWidth: 180,
},
{
align: 'right',
cellRender: {
attrs: {
nameField: 'name',
nameTitle: $t('system.user.userName'),
onClick: onActionClick,
},
name: 'CellOperation',
options: ['edit', 'delete'],
},
field: 'operation',
fixed: 'right',
headerAlign: 'center',
showOverflow: false,
title: $t('system.user.operation'),
minWidth: 150,
},
];
}
@@ -0,0 +1,160 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElButton, ElMessage, ElMessageBox } from 'element-plus';
import { addPostUsersApi, removePostUsersApi } from '#/api/core/post';
import { UserListPanel } from '#/components/user-list-panel';
import { UserSelector } from '#/components/zq-form/user-selector';
import PostList from './modules/post-list.vue';
defineOptions({ name: 'SystemPost' });
const currentPostId = ref<string>();
const tempSelectedUsers = ref<Set<string>>(new Set());
const userListPanelRef = ref<InstanceType<typeof UserListPanel>>();
/**
* 岗位选择事件
*/
function onPostSelect(postId: string | undefined) {
currentPostId.value = postId;
tempSelectedUsers.value.clear();
}
/**
* 处理用户选择
*/
function handleUserSelect(userId: string, _user: any) {
if (tempSelectedUsers.value.has(userId)) {
tempSelectedUsers.value.delete(userId);
} else {
tempSelectedUsers.value.add(userId);
}
}
/**
* 处理移除用户
*/
function handleRemoveUser(userId: string) {
tempSelectedUsers.value.delete(userId);
}
/**
* 新增用户到岗位(作为 UserSelector 的 onConfirm 回调)
*/
async function handleAddUsers(userIds: string | string[]) {
if (!currentPostId.value) {
ElMessage.warning($t('post.selectPostFirst') || '请先选择岗位');
throw new Error('请先选择岗位');
}
const userIdsArray = Array.isArray(userIds) ? userIds : [userIds];
if (userIdsArray.length === 0) {
ElMessage.warning($t('post.selectUsersFirst') || '请先选择用户');
throw new Error('请先选择用户');
}
await addPostUsersApi(currentPostId.value, {
user_ids: userIdsArray,
});
ElMessage.success($t('post.addUsersSuccess') || '添加成功');
// 刷新用户列表
userListPanelRef.value?.reload();
}
/**
* 从岗位删除用户
*/
async function handleRemoveUsers() {
if (!currentPostId.value) {
ElMessage.warning($t('post.selectPostFirst') || '请先选择岗位');
return;
}
if (tempSelectedUsers.value.size === 0) {
ElMessage.warning($t('post.selectUsersFirst') || '请先选择用户');
return;
}
const userIds = [...tempSelectedUsers.value];
const confirmMessage =
$t('post.removeUsersConfirm', [tempSelectedUsers.value.size]) ||
`确定要删除选中的 ${tempSelectedUsers.value.size} 个用户吗?`;
try {
await ElMessageBox.confirm(confirmMessage, $t('common.delete') || '删除', {
confirmButtonText: $t('common.confirm') || '确定',
cancelButtonText: $t('common.cancel') || '取消',
type: 'warning',
});
await removePostUsersApi(currentPostId.value, {
user_ids: userIds,
});
ElMessage.success($t('post.removeUsersSuccess') || '删除成功');
tempSelectedUsers.value.clear();
// 刷新用户列表
userListPanelRef.value?.reload();
} catch (error) {
if (error !== 'cancel') {
console.error('Failed to remove users:', error);
ElMessage.error($t('post.removeUsersFailed') || '删除失败');
}
}
}
</script>
<template>
<Page auto-content-height>
<div class="flex h-full">
<!-- 岗位列表 -->
<div class="mr-3 w-1/6">
<PostList @select="onPostSelect" />
</div>
<!-- 主内容区用户列表 -->
<div class="w-5/6">
<UserListPanel
ref="userListPanelRef"
:data-source="currentPostId ? 'post' : 'all'"
:source-id="currentPostId"
:temp-selected-users="tempSelectedUsers"
:filterable="true"
:multiple="true"
:selectable="true"
:show-selected-tags="false"
:show-border="false"
@user-select="handleUserSelect"
@remove-user="handleRemoveUser"
>
<template #title>
<div class="flex items-center gap-2">
<UserSelector
:multiple="true"
:disabled="!currentPostId"
display-mode="button"
:placeholder="$t('common.add') || '新增'"
:on-confirm="handleAddUsers"
/>
<ElButton
type="danger"
plain
:disabled="!currentPostId || tempSelectedUsers.size === 0"
@click="handleRemoveUsers"
>
{{ $t('common.delete') || '删除' }}
</ElButton>
</div>
</template>
</UserListPanel>
</div>
</div>
</Page>
</template>
@@ -0,0 +1,84 @@
<script lang="ts" setup>
import type { Post } from '#/api/core/post';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { createPostApi, updatePostApi } from '#/api/core/post';
import { ZqDialog } from '#/components/zq-dialog';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<Post>();
const visible = ref(false);
const confirmLoading = ref(false);
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', [$t('post.name')])
: $t('ui.actionTitle.create', [$t('post.name')]);
});
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: useFormSchema(),
showDefaultActions: false,
});
function resetForm() {
formApi.resetForm();
formApi.setValues(formData.value || {});
}
async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
confirmLoading.value = true;
const data = await formApi.getValues();
try {
await (formData.value?.id
? updatePostApi(formData.value.id, data)
: createPostApi(data));
visible.value = false;
emit('success');
} finally {
confirmLoading.value = false;
}
}
}
function open(data?: Post) {
visible.value = true;
if (data) {
formData.value = data;
formApi.setValues(formData.value);
} else {
formData.value = undefined;
formApi.resetForm();
}
}
defineExpose({
open,
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="getTitle"
:confirm-loading="confirmLoading"
@confirm="onSubmit"
>
<Form class="mx-4" />
<template #footer-left>
<ElButton type="primary" @click="resetForm">
{{ $t('common.reset') }}
</ElButton>
</template>
</ZqDialog>
</template>
@@ -0,0 +1,291 @@
<script lang="ts" setup>
import type { Post } from '#/api/core/post';
import type { CardListOptions } from '#/components/card-list';
import { onMounted, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElMessage,
ElMessageBox,
ElPopover,
ElTooltip,
} from 'element-plus';
import { deletePostApi, getPostListApi } from '#/api/core/post';
import { CardList } from '#/components/card-list';
import PostFormModal from './post-form-modal.vue';
const emit = defineEmits<{
select: [postId: string | undefined];
}>();
const postList = ref<Post[]>([]);
const loading = ref(false);
const selectedPostId = ref<string>();
const searchKeyword = ref<string>('');
const hoveredPostId = ref<string>();
const postFormModalRef = ref<InstanceType<typeof PostFormModal>>();
// 卡片列表配置
const cardListOptions: CardListOptions<Post> = {
searchFields: [{ field: 'name' }, { field: 'code' }],
titleField: 'name',
};
async function fetchPostList() {
try {
loading.value = true;
const response = await getPostListApi({ page: 1, pageSize: 100 });
postList.value = response.items || [];
// 自动选中第一个岗位
if (postList.value.length > 0 && !selectedPostId.value) {
const firstPost = postList.value.at(0);
if (firstPost) {
selectedPostId.value = firstPost.id;
emit('select', firstPost.id);
}
}
} finally {
loading.value = false;
}
}
/**
* 处理岗位选择
*/
function onPostSelect(postId: string | undefined) {
selectedPostId.value = postId;
emit('select', postId);
}
/**
* 打开添加岗位对话框
*/
function onAddPost() {
postFormModalRef.value?.open();
}
/**
* 打开编辑岗位对话框
*/
function onEditPost(post: Post, e?: Event) {
e?.stopPropagation();
postFormModalRef.value?.open(post);
}
/**
* 删除岗位
*/
async function onDeletePost(post: Post, e?: Event) {
e?.stopPropagation();
ElMessageBox.confirm(
$t('ui.actionMessage.deleteConfirm', [post.name]),
$t('common.delete'),
{
confirmButtonText: $t('common.confirm'),
cancelButtonText: $t('common.cancel'),
type: 'warning',
showClose: false,
},
)
.then(async () => {
try {
await deletePostApi(post.id);
ElMessage.success($t('ui.actionMessage.deleteSuccess', [post.name]));
// 如果删除的是当前选中的岗位,清除选中状态
if (selectedPostId.value === post.id) {
selectedPostId.value = undefined;
emit('select', undefined);
}
await fetchPostList();
} catch {
ElMessage.error($t('ui.actionMessage.deleteError'));
}
})
.catch(() => {
// 用户取消了操作
});
}
/**
* 添加岗位成功后的回调
*/
async function onPostFormSuccess() {
ElMessage.success($t('ui.actionMessage.createSuccess', [$t('post.name')]));
await fetchPostList();
}
onMounted(() => {
fetchPostList();
});
</script>
<template>
<CardList
:items="postList"
:loading="loading"
:selected-id="selectedPostId"
:hovered-id="hoveredPostId"
:search-keyword="searchKeyword"
:options="cardListOptions"
@select="onPostSelect"
@update:search-keyword="(v) => (searchKeyword = v)"
@update:hovered-id="(v) => (hoveredPostId = v)"
@add="onAddPost"
@edit="onEditPost"
@delete="onDeletePost"
>
<!-- 自定义项目渲染 -->
<template #item="{ item }">
<div class="truncate text-sm" :title="item.name">
{{ item.name }}
</div>
</template>
<!-- 详细信息 -->
<template #details="{ item }">
<div class="flex items-center gap-2 text-xs opacity-70">
<!-- 岗位编码 -->
<span class="truncate" :title="item.code">
{{ item.code }}
</span>
<!-- 分隔符 -->
<span class="text-gray-400">|</span>
<!-- 岗位类型 -->
<span v-if="item.post_type_display" class="flex-shrink-0">
{{ item.post_type_display }}
</span>
<!-- 部门 -->
<span
v-if="item.dept_name"
class="flex-1 truncate"
:title="item.dept_name"
>
{{ item.dept_name }}
</span>
</div>
</template>
<!-- 操作按钮 -->
<template #actions="{ item }">
<div class="flex flex-shrink-0" @click.stop>
<!-- 编辑按钮 -->
<ElTooltip :content="$t('post.edit')" placement="top">
<ElButton
type="primary"
text
size="small"
circle
@click="onEditPost(item, $event)"
>
<IconifyIcon icon="ep:edit" class="size-4" />
</ElButton>
</ElTooltip>
<!-- 删除按钮 -->
<ElButton
type="danger"
text
size="small"
circle
style="margin-left: 0"
:title="$t('common.delete')"
@click="onDeletePost(item, $event)"
>
<IconifyIcon icon="ep:delete" class="size-4" />
</ElButton>
<!-- 详情按钮 -->
<ElPopover placement="right" :width="300">
<template #reference>
<ElButton
type="info"
text
size="small"
style="margin-left: 0"
circle
>
<IconifyIcon icon="ep:info-filled" class="size-4" />
</ElButton>
</template>
<!-- Popover 内容:详细信息 -->
<div class="space-y-2 p-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">岗位名称:</span>
<span class="font-medium">{{ item.name || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">岗位编码:</span>
<span class="font-medium">{{ item.code || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">岗位类型:</span>
<span class="font-medium">{{
item.post_type_display || '-'
}}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">岗位级别:</span>
<span class="font-medium">{{
item.post_level_display || '-'
}}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">所属部门:</span>
<span class="font-medium">{{ item.dept_name || '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">状态:</span>
<span class="font-medium">{{
item.status ? '启用' : '禁用'
}}</span>
</div>
<div
v-if="item.description"
class="border-t border-gray-200 pt-2 dark:border-gray-700"
>
<span class="text-gray-600 dark:text-gray-400">描述:</span>
<div
class="mt-1 max-h-32 overflow-y-auto break-words rounded bg-gray-100 p-2 text-xs dark:bg-gray-800"
>
{{ item.description }}
</div>
</div>
</div>
</ElPopover>
</div>
</template>
<!-- Modal 组件 -->
<template #modal>
<PostFormModal ref="postFormModalRef" @success="onPostFormSuccess" />
</template>
</CardList>
</template>
<style scoped>
/* 输入框前置图标样式 */
:deep(.el-input__icon) {
cursor: pointer;
}
/* 文本按钮样式 */
:deep(.el-button--text) {
padding: 0 4px;
}
:deep(.el-popover__reference) {
padding: 0;
}
</style>
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { SystemPostApi } from '#/api/system/post';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { useVbenForm } from '#/adapter/form';
import { ZqDialog } from '#/components/zq-dialog';
const emit = defineEmits<{
success: [];
}>();
const userData = ref<SystemPostApi.SystemPost>();
const visible = ref(false);
const formSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'username',
label: $t('system.user.account'),
componentProps: {
disabled: true,
},
},
{
component: 'Input',
fieldName: 'name',
label: $t('system.user.userName'),
componentProps: {
disabled: true,
},
},
{
component: 'Input',
fieldName: 'email',
label: $t('system.user.email'),
componentProps: {
disabled: true,
},
},
];
const [Form, formApi] = useVbenForm({
layout: 'vertical',
schema: formSchema,
showDefaultActions: false,
});
function onConfirm() {
visible.value = false;
emit('success');
}
function open(data: SystemPostApi.SystemPost) {
visible.value = true;
userData.value = data;
formApi.setValues(userData.value);
}
defineExpose({
open,
});
const getModalTitle = computed(() =>
$t('ui.actionTitle.view', [$t('system.user.name')]),
);
</script>
<template>
<ZqDialog v-model="visible" :title="getModalTitle" @confirm="onConfirm">
<Form class="mx-4" />
</ZqDialog>
</template>
@@ -31,6 +31,7 @@ import {
getRoleMenusApi,
updateRoleMenusPermissionsApi,
} from '#/api/core/role';
import { filterLightMenuTree } from '#/router/light-menu';
import { useAppContextStore } from '#/store/app-context';
import FieldPermissionConfig from './field-permission-config.vue';
@@ -95,17 +96,6 @@ function filterTreeByApp(nodes: MenuNode[], appId?: string): MenuNode[] {
}));
}
function filterAiAgentAdminTree(nodes: MenuNode[]): MenuNode[] {
return nodes
.filter((node) => AI_AGENT_ADMIN_MENU_NAMES.has(node.name))
.map((node) => ({
...node,
children: node.children
? filterAiAgentAdminTree(node.children as MenuNode[])
: [],
}));
}
function collectMenuIds(nodes: MenuNode[], ids: Set<string>) {
nodes.forEach((node) => {
ids.add(node.id);
@@ -130,26 +120,6 @@ const appList = ref<ApplicationListItem[]>([]);
const selectedAppId = ref<string | undefined>(undefined);
const loadingApps = ref(false);
const AI_AGENT_ADMIN_MENU_NAMES = new Set([
'ControlCenter',
'AIPlatform',
'AIAgent',
'AIModelConfig',
'AIWorkflow',
'AIWorkflowRuns',
'KnowledgeBase',
'Codex',
'SystemManagement',
'SystemPermission',
'UserManagement',
'SystemMenu',
'SystemRole',
'Message',
'MessageList',
'AnnouncementList',
'AnnouncementManage',
]);
const AI_AGENT_ADMIN_APP_CODES = new Set(['ai_agent_admin']);
const resourceScopeConfigRef = ref<InstanceType<typeof ResourceScopeConfig>>();
@@ -267,7 +237,7 @@ async function loadMenuTree() {
const data = await getRoleMenusApi(props.role.id);
// 使用后端返回的菜单树结构
allTreeData.value = filterAiAgentAdminTree(data.menu_tree || []);
allTreeData.value = filterLightMenuTree(data.menu_tree || []);
// 初始化已选菜单
const selectedMenuIdsList = data.selected_menu_ids || [];
@@ -0,0 +1,294 @@
<script lang="ts" setup>
import type { CardListItem, CardListOptions } from '#/components/card-list';
import type { ZqTabItem } from '#/components/zq-tabs';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
BellRing,
Bot,
IconifyIcon,
Shield,
} from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElButton,
ElCard,
ElMessage,
ElMessageBox,
ElScrollbar,
} from 'element-plus';
import {
deleteGroupConfigApi,
getAllConfigsApi,
} from '#/api/core/system-config';
import { CardList } from '#/components/card-list';
import { ZqTabs } from '#/components/zq-tabs';
import ConfigForm from './modules/config-form.vue';
import ModelConfigForm from './modules/model-config-form.vue';
defineOptions({ name: 'SystemConfigManager' });
interface ConfigMenuItem extends CardListItem {
id: string;
name: string;
group: string;
icon: string;
category: 'notify' | 'oauth';
}
const SSO_GROUPS = [
'oauth_gitee',
'oauth_github',
'oauth_qq',
'oauth_google',
'oauth_wechat',
'oauth_microsoft',
];
const NOTIFY_GROUPS = [
'notify_email',
'notify_sms',
'notify_wechat_mp',
];
const GROUP_ICONS: Record<string, string> = {
oauth_gitee: 'simple-icons:gitee',
oauth_github: 'simple-icons:github',
oauth_qq: 'simple-icons:tencentqq',
oauth_google: 'simple-icons:google',
oauth_wechat: 'simple-icons:wechat',
oauth_microsoft: 'simple-icons:microsoft',
notify_email: 'mdi:email-outline',
notify_sms: 'mdi:message-text-outline',
notify_wechat_mp: 'simple-icons:wechat',
};
const activeTab = ref<string>('oauth');
const allConfigs = ref<Record<string, Record<string, any>>>({});
const selectedMenuId = ref<string>('oauth_gitee');
const loading = ref(false);
const saving = ref(false);
const configFormRef = ref<InstanceType<typeof ConfigForm>>();
const menuItems = computed<ConfigMenuItem[]>(() => {
const items: ConfigMenuItem[] = [];
for (const group of SSO_GROUPS) {
items.push({
id: group,
name: $t(`system-config.groups.${group}`),
group,
icon: GROUP_ICONS[group] || 'mdi:key',
category: 'oauth',
});
}
for (const group of NOTIFY_GROUPS) {
items.push({
id: group,
name: $t(`system-config.groups.${group}`),
group,
icon: GROUP_ICONS[group] || 'mdi:bell-ring',
category: 'notify',
});
}
return items;
});
const currentItems = computed(() =>
menuItems.value.filter((i) => i.category === activeTab.value),
);
const selectedItem = computed(() =>
menuItems.value.find((i) => i.id === selectedMenuId.value),
);
const modelConfigFormRef = ref<InstanceType<typeof ModelConfigForm>>();
const tabItems = computed<ZqTabItem[]>(() => [
{ key: 'oauth', label: $t('system-config.ssoConfig'), icon: Shield },
{ key: 'notify', label: $t('system-config.notifyConfig'), icon: BellRing },
{ key: 'model', label: $t('system-config.modelConfig'), icon: Bot },
]);
const cardListOptions: CardListOptions<ConfigMenuItem> = {
searchFields: [{ field: 'name' }],
titleField: 'name',
displayMode: 'center',
};
async function loadAllConfigs() {
loading.value = true;
try {
allConfigs.value = await getAllConfigsApi();
} catch {
// ignore
} finally {
loading.value = false;
}
}
function handleMenuSelect(id: string | undefined) {
if (id) {
selectedMenuId.value = id;
}
}
function handleTabChange(tab: string) {
activeTab.value = tab;
const items = menuItems.value.filter((i) => i.category === tab);
if (items.length > 0 && !items.some((i) => i.id === selectedMenuId.value)) {
selectedMenuId.value = items[0]!.id;
}
}
async function handleSave() {
saving.value = true;
try {
await configFormRef.value?.save();
} finally {
saving.value = false;
}
}
async function handleReset() {
try {
await ElMessageBox.confirm(
$t('system-config.resetConfirm'),
$t('system-config.reset'),
{ type: 'warning' },
);
await deleteGroupConfigApi(selectedMenuId.value);
ElMessage.success($t('system-config.resetSuccess'));
await loadAllConfigs();
} catch {
// cancelled
}
}
function handleSaved() {
loadAllConfigs();
}
onMounted(() => {
loadAllConfigs();
});
</script>
<template>
<Page auto-content-height>
<div class="flex h-full">
<!-- 左侧竖向 Tab -->
<div class="bg-background mr-3 flex-shrink-0 rounded-[8px] p-4">
<ZqTabs
v-model="activeTab"
:items="tabItems"
vertical
@change="handleTabChange"
/>
</div>
<!-- 列表oauth/notify tab 显示 -->
<template v-if="activeTab !== 'model'">
<div class="mr-3 w-[250px] flex-shrink-0">
<ElCard shadow="never" class="h-full !border-none">
<ElScrollbar>
<CardList
:items="currentItems"
:selected-id="selectedMenuId"
:options="cardListOptions"
:loading="false"
class="config-menu"
@select="handleMenuSelect"
>
<template #item="{ item }">
<div class="flex items-center gap-2 text-sm">
<IconifyIcon :icon="item.icon" class="size-4 opacity-60" />
{{ item.name }}
</div>
</template>
</CardList>
</ElScrollbar>
</ElCard>
</div>
<!-- 右侧内容 -->
<div class="flex-1 overflow-hidden">
<ElCard
shadow="never"
class="config-card flex h-full flex-col !border-none"
>
<template #header>
<div class="card-header">
<IconifyIcon
:icon="selectedItem?.icon || ''"
class="mr-2 size-5 opacity-60"
/>
<span>{{ selectedItem?.name }}</span>
<div class="ml-auto flex gap-2">
<ElButton @click="handleReset">
{{ $t('system-config.reset') }}
</ElButton>
<ElButton
type="primary"
:loading="saving"
@click="handleSave"
>
{{ $t('system-config.save') }}
</ElButton>
</div>
</div>
</template>
<ElScrollbar class="flex-1">
<ConfigForm
ref="configFormRef"
:group="selectedMenuId"
:config-data="allConfigs[selectedMenuId] || {}"
:loading="loading"
@saved="handleSaved"
/>
</ElScrollbar>
</ElCard>
</div>
</template>
<!-- 模型配置model tab -->
<div v-else class="flex-1 overflow-hidden">
<ModelConfigForm ref="modelConfigFormRef" />
</div>
</div>
</Page>
</template>
<style scoped>
.config-menu :deep(.el-card__body) {
padding: 0;
}
.config-menu :deep(.mb-4.flex) {
display: none;
}
.config-card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,206 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { EyeOff, Info } from '@vben/icons';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElMessage,
ElOption,
ElSelect,
ElSwitch,
ElTooltip,
} from 'element-plus';
import { updateGroupConfigApi } from '#/api/core/system-config';
defineOptions({ name: 'SystemConfigForm' });
const props = defineProps<{
configData: Record<string, any>;
group: string;
loading: boolean;
}>();
const emit = defineEmits<{
saved: [];
}>();
const SECRET_KEYS = new Set([
'aliyun_access_key_secret',
'app_key',
'app_secret',
'client_secret',
'smtp_password',
'tencent_secret_key',
'webhook_secret',
]);
const BOOLEAN_KEYS = new Set(['smtp_use_tls']);
const SELECT_OPTIONS: Record<
string,
Array<{ label: string; value: string }>
> = {
provider: [
{ label: $t('system-config.fields.providerAliyun'), value: 'aliyun' },
{ label: $t('system-config.fields.providerTencent'), value: 'tencent' },
],
};
const formData = ref<Record<string, any>>({});
const saving = ref(false);
const fields = computed(() => {
return Object.keys(props.configData);
});
const visibleFields = computed(() => {
if (props.group !== 'notify_sms') return fields.value;
const provider = formData.value.provider || '';
const hidePrefix =
provider === 'aliyun'
? 'tencent_'
: (provider === 'tencent'
? 'aliyun_'
: '');
if (!hidePrefix) return fields.value;
return fields.value.filter((key) => !key.startsWith(hidePrefix));
});
function isSelect(key: string): boolean {
return key in SELECT_OPTIONS;
}
function isSecret(key: string): boolean {
return SECRET_KEYS.has(key);
}
function isBoolean(key: string): boolean {
return BOOLEAN_KEYS.has(key);
}
function isMasked(value: any): boolean {
return typeof value === 'string' && value.includes('***');
}
function getFieldLabel(key: string): string {
return $t(`system-config.fields.${key}`) || key;
}
function loadFormData() {
const data: Record<string, any> = {};
for (const key of Object.keys(props.configData)) {
const val = props.configData[key];
data[key] = isBoolean(key) ? val === 'true' || val === true : (val ?? '');
}
formData.value = data;
}
watch(
() => [props.group, props.configData],
() => {
loadFormData();
},
{ immediate: true, deep: true },
);
async function save() {
saving.value = true;
try {
const configs: Record<string, null | string> = {};
for (const key of fields.value) {
const val = formData.value[key];
if (isBoolean(key)) {
configs[key] = String(val);
} else if (isSecret(key) && isMasked(val)) {
configs[key] = val;
} else {
configs[key] = val === '' ? null : String(val);
}
}
await updateGroupConfigApi(props.group, { configs });
ElMessage.success($t('system-config.saveSuccess'));
emit('saved');
} catch {
ElMessage.error($t('system-config.saveError'));
} finally {
saving.value = false;
}
}
defineExpose({ save });
</script>
<template>
<div v-loading="loading" class="p-6">
<ElForm label-position="top" class="max-w-[600px]">
<template v-for="key in visibleFields" :key="key">
<!-- Select field -->
<ElFormItem v-if="isSelect(key)" :label="getFieldLabel(key)">
<ElSelect
v-model="formData[key]"
:placeholder="getFieldLabel(key)"
clearable
>
<ElOption
v-for="opt in SELECT_OPTIONS[key]"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<!-- Boolean field -->
<ElFormItem v-else-if="isBoolean(key)" :label="getFieldLabel(key)">
<ElSwitch v-model="formData[key]" />
</ElFormItem>
<!-- Secret field -->
<ElFormItem v-else-if="isSecret(key)" :label="getFieldLabel(key)">
<ElInput
v-model="formData[key]"
show-password
:placeholder="getFieldLabel(key)"
clearable
>
<template #suffix>
<ElTooltip
v-if="isMasked(formData[key])"
:content="$t('system-config.secretTip')"
placement="top"
>
<EyeOff
class="size-4 cursor-help"
style="color: var(--el-text-color-placeholder)"
/>
</ElTooltip>
</template>
</ElInput>
</ElFormItem>
<!-- Normal field -->
<ElFormItem v-else :label="getFieldLabel(key)">
<ElInput
v-model="formData[key]"
:placeholder="getFieldLabel(key)"
clearable
/>
</ElFormItem>
</template>
<div
v-if="fields.length === 0"
class="flex items-center gap-2 py-8"
style="color: var(--el-text-color-secondary)"
>
<Info class="size-4" />
<span>{{ $t('common.noData') || 'No data' }}</span>
</div>
</ElForm>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,219 @@
<script lang="ts" setup>
import type { Component } from 'vue';
import type { CardListItem, CardListOptions } from '#/components/card-list';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { LogIn, Palette, Settings } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElCard, ElScrollbar, ElTooltip } from 'element-plus';
import { CardList } from '#/components/card-list';
import AppSettingsForm from './modules/app-settings-form.vue';
import LoginConfigForm from './modules/login-config-form.vue';
import UIPreferencesForm from './modules/ui-preferences-form.vue';
defineOptions({ name: 'UIConfigManager' });
interface SettingMenuItem extends CardListItem {
id: string;
name: string;
key: 'app' | 'login' | 'ui';
icon: Component;
}
const menuItems = ref<SettingMenuItem[]>([
{
id: 'app',
name: $t('ui-config.appConfig'),
key: 'app',
icon: Settings,
},
{
id: 'ui',
name: $t('ui-config.styleConfig'),
key: 'ui',
icon: Palette,
},
{
id: 'login',
name: $t('ui-config.loginConfig.title'),
key: 'login',
icon: LogIn,
},
]);
const selectedMenuId = ref<string>('app');
const uiPreferencesFormRef = ref<InstanceType<typeof UIPreferencesForm>>();
const appSettingsFormRef = ref<InstanceType<typeof AppSettingsForm>>();
const loginConfigFormRef = ref<InstanceType<typeof LoginConfigForm>>();
const saving = ref(false);
const cardListOptions: CardListOptions<SettingMenuItem> = {
searchFields: [{ field: 'name' }],
titleField: 'name',
displayMode: 'center',
};
function handleMenuSelect(id: string | undefined) {
selectedMenuId.value = id || 'ui';
}
function getMenuTitle(key: string): string {
const item = menuItems.value.find((m) => m.id === key);
return item?.name || '';
}
function getMenuIcon(key: string): Component | undefined {
const item = menuItems.value.find((m) => m.id === key);
return item?.icon;
}
function handleReset() {
switch (selectedMenuId.value) {
case 'app': {
appSettingsFormRef.value?.reset();
break;
}
case 'ui': {
uiPreferencesFormRef.value?.reset();
break;
}
case 'login': {
loginConfigFormRef.value?.reset();
break;
}
}
}
async function handleSave() {
saving.value = true;
try {
switch (selectedMenuId.value) {
case 'app': {
await appSettingsFormRef.value?.save();
break;
}
case 'ui': {
await uiPreferencesFormRef.value?.save();
break;
}
case 'login': {
await loginConfigFormRef.value?.save();
break;
}
}
} finally {
saving.value = false;
}
}
</script>
<template>
<Page auto-content-height>
<div class="flex h-full">
<!-- 左侧菜单 -->
<div class="mr-3 w-[235px]">
<CardList
:items="menuItems"
:selected-id="selectedMenuId"
:options="cardListOptions"
:loading="false"
class="ui-config-menu"
@select="handleMenuSelect"
>
<template #item="{ item }">
<div class="flex items-center gap-2 text-sm font-medium">
<component :is="item.icon" class="size-4" />
{{ item.name }}
</div>
</template>
</CardList>
</div>
<!-- 右侧内容 -->
<div class="flex-1 overflow-hidden">
<ElCard shadow="never" class="ui-config-card flex h-full flex-col">
<template #header>
<div class="card-header">
<component
:is="getMenuIcon(selectedMenuId)"
class="mr-2 size-5"
/>
<span>{{ getMenuTitle(selectedMenuId) }}</span>
<div class="ml-auto flex gap-2">
<ElTooltip
:content="$t('preferences.resetTip')"
placement="bottom"
>
<ElButton @click="handleReset">
{{ $t('preferences.resetTitle') }}
</ElButton>
</ElTooltip>
<ElButton type="primary" :loading="saving" @click="handleSave">
{{ $t('ui-config.save') }}
</ElButton>
</div>
</div>
</template>
<ElScrollbar class="flex-1">
<!-- UI配置 -->
<template v-if="selectedMenuId === 'ui'">
<UIPreferencesForm ref="uiPreferencesFormRef" />
</template>
<!-- 应用配置 -->
<template v-else-if="selectedMenuId === 'app'">
<AppSettingsForm ref="appSettingsFormRef" />
</template>
<!-- 登录配置 -->
<template v-else-if="selectedMenuId === 'login'">
<LoginConfigForm ref="loginConfigFormRef" />
</template>
</ElScrollbar>
</ElCard>
</div>
</div>
</Page>
</template>
<style scoped>
.ui-config-menu :deep(.el-card__body) {
padding: 16px;
}
.ui-config-menu :deep(.mb-4.flex) {
display: none;
}
.ui-config-menu :deep(.el-form-item__label) {
font-weight: 500;
}
.ui-config-card {
border: none;
}
.ui-config-card :deep(.el-card__body) {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.card-header {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,619 @@
<script lang="ts" setup>
import type { LocaleType } from '@vben/locales';
import type { PreferencesConfig } from '#/api/core/ui-config';
import { onMounted, ref, watch } from 'vue';
import {
Block,
Copyright,
Footer,
General,
GlobalShortcutKeys,
InputItem,
SwitchItem,
} from '@vben/layouts/preferences-blocks';
import { $t, getSystemLanguage, loadLocaleMessages } from '@vben/locales';
import { preferences, updatePreferences } from '@vben/preferences';
import {
ElButton,
ElDivider,
ElInput,
ElMessage,
ElOption,
ElSelect,
} from 'element-plus';
import { uploadFile } from '#/api/core/file';
import {
getPreferencesConfigApi,
mergePreferencesConfig,
updatePreferencesConfigApi,
} from '#/api/core/ui-config';
import { getFileUrlPublic } from '#/composables/useFileUrl';
import { overridesPreferences } from '#/preferences';
import { useAppContextStore } from '#/store/app-context';
defineOptions({ name: 'AppSettingsForm' });
const appContextStore = useAppContextStore();
// 获取当前应用ID(子应用模式下返回应用ID,主应用模式下返回 undefined
function getCurrentApplicationId(): string | undefined {
return appContextStore.currentApp?.id;
}
const loading = ref(false);
const appName = ref('');
const appLocale = ref<LocaleType>('zh-CN');
const appDynamicTitle = ref(true);
const appWatermark = ref(false);
const appWatermarkContent = ref('');
const appEnableCheckUpdates = ref(true);
const appDefaultHomePath = ref('/analytics');
const appEnablePreferences = ref(true);
const footerEnable = ref(true);
const footerFixed = ref(true);
const copyrightEnable = ref(true);
const copyrightCompanyName = ref('');
const copyrightCompanySiteLink = ref('');
const copyrightDate = ref('');
const copyrightIcp = ref('');
const copyrightIcpLink = ref('');
const copyrightPoliceIcp = ref('');
const copyrightPoliceIcpLink = ref('');
const copyrightLoginOnly = ref(true);
const logoEnable = ref(true);
const logoSource = ref('');
const logoFit = ref('contain');
const shortcutKeysEnable = ref(true);
const shortcutKeysGlobalSearch = ref(true);
const shortcutKeysGlobalLogout = ref(true);
const shortcutKeysGlobalLockScreen = ref(true);
const fitOptions = [
{ label: $t('ui-config.logo.fitOptions.contain'), value: 'contain' },
{ label: $t('ui-config.logo.fitOptions.cover'), value: 'cover' },
{ label: $t('ui-config.logo.fitOptions.fill'), value: 'fill' },
{ label: $t('ui-config.logo.fitOptions.none'), value: 'none' },
{ label: $t('ui-config.logo.fitOptions.scale-down'), value: 'scale-down' },
];
function getDefaultConfig() {
const overrides = overridesPreferences as Record<string, any>;
return {
app: {
name: preferences.app.name || overrides.app?.name || '',
locale: overrides.app?.locale || preferences.app.locale || 'zh-CN',
dynamicTitle:
overrides.app?.dynamicTitle ?? preferences.app.dynamicTitle ?? true,
watermark: overrides.app?.watermark ?? preferences.app.watermark ?? false,
watermarkContent:
overrides.app?.watermarkContent ||
preferences.app.watermarkContent ||
'',
enableCheckUpdates:
overrides.app?.enableCheckUpdates ??
preferences.app.enableCheckUpdates ??
true,
defaultHomePath:
overrides.app?.defaultHomePath ||
preferences.app.defaultHomePath ||
'/page-render/main_home',
enablePreferences: overrides.app?.enablePreferences ?? true,
},
footer: {
enable: overrides.footer?.enable ?? preferences.footer.enable ?? true,
fixed: overrides.footer?.fixed ?? preferences.footer.fixed ?? true,
},
copyright: {
enable:
overrides.copyright?.enable ?? preferences.copyright.enable ?? true,
companyName:
overrides.copyright?.companyName ||
preferences.copyright.companyName ||
'',
companySiteLink:
overrides.copyright?.companySiteLink ||
preferences.copyright.companySiteLink ||
'',
date: overrides.copyright?.date || preferences.copyright.date || '',
icp: overrides.copyright?.icp || preferences.copyright.icp || '',
icpLink:
overrides.copyright?.icpLink || preferences.copyright.icpLink || '',
policeIcp:
overrides.copyright?.policeIcp || preferences.copyright.policeIcp || '',
policeIcpLink:
overrides.copyright?.policeIcpLink || preferences.copyright.policeIcpLink || '',
loginOnly:
overrides.copyright?.loginOnly ?? preferences.copyright.loginOnly ?? true,
},
logo: {
enable: overrides.logo?.enable ?? preferences.logo.enable ?? true,
source: overrides.logo?.source || preferences.logo.source || '',
fit: overrides.logo?.fit || preferences.logo.fit || 'contain',
},
shortcutKeys: {
enable:
overrides.shortcutKeys?.enable ??
preferences.shortcutKeys.enable ??
true,
globalSearch:
overrides.shortcutKeys?.globalSearch ??
preferences.shortcutKeys.globalSearch ??
true,
globalLogout:
overrides.shortcutKeys?.globalLogout ??
preferences.shortcutKeys.globalLogout ??
true,
globalLockScreen:
overrides.shortcutKeys?.globalLockScreen ??
preferences.shortcutKeys.globalLockScreen ??
true,
},
};
}
async function loadConfig() {
loading.value = true;
try {
const applicationId = getCurrentApplicationId();
const data = await getPreferencesConfigApi(applicationId);
const defaults = getDefaultConfig();
appName.value = data?.app?.name || defaults.app.name;
appLocale.value = (data?.app?.locale || defaults.app.locale) as LocaleType;
appDynamicTitle.value =
data?.app?.dynamicTitle ?? defaults.app.dynamicTitle;
appWatermark.value = data?.app?.watermark ?? defaults.app.watermark;
appWatermarkContent.value =
data?.app?.watermarkContent || defaults.app.watermarkContent;
appEnableCheckUpdates.value =
data?.app?.enableCheckUpdates ?? defaults.app.enableCheckUpdates;
appDefaultHomePath.value =
data?.app?.defaultHomePath || defaults.app.defaultHomePath;
appEnablePreferences.value =
data?.app?.enablePreferences ?? defaults.app.enablePreferences;
footerEnable.value = data?.footer?.enable ?? defaults.footer.enable;
footerFixed.value = data?.footer?.fixed ?? defaults.footer.fixed;
copyrightEnable.value =
data?.copyright?.enable ?? defaults.copyright.enable;
copyrightCompanyName.value =
data?.copyright?.companyName || defaults.copyright.companyName;
copyrightCompanySiteLink.value =
data?.copyright?.companySiteLink || defaults.copyright.companySiteLink;
copyrightDate.value = data?.copyright?.date || defaults.copyright.date;
copyrightIcp.value = data?.copyright?.icp || defaults.copyright.icp;
copyrightIcpLink.value =
data?.copyright?.icpLink || defaults.copyright.icpLink;
copyrightPoliceIcp.value =
data?.copyright?.policeIcp || defaults.copyright.policeIcp;
copyrightPoliceIcpLink.value =
data?.copyright?.policeIcpLink || defaults.copyright.policeIcpLink;
copyrightLoginOnly.value =
data?.copyright?.loginOnly ?? defaults.copyright.loginOnly;
logoEnable.value = data?.logo?.enable ?? defaults.logo.enable;
logoSource.value = data?.logo?.source || defaults.logo.source;
logoFit.value = data?.logo?.fit || defaults.logo.fit;
shortcutKeysEnable.value =
data?.shortcutKeys?.enable ?? defaults.shortcutKeys.enable;
shortcutKeysGlobalSearch.value =
data?.shortcutKeys?.globalSearch ?? defaults.shortcutKeys.globalSearch;
shortcutKeysGlobalLogout.value =
data?.shortcutKeys?.globalLogout ?? defaults.shortcutKeys.globalLogout;
shortcutKeysGlobalLockScreen.value =
data?.shortcutKeys?.globalLockScreen ??
defaults.shortcutKeys.globalLockScreen;
} catch {
const defaults = getDefaultConfig();
appName.value = defaults.app.name;
appLocale.value = defaults.app.locale as LocaleType;
logoEnable.value = defaults.logo.enable;
logoSource.value = defaults.logo.source;
logoFit.value = defaults.logo.fit;
} finally {
loading.value = false;
}
}
// 获取带子应用前缀的首页路径
function getDefaultHomePathWithPrefix(): string {
const path = appDefaultHomePath.value || '/analytics';
const appCode = appContextStore.appCode;
// 如果是子应用模式且路径不包含子应用前缀,自动添加
if (appCode && !path.startsWith(`/app/${appCode}`)) {
return `/app/${appCode}${path.startsWith('/') ? path : `/${path}`}`;
}
return path;
}
function buildConfig(): PreferencesConfig {
return {
app: {
name: appName.value,
locale: appLocale.value,
dynamicTitle: appDynamicTitle.value,
watermark: appWatermark.value,
watermarkContent: appWatermarkContent.value,
enableCheckUpdates: appEnableCheckUpdates.value,
defaultHomePath: getDefaultHomePathWithPrefix(),
enablePreferences: appEnablePreferences.value,
},
footer: {
enable: footerEnable.value,
fixed: footerFixed.value,
},
copyright: {
enable: copyrightEnable.value,
companyName: copyrightCompanyName.value,
companySiteLink: copyrightCompanySiteLink.value,
date: copyrightDate.value,
icp: copyrightIcp.value,
icpLink: copyrightIcpLink.value,
policeIcp: copyrightPoliceIcp.value,
policeIcpLink: copyrightPoliceIcpLink.value,
loginOnly: copyrightLoginOnly.value,
},
logo: {
enable: logoEnable.value,
source: logoSource.value,
fit: logoFit.value,
},
shortcutKeys: {
enable: shortcutKeysEnable.value,
globalSearch: shortcutKeysGlobalSearch.value,
globalLogout: shortcutKeysGlobalLogout.value,
globalLockScreen: shortcutKeysGlobalLockScreen.value,
},
};
}
function applyPreferences() {
const config = buildConfig();
updatePreferences({
app: config.app,
footer: config.footer,
copyright: config.copyright,
logo: config.logo,
shortcutKeys: config.shortcutKeys,
});
}
async function save() {
if (loading.value) return;
try {
applyPreferences();
if (appLocale.value !== preferences.app.locale) {
// 如果是跟随系统,获取系统语言
const actualLocale =
appLocale.value === 'auto' ? getSystemLanguage() : appLocale.value;
await loadLocaleMessages(actualLocale);
}
const applicationId = getCurrentApplicationId();
const currentConfig = (await getPreferencesConfigApi(applicationId)) ?? {};
const newConfig = mergePreferencesConfig(currentConfig, buildConfig());
await updatePreferencesConfigApi(newConfig, applicationId);
ElMessage.success($t('ui-config.saveSuccess'));
} catch {
ElMessage.error($t('ui-config.saveError'));
}
}
function reset() {
loadConfig();
}
defineExpose({ reset, save });
function isFileId(value: string): boolean {
// 支持多种ID格式:UUID、纯数字、nanoid(字母数字混合,通常21位)
return (
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value) ||
/^\d+$/.test(value) ||
/^[\w-]{10,30}$/.test(value) // nanoid格式:字母数字下划线横杠,10-30位
);
}
// Logo显示URL(响应式)
const logoDisplayUrl = ref('');
// 加载Logo URL(Logo是公开文件,无需认证)
function loadLogoDisplayUrl(source: string) {
if (!source) {
logoDisplayUrl.value = '';
return;
}
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('/') ||
source.startsWith('data:')
) {
logoDisplayUrl.value = source;
return;
}
if (isFileId(source)) {
// Logo是公开文件,直接使用公开URL,无需临时token
logoDisplayUrl.value = getFileUrlPublic(source);
return;
}
logoDisplayUrl.value = source;
}
// 监听logoSource变化,加载显示URL
watch(
logoSource,
(newSource) => {
loadLogoDisplayUrl(newSource);
},
{ immediate: true },
);
const uploadInputRef = ref<HTMLInputElement>();
function openFileSelector() {
uploadInputRef.value?.click();
}
async function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
// Logo上传时设置为公开,这样无需认证即可访问
const response = await uploadFile(file, {
isPublic: true,
source: 'avatar',
});
if (response && response.id) {
// 保存公开文件的完整URL路径,这样layout和auth页面可以直接使用
logoSource.value = getFileUrlPublic(String(response.id));
}
} catch {
ElMessage.error($t('ui-config.loadError'));
}
target.value = '';
}
function clearLogo() {
logoSource.value = '';
}
onMounted(() => {
loadConfig();
});
</script>
<template>
<div v-loading="loading" class="grid grid-cols-3 gap-6 p-4">
<!-- 第一列应用配置 -->
<div class="app-column">
<Block :title="$t('ui-config.app.title')">
<InputItem
v-model="appName"
:placeholder="$t('ui-config.app.namePlaceholder')"
>
{{ $t('ui-config.app.name') }}
</InputItem>
<InputItem
v-model="appDefaultHomePath"
:placeholder="$t('ui-config.app.defaultHomePathPlaceholder')"
>
{{ $t('ui-config.app.defaultHomePath') }}
</InputItem>
<SwitchItem v-model="appEnablePreferences">
{{ $t('ui-config.app.enablePreferences') }}
</SwitchItem>
</Block>
<Block :title="$t('preferences.footer.title')">
<Footer
v-model:footer-enable="footerEnable"
v-model:footer-fixed="footerFixed"
/>
</Block>
<Block :title="$t('preferences.copyright.title')">
<Copyright
v-model:copyright-enable="copyrightEnable"
v-model:copyright-login-only="copyrightLoginOnly"
v-model:copyright-company-name="copyrightCompanyName"
v-model:copyright-company-site-link="copyrightCompanySiteLink"
v-model:copyright-date="copyrightDate"
v-model:copyright-icp="copyrightIcp"
v-model:copyright-icp-link="copyrightIcpLink"
v-model:copyright-police-icp="copyrightPoliceIcp"
v-model:copyright-police-icp-link="copyrightPoliceIcpLink"
:disabled="false"
/>
</Block>
</div>
<!-- 第二列Logo配置 -->
<div class="logo-column">
<Block :title="$t('ui-config.logoConfig')">
<SwitchItem v-model="logoEnable">
{{ $t('ui-config.logo.enable') }}
</SwitchItem>
<template v-if="logoEnable">
<ElDivider />
<div class="mb-4">
<div class="text-muted-foreground mb-2 text-sm">
{{ $t('ui-config.logo.source') }}
</div>
<div class="flex flex-col gap-4">
<!-- Logo 预览和上传 -->
<div class="logo-preview-container flex">
<div class="flex items-center gap-4">
<div>
<div
v-if="logoDisplayUrl"
class="logo-preview"
@click="openFileSelector"
>
<img
:src="logoDisplayUrl"
alt="Logo"
class="logo-image"
/>
<div class="logo-overlay">
<span class="text-sm text-white">{{
$t('common.replace')
}}</span>
</div>
</div>
<div
v-else
class="logo-placeholder"
@click="openFileSelector"
>
<span class="text-muted-foreground text-sm">{{
$t('ui-config.logo.sourcePlaceholder')
}}</span>
</div>
</div>
<ElButton
v-if="logoDisplayUrl"
size="small"
class="mt-2"
@click="clearLogo"
>
{{ $t('common.clear') }}
</ElButton>
</div>
</div>
<input
ref="uploadInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileInputChange"
/>
<ElInput
v-model="logoSource"
:placeholder="$t('ui-config.logo.sourcePlaceholder')"
clearable
/>
</div>
</div>
<div class="mb-4">
<div class="text-muted-foreground mb-2 text-sm">
{{ $t('ui-config.logo.fit') }}
</div>
<ElSelect v-model="logoFit" class="w-full">
<ElOption
v-for="item in fitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</div>
</template>
</Block>
</div>
<!-- 第三列快捷键 -->
<div class="shortcut-column">
<Block :title="$t('preferences.shortcutKeys.global')">
<GlobalShortcutKeys
v-model:shortcut-keys-enable="shortcutKeysEnable"
v-model:shortcut-keys-global-search="shortcutKeysGlobalSearch"
v-model:shortcut-keys-lock-screen="shortcutKeysGlobalLockScreen"
v-model:shortcut-keys-logout="shortcutKeysGlobalLogout"
/>
</Block>
<Block :title="$t('preferences.general')">
<General
v-model:app-locale="appLocale"
v-model:app-dynamic-title="appDynamicTitle"
v-model:app-watermark="appWatermark"
v-model:app-watermark-content="appWatermarkContent"
v-model:app-enable-check-updates="appEnableCheckUpdates"
/>
</Block>
</div>
</div>
</template>
<style scoped>
.app-column,
.logo-column,
.shortcut-column {
min-width: 0;
}
.logo-preview-container {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.logo-preview {
position: relative;
width: 100px;
height: 100px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.logo-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.logo-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgb(0 0 0 / 50%);
opacity: 0;
transition: opacity 0.2s;
}
.logo-preview:hover .logo-overlay {
opacity: 1;
}
.logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.logo-placeholder:hover {
border-color: hsl(var(--primary));
}
</style>
@@ -0,0 +1,331 @@
<script lang="ts" setup>
import type { PreferencesConfig } from '#/api/core/ui-config';
import { onMounted, ref } from 'vue';
import { Block, SwitchItem } from '@vben/layouts/preferences-blocks';
import { $t } from '@vben/locales';
import { ElMessage } from 'element-plus';
import {
getPreferencesConfigApi,
mergePreferencesConfig,
updatePreferencesConfigApi,
} from '#/api/core/ui-config';
import { useAppContextStore } from '#/store/app-context';
defineOptions({ name: 'LoginConfigForm' });
const appContextStore = useAppContextStore();
function getCurrentApplicationId(): string | undefined {
return appContextStore.currentApp?.id;
}
const loading = ref(false);
const loginEnableThirdParty = ref(false);
const loginEnabledProviders = ref<string[]>([]);
interface ProviderInfo {
key: string;
color: string;
iconViewBox: string;
iconPaths: string[];
pathColors?: string[];
isRect?: boolean;
}
const microsoftRects = [
{ x: 0, y: 0, width: 10.66, height: 10.66 },
{ x: 12.34, y: 0, width: 10.66, height: 10.66 },
{ x: 0, y: 12.34, width: 10.66, height: 10.66 },
{ x: 12.34, y: 12.34, width: 10.66, height: 10.66 },
];
const allProviders: ProviderInfo[] = [
{
key: 'gitee',
color: '#C71D23',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M512 1024C229.222 1024 0 794.778 0 512S229.222 0 512 0s512 229.222 512 512-229.222 512-512 512z m259.149-568.883h-290.74a25.293 25.293 0 0 0-25.292 25.293l-0.026 63.206c0 13.952 11.315 25.293 25.267 25.293h177.024c13.978 0 25.293 11.315 25.293 25.267v12.646a75.853 75.853 0 0 1-75.853 75.853h-240.23a25.293 25.293 0 0 1-25.267-25.293V417.203a75.853 75.853 0 0 1 75.827-75.853h353.946a25.293 25.293 0 0 0 25.267-25.292l0.077-63.207a25.293 25.293 0 0 0-25.268-25.293H417.152a189.62 189.62 0 0 0-189.62 189.645V771.15c0 13.977 11.316 25.293 25.294 25.293h372.94a170.65 170.65 0 0 0 170.65-170.65V480.384a25.293 25.293 0 0 0-25.293-25.267z',
],
},
{
key: 'github',
color: '#24292e',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M512 42.666667A464.64 464.64 0 0 0 42.666667 502.186667 460.373333 460.373333 0 0 0 363.52 938.666667c23.466667 4.266667 32-9.813333 32-22.186667v-78.08c-130.56 27.733333-158.293333-61.44-158.293333-61.44a122.026667 122.026667 0 0 0-52.053334-67.413333c-42.666667-28.16 3.413333-27.733333 3.413334-27.733334a98.56 98.56 0 0 1 71.68 47.36 101.12 101.12 0 0 0 136.533333 37.973334 99.413333 99.413333 0 0 1 29.866667-61.44c-104.106667-11.52-213.333333-50.773333-213.333334-226.986667a177.066667 177.066667 0 0 1 47.36-124.16 161.28 161.28 0 0 1 4.693334-121.173333s39.68-12.373333 128 46.933333a455.68 455.68 0 0 1 234.666666 0c89.6-59.306667 128-46.933333 128-46.933333a161.28 161.28 0 0 1 4.693334 121.173333A177.066667 177.066667 0 0 1 810.666667 477.866667c0 176.64-110.08 215.466667-213.333334 226.986666a106.666667 106.666667 0 0 1 32 85.333334v125.866666c0 14.933333 8.533333 26.88 32 22.186667A460.8 460.8 0 0 0 981.333333 502.186667 464.64 464.64 0 0 0 512 42.666667',
],
},
{
key: 'google',
color: '#4285F4',
iconViewBox: '0 0 24 24',
iconPaths: [
'M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z',
'M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z',
'M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z',
'M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z',
],
pathColors: ['#4285F4', '#34A853', '#FBBC05', '#EA4335'],
},
{
key: 'microsoft',
color: '#00A4EF',
iconViewBox: '0 0 23 23',
iconPaths: [
'M0 0h10.66v10.66H0z',
'M12.34 0h10.66v10.66H12.34z',
'M0 12.34h10.66v10.66H0z',
'M12.34 12.34h10.66v10.66H12.34z',
],
pathColors: ['#F25022', '#7FBA00', '#00A4EF', '#FFB900'],
isRect: true,
},
{
key: 'qq',
color: '#12B7F5',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M824.8 613.2c-16-51.4-34.4-94.6-62.7-165.3C766.5 262.2 689.3 112 511.5 112 331.7 112 256.2 265.2 261 447.9c-28.4 70.8-46.7 113.7-62.7 165.3-34 109.5-23 154.8-14.6 155.8 18 2.2 70.1-82.4 70.1-82.4 0 49 25.2 112.9 79.8 159-26.4 8.1-85.7 29.9-71.6 53.8 11.4 19.3 196.2 12.3 249.5 6.3 53.3 6 238.1 13 249.5-6.3 14.1-23.8-45.3-45.7-71.6-53.8 54.6-46.2 79.8-110.1 79.8-159 0 0 52.1 84.6 70.1 82.4 8.5-1.1 19.5-46.4-14.5-155.8z',
],
},
{
key: 'wechat',
color: '#07C160',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M664.250054 368.541681c10.015098 0 19.892049 0.732687 29.67281 1.795902-26.647917-122.810047-159.358451-214.077703-310.826188-214.077703-169.353083 0-308.085774 114.232694-308.085774 259.274068 0 83.708494 46.165436 152.460344 123.281791 205.78483l-30.80868 91.730191 107.688651-53.455469c38.558178 7.53665 69.459978 15.308661 107.924012 15.308661 9.66308 0 19.230993-0.470721 28.752858-1.225921-6.025227-20.36584-9.521864-41.723264-9.521864-63.862493C402.328693 476.632491 517.908058 368.541681 664.250054 368.541681zM498.62897 285.87389c23.200398 0 38.557154 15.120372 38.557154 38.061874 0 22.846334-15.356756 38.156018-38.557154 38.156018-23.107277 0-46.260603-15.309684-46.260603-38.156018C452.368366 300.994262 475.522716 285.87389 498.62897 285.87389zM283.016307 362.090758c-23.107277 0-46.402843-15.309684-46.402843-38.156018 0-22.941502 23.295566-38.061874 46.402843-38.061874 23.081695 0 38.46301 15.120372 38.46301 38.061874C321.479317 346.782098 306.098002 362.090758 283.016307 362.090758zM945.448458 606.151333c0-121.888048-123.258255-221.236753-261.683954-221.236753-146.57838 0-262.015505 99.348706-262.015505 221.236753 0 122.06508 115.437126 221.200938 262.015505 221.200938 30.66644 0 61.617359-7.609305 92.423993-15.262612l84.513836 45.786813-23.178909-76.17082C899.379213 735.776599 945.448458 674.90216 945.448458 606.151333zM598.803483 567.994292c-15.332197 0-30.807656-15.096836-30.807656-30.501688 0-15.190981 15.47546-30.477129 30.807656-30.477129 23.295566 0 38.558178 15.286148 38.558178 30.477129C637.361661 552.897456 622.099049 567.994292 598.803483 567.994292zM768.25071 567.994292c-15.213493 0-30.594809-15.096836-30.594809-30.501688 0-15.190981 15.381315-30.477129 30.594809-30.477129 23.107277 0 38.558178 15.286148 38.558178 30.477129C806.808888 552.897456 791.357987 567.994292 768.25071 567.994292z',
],
},
{
key: 'wecom',
color: '#07C160',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M679.872 348.064c10.688 0 21.184 0.768 31.552 1.92C684.48 221.312 545.408 124.48 383.488 124.48c-180.16 0-327.68 121.536-327.68 275.84 0 89.088 49.088 162.176 131.2 218.88l-32.768 97.6 114.56-56.896c41.024 8.064 73.92 16.32 114.816 16.32 10.304 0 20.48-0.512 30.592-1.28-6.4-21.696-10.112-44.416-10.112-67.968 0-139.264 122.88-258.912 275.776-258.912zM505.152 270.528c16.384 0 27.264 10.688 27.264 26.88 0 16.128-10.88 26.944-27.264 26.944-16.32 0-32.704-10.816-32.704-26.944 0-16.192 16.384-26.88 32.704-26.88zM276.416 324.352c-16.384 0-32.832-10.816-32.832-26.944 0-16.192 16.448-26.88 32.832-26.88 16.32 0 27.2 10.688 27.2 26.88 0 16.128-10.88 26.944-27.2 26.944z',
'M968.064 604.864c0-137.344-131.072-249.024-278.208-249.024-155.904 0-278.528 111.68-278.528 249.024 0 137.472 122.624 249.024 278.528 249.024 32.64 0 65.536-8.128 98.304-16.256l89.856 48.768-24.64-81.024c65.536-65.472 114.688-137.344 114.688-200.512zM614.208 578.176c-10.816 0-21.824-10.688-21.824-21.568 0-10.752 11.008-21.568 21.824-21.568 16.512 0 27.328 10.816 27.328 21.568 0 10.88-10.816 21.568-27.328 21.568z m196.416 0c-10.752 0-21.696-10.688-21.696-21.568 0-10.752 10.944-21.568 21.696-21.568 16.384 0 27.328 10.816 27.328 21.568 0 10.88-10.944 21.568-27.328 21.568z',
],
},
{
key: 'dingtalk',
color: '#0089FF',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m244.5 558.4l-106.1-17s87.5-98.1 34.1-184.8c-53.4-86.7-151.5-51.4-151.5-51.4s-100.1 34.1-132.2 134.2c-32.1 100.1 34.1 184.8 34.1 184.8l-106.1 17s-17-68.2 17-151.5c34.1-83.3 100.1-132.2 100.1-132.2s-17-34.1-51.4-34.1c-34.1 0-68.2 17-68.2 17s-17-51.4 17-100.1c34.1-48.7 100.1-68.2 100.1-68.2s184.8-34.1 285 100.1c100.1 134.2 28.1 285.2 28.1 285.2z',
],
},
{
key: 'feishu',
color: '#00D6B9',
iconViewBox: '0 0 1024 1024',
iconPaths: [
'M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m213.3 512H298.7c-17.7 0-32-14.3-32-32V298.7c0-17.7 14.3-32 32-32h426.6c17.7 0 32 14.3 32 32V544c0 17.7-14.3 32-32 32z',
'M426.7 469.3h170.6c17.7 0 32 14.3 32 32v42.7c0 17.7-14.3 32-32 32H426.7c-17.7 0-32-14.3-32-32v-42.7c0-17.7 14.3-32 32-32z',
],
},
];
function isProviderSelected(key: string): boolean {
return loginEnabledProviders.value.includes(key);
}
function toggleProvider(key: string) {
const index = loginEnabledProviders.value.indexOf(key);
if (index >= 0) {
loginEnabledProviders.value.splice(index, 1);
} else {
loginEnabledProviders.value.push(key);
}
}
async function loadConfig() {
loading.value = true;
try {
const applicationId = getCurrentApplicationId();
const data = await getPreferencesConfigApi(applicationId);
loginEnableThirdParty.value =
data?.loginConfig?.enableThirdPartyLogin ?? false;
loginEnabledProviders.value =
data?.loginConfig?.enabledProviders ?? [];
} catch {
loginEnableThirdParty.value = false;
loginEnabledProviders.value = [];
} finally {
loading.value = false;
}
}
function buildConfig(): PreferencesConfig {
return {
loginConfig: {
enableThirdPartyLogin: loginEnableThirdParty.value,
enabledProviders: loginEnabledProviders.value,
},
};
}
async function save() {
if (loading.value) return;
try {
const applicationId = getCurrentApplicationId();
const currentConfig = (await getPreferencesConfigApi(applicationId)) ?? {};
const newConfig = mergePreferencesConfig(currentConfig, buildConfig());
await updatePreferencesConfigApi(newConfig, applicationId);
ElMessage.success($t('ui-config.saveSuccess'));
} catch {
ElMessage.error($t('ui-config.saveError'));
}
}
function reset() {
loadConfig();
}
defineExpose({ reset, save });
onMounted(() => {
loadConfig();
});
</script>
<template>
<div v-loading="loading" class="p-6">
<Block :title="$t('ui-config.loginConfig.title')">
<SwitchItem v-model="loginEnableThirdParty">
{{ $t('ui-config.loginConfig.enableThirdPartyLogin') }}
</SwitchItem>
<template v-if="loginEnableThirdParty">
<div class="mt-4">
<div class="text-muted-foreground mb-1 text-sm">
{{ $t('ui-config.loginConfig.enabledProviders') }}
</div>
<div class="text-muted-foreground mb-4 text-xs">
{{ $t('ui-config.loginConfig.enabledProvidersTip') }}
</div>
<div class="grid grid-cols-3 gap-3">
<div
v-for="provider in allProviders"
:key="provider.key"
class="provider-card"
:class="{ 'provider-card--selected': isProviderSelected(provider.key) }"
@click="toggleProvider(provider.key)"
>
<div class="provider-card__icon" :style="{ backgroundColor: provider.color + '14' }">
<svg
class="size-5"
:viewBox="provider.iconViewBox"
xmlns="http://www.w3.org/2000/svg"
>
<template v-if="provider.isRect">
<rect
v-for="(rect, idx) in microsoftRects"
:key="idx"
:x="rect.x"
:y="rect.y"
:width="rect.width"
:height="rect.height"
:fill="provider.pathColors?.[idx] || provider.color"
/>
</template>
<template v-else>
<path
v-for="(pathD, idx) in provider.iconPaths"
:key="idx"
:d="pathD"
:fill="provider.pathColors?.[idx] || provider.color"
/>
</template>
</svg>
</div>
<span class="provider-card__name">
{{ $t(`ui-config.loginConfig.providers.${provider.key}`) }}
</span>
<div class="provider-card__check">
<svg
v-if="isProviderSelected(provider.key)"
class="size-4"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M20 6L9 17L4 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
</div>
</div>
</template>
</Block>
</div>
</template>
<style scoped>
.provider-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 2px solid hsl(var(--border));
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
position: relative;
}
.provider-card:hover {
border-color: hsl(var(--primary) / 0.5);
background-color: hsl(var(--accent) / 0.3);
}
.provider-card--selected {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary) / 0.06);
}
.provider-card--selected:hover {
border-color: hsl(var(--primary));
}
.provider-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
flex-shrink: 0;
}
.provider-card__name {
font-size: 14px;
font-weight: 500;
color: hsl(var(--foreground));
flex: 1;
}
.provider-card__check {
width: 20px;
height: 20px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: hsl(var(--primary));
}
</style>
@@ -0,0 +1,664 @@
<script lang="ts" setup>
import type {
BreadcrumbStyleType,
BuiltinThemeType,
ContentCompactType,
LayoutHeaderMenuAlignType,
LayoutHeaderModeType,
LayoutType,
NavigationStyleType,
PreferencesButtonPositionType,
ThemeModeType,
} from '@vben/types';
import type { PreferencesConfig } from '#/api/core/ui-config';
import { onMounted, ref } from 'vue';
import {
Animation,
Block,
Breadcrumb,
BuiltinTheme,
ColorMode,
Content,
Header,
Layout,
Navigation,
Radius,
Sidebar,
Tabbar,
Theme,
Widget,
} from '@vben/layouts/preferences-blocks';
import { $t } from '@vben/locales';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben/preferences';
import { ElMessage } from 'element-plus';
import {
getPreferencesConfigApi,
mergePreferencesConfig,
updatePreferencesConfigApi,
} from '#/api/core/ui-config';
import { overridesPreferences } from '#/preferences';
import { useAppContextStore } from '#/store/app-context';
defineOptions({ name: 'UIPreferencesForm' });
const appContextStore = useAppContextStore();
// 获取当前应用ID(子应用模式下返回应用ID,主应用模式下返回 undefined
function getCurrentApplicationId(): string | undefined {
return appContextStore.currentApp?.id;
}
const loading = ref(false);
const { isDark, isFullContent, isHeaderNav, isMixedNav, isSideMode } =
usePreferences();
const appLayout = ref<LayoutType>('sidebar-nav');
const appColorGrayMode = ref(false);
const appColorWeakMode = ref(false);
const appContentCompact = ref<ContentCompactType>('wide');
const appPreferencesButtonPosition = ref<PreferencesButtonPositionType>('auto');
const transitionProgress = ref(true);
const transitionName = ref('fade-slide');
const transitionLoading = ref(true);
const transitionEnable = ref(true);
const themeColorPrimary = ref('hsl(212 100% 45%)');
const themeBuiltinType = ref<BuiltinThemeType>('default');
const themeMode = ref<ThemeModeType>('light');
const themeRadius = ref('0.5');
const themeSemiDarkSidebar = ref(false);
const themeSemiDarkHeader = ref(false);
const sidebarEnable = ref(true);
const sidebarWidth = ref(230);
const sidebarCollapsed = ref(false);
const sidebarCollapsedShowTitle = ref(false);
const sidebarAutoActivateChild = ref(false);
const sidebarExpandOnHover = ref(true);
const sidebarCollapsedButton = ref(true);
const sidebarFixedButton = ref(true);
const headerEnable = ref(true);
const headerMode = ref<LayoutHeaderModeType>('fixed');
const headerMenuAlign = ref<LayoutHeaderMenuAlignType>('start');
const breadcrumbEnable = ref(true);
const breadcrumbShowIcon = ref(true);
const breadcrumbShowHome = ref(true);
const breadcrumbStyleType = ref<BreadcrumbStyleType>('normal');
const breadcrumbHideOnlyOne = ref(false);
const tabbarEnable = ref(true);
const tabbarShowIcon = ref(true);
const tabbarShowMore = ref(true);
const tabbarShowMaximize = ref(true);
const tabbarPersist = ref(true);
const tabbarDraggable = ref(true);
const tabbarWheelable = ref(true);
const tabbarStyleType = ref('chrome');
const tabbarMaxCount = ref(0);
const tabbarMiddleClickToClose = ref(true);
const navigationStyleType = ref<NavigationStyleType>('rounded');
const navigationSplit = ref(true);
const navigationAccordion = ref(true);
const widgetGlobalSearch = ref(true);
const widgetFullscreen = ref(true);
const widgetLanguageToggle = ref(true);
const widgetNotification = ref(true);
const widgetThemeToggle = ref(true);
const widgetSidebarToggle = ref(true);
const widgetLockScreen = ref(true);
const widgetRefresh = ref(true);
function getDefaultConfig() {
const overrides = overridesPreferences as Record<string, any>;
return {
app: {
layout: overrides.app?.layout || preferences.app.layout || 'sidebar-nav',
colorGrayMode:
overrides.app?.colorGrayMode ?? preferences.app.colorGrayMode ?? false,
colorWeakMode:
overrides.app?.colorWeakMode ?? preferences.app.colorWeakMode ?? false,
contentCompact:
overrides.app?.contentCompact ||
preferences.app.contentCompact ||
'wide',
preferencesButtonPosition:
overrides.app?.preferencesButtonPosition ||
preferences.app.preferencesButtonPosition ||
'auto',
},
transition: {
progress:
overrides.transition?.progress ??
preferences.transition.progress ??
true,
name:
overrides.transition?.name ||
preferences.transition.name ||
'fade-slide',
loading:
overrides.transition?.loading ?? preferences.transition.loading ?? true,
enable:
overrides.transition?.enable ?? preferences.transition.enable ?? true,
},
theme: {
colorPrimary:
overrides.theme?.colorPrimary ||
preferences.theme.colorPrimary ||
'hsl(212 100% 45%)',
builtinType:
overrides.theme?.builtinType ||
preferences.theme.builtinType ||
'default',
mode: overrides.theme?.mode || preferences.theme.mode || 'light',
radius: String(
overrides.theme?.radius || preferences.theme.radius || '0.5',
),
semiDarkSidebar:
overrides.theme?.semiDarkSidebar ??
preferences.theme.semiDarkSidebar ??
false,
semiDarkHeader:
overrides.theme?.semiDarkHeader ??
preferences.theme.semiDarkHeader ??
false,
},
sidebar: {
enable: overrides.sidebar?.enable ?? preferences.sidebar.enable ?? true,
width: overrides.sidebar?.width || preferences.sidebar.width || 230,
collapsed:
overrides.sidebar?.collapsed ?? preferences.sidebar.collapsed ?? false,
collapsedShowTitle:
overrides.sidebar?.collapsedShowTitle ??
preferences.sidebar.collapsedShowTitle ??
false,
autoActivateChild:
overrides.sidebar?.autoActivateChild ??
preferences.sidebar.autoActivateChild ??
false,
expandOnHover:
overrides.sidebar?.expandOnHover ??
preferences.sidebar.expandOnHover ??
true,
collapsedButton:
overrides.sidebar?.collapsedButton ??
preferences.sidebar.collapsedButton ??
true,
fixedButton:
overrides.sidebar?.fixedButton ??
preferences.sidebar.fixedButton ??
true,
},
header: {
enable: overrides.header?.enable ?? preferences.header.enable ?? true,
mode: overrides.header?.mode || preferences.header.mode || 'fixed',
menuAlign:
overrides.header?.menuAlign || preferences.header.menuAlign || 'start',
},
breadcrumb: {
enable:
overrides.breadcrumb?.enable ?? preferences.breadcrumb.enable ?? true,
showIcon:
overrides.breadcrumb?.showIcon ??
preferences.breadcrumb.showIcon ??
true,
showHome:
overrides.breadcrumb?.showHome ??
preferences.breadcrumb.showHome ??
true,
styleType:
overrides.breadcrumb?.styleType ||
preferences.breadcrumb.styleType ||
'normal',
hideOnlyOne:
overrides.breadcrumb?.hideOnlyOne ??
preferences.breadcrumb.hideOnlyOne ??
false,
},
tabbar: {
enable: overrides.tabbar?.enable ?? preferences.tabbar.enable ?? true,
showIcon:
overrides.tabbar?.showIcon ?? preferences.tabbar.showIcon ?? true,
showMore:
overrides.tabbar?.showMore ?? preferences.tabbar.showMore ?? true,
showMaximize:
overrides.tabbar?.showMaximize ??
preferences.tabbar.showMaximize ??
true,
persist: overrides.tabbar?.persist ?? preferences.tabbar.persist ?? true,
draggable:
overrides.tabbar?.draggable ?? preferences.tabbar.draggable ?? true,
wheelable:
overrides.tabbar?.wheelable ?? preferences.tabbar.wheelable ?? true,
styleType:
overrides.tabbar?.styleType || preferences.tabbar.styleType || 'chrome',
maxCount: overrides.tabbar?.maxCount || preferences.tabbar.maxCount || 0,
middleClickToClose:
overrides.tabbar?.middleClickToClose ??
preferences.tabbar.middleClickToClose ??
true,
},
navigation: {
styleType:
overrides.navigation?.styleType ||
preferences.navigation.styleType ||
'rounded',
split:
overrides.navigation?.split ?? preferences.navigation.split ?? true,
accordion:
overrides.navigation?.accordion ??
preferences.navigation.accordion ??
true,
},
widget: {
globalSearch:
overrides.widget?.globalSearch ??
preferences.widget.globalSearch ??
true,
fullscreen:
overrides.widget?.fullscreen ?? preferences.widget.fullscreen ?? true,
languageToggle:
overrides.widget?.languageToggle ??
preferences.widget.languageToggle ??
true,
notification:
overrides.widget?.notification ??
preferences.widget.notification ??
true,
themeToggle:
overrides.widget?.themeToggle ?? preferences.widget.themeToggle ?? true,
sidebarToggle:
overrides.widget?.sidebarToggle ??
preferences.widget.sidebarToggle ??
true,
lockScreen:
overrides.widget?.lockScreen ?? preferences.widget.lockScreen ?? true,
refresh: overrides.widget?.refresh ?? preferences.widget.refresh ?? true,
},
};
}
async function loadConfig() {
loading.value = true;
try {
const applicationId = getCurrentApplicationId();
const data = await getPreferencesConfigApi(applicationId);
const defaults = getDefaultConfig();
appLayout.value = (data?.app?.layout || defaults.app.layout) as LayoutType;
appColorGrayMode.value =
data?.app?.colorGrayMode ?? defaults.app.colorGrayMode;
appColorWeakMode.value =
data?.app?.colorWeakMode ?? defaults.app.colorWeakMode;
appContentCompact.value = (data?.app?.contentCompact ||
defaults.app.contentCompact) as ContentCompactType;
appPreferencesButtonPosition.value = (data?.app
?.preferencesButtonPosition ||
defaults.app.preferencesButtonPosition) as PreferencesButtonPositionType;
transitionProgress.value =
data?.transition?.progress ?? defaults.transition.progress;
transitionName.value = data?.transition?.name || defaults.transition.name;
transitionLoading.value =
data?.transition?.loading ?? defaults.transition.loading;
transitionEnable.value =
data?.transition?.enable ?? defaults.transition.enable;
themeColorPrimary.value =
data?.theme?.colorPrimary || defaults.theme.colorPrimary;
themeBuiltinType.value = (data?.theme?.builtinType ||
defaults.theme.builtinType) as BuiltinThemeType;
themeMode.value = (data?.theme?.mode ||
defaults.theme.mode) as ThemeModeType;
themeRadius.value = String(data?.theme?.radius || defaults.theme.radius);
themeSemiDarkSidebar.value =
data?.theme?.semiDarkSidebar ?? defaults.theme.semiDarkSidebar;
themeSemiDarkHeader.value =
data?.theme?.semiDarkHeader ?? defaults.theme.semiDarkHeader;
sidebarEnable.value = data?.sidebar?.enable ?? defaults.sidebar.enable;
sidebarWidth.value = data?.sidebar?.width || defaults.sidebar.width;
sidebarCollapsed.value =
data?.sidebar?.collapsed ?? defaults.sidebar.collapsed;
sidebarCollapsedShowTitle.value =
data?.sidebar?.collapsedShowTitle ?? defaults.sidebar.collapsedShowTitle;
sidebarAutoActivateChild.value =
data?.sidebar?.autoActivateChild ?? defaults.sidebar.autoActivateChild;
sidebarExpandOnHover.value =
data?.sidebar?.expandOnHover ?? defaults.sidebar.expandOnHover;
sidebarCollapsedButton.value =
data?.sidebar?.collapsedButton ?? defaults.sidebar.collapsedButton;
sidebarFixedButton.value =
data?.sidebar?.fixedButton ?? defaults.sidebar.fixedButton;
headerEnable.value = data?.header?.enable ?? defaults.header.enable;
headerMode.value = (data?.header?.mode ||
defaults.header.mode) as LayoutHeaderModeType;
headerMenuAlign.value = (data?.header?.menuAlign ||
defaults.header.menuAlign) as LayoutHeaderMenuAlignType;
breadcrumbEnable.value =
data?.breadcrumb?.enable ?? defaults.breadcrumb.enable;
breadcrumbShowIcon.value =
data?.breadcrumb?.showIcon ?? defaults.breadcrumb.showIcon;
breadcrumbShowHome.value =
data?.breadcrumb?.showHome ?? defaults.breadcrumb.showHome;
breadcrumbStyleType.value = (data?.breadcrumb?.styleType ||
defaults.breadcrumb.styleType) as BreadcrumbStyleType;
breadcrumbHideOnlyOne.value =
data?.breadcrumb?.hideOnlyOne ?? defaults.breadcrumb.hideOnlyOne;
tabbarEnable.value = data?.tabbar?.enable ?? defaults.tabbar.enable;
tabbarShowIcon.value = data?.tabbar?.showIcon ?? defaults.tabbar.showIcon;
tabbarShowMore.value = data?.tabbar?.showMore ?? defaults.tabbar.showMore;
tabbarShowMaximize.value =
data?.tabbar?.showMaximize ?? defaults.tabbar.showMaximize;
tabbarPersist.value = data?.tabbar?.persist ?? defaults.tabbar.persist;
tabbarDraggable.value =
data?.tabbar?.draggable ?? defaults.tabbar.draggable;
tabbarWheelable.value =
data?.tabbar?.wheelable ?? defaults.tabbar.wheelable;
tabbarStyleType.value =
data?.tabbar?.styleType || defaults.tabbar.styleType;
tabbarMaxCount.value = data?.tabbar?.maxCount || defaults.tabbar.maxCount;
tabbarMiddleClickToClose.value =
data?.tabbar?.middleClickToClose ?? defaults.tabbar.middleClickToClose;
navigationStyleType.value = (data?.navigation?.styleType ||
defaults.navigation.styleType) as NavigationStyleType;
navigationSplit.value =
data?.navigation?.split ?? defaults.navigation.split;
navigationAccordion.value =
data?.navigation?.accordion ?? defaults.navigation.accordion;
widgetGlobalSearch.value =
data?.widget?.globalSearch ?? defaults.widget.globalSearch;
widgetFullscreen.value =
data?.widget?.fullscreen ?? defaults.widget.fullscreen;
widgetLanguageToggle.value =
data?.widget?.languageToggle ?? defaults.widget.languageToggle;
widgetNotification.value =
data?.widget?.notification ?? defaults.widget.notification;
widgetThemeToggle.value =
data?.widget?.themeToggle ?? defaults.widget.themeToggle;
widgetSidebarToggle.value =
data?.widget?.sidebarToggle ?? defaults.widget.sidebarToggle;
widgetLockScreen.value =
data?.widget?.lockScreen ?? defaults.widget.lockScreen;
widgetRefresh.value = data?.widget?.refresh ?? defaults.widget.refresh;
} catch {
const defaults = getDefaultConfig();
appLayout.value = defaults.app.layout as LayoutType;
} finally {
loading.value = false;
}
}
function buildConfig(): PreferencesConfig {
return {
app: {
layout: appLayout.value,
colorGrayMode: appColorGrayMode.value,
colorWeakMode: appColorWeakMode.value,
contentCompact: appContentCompact.value,
preferencesButtonPosition: appPreferencesButtonPosition.value,
},
transition: {
progress: transitionProgress.value,
name: transitionName.value,
loading: transitionLoading.value,
enable: transitionEnable.value,
},
theme: {
colorPrimary: themeColorPrimary.value,
builtinType: themeBuiltinType.value,
mode: themeMode.value,
radius: themeRadius.value,
semiDarkSidebar: themeSemiDarkSidebar.value,
semiDarkHeader: themeSemiDarkHeader.value,
},
sidebar: {
enable: sidebarEnable.value,
width: sidebarWidth.value,
collapsed: sidebarCollapsed.value,
collapsedShowTitle: sidebarCollapsedShowTitle.value,
autoActivateChild: sidebarAutoActivateChild.value,
expandOnHover: sidebarExpandOnHover.value,
collapsedButton: sidebarCollapsedButton.value,
fixedButton: sidebarFixedButton.value,
},
header: {
enable: headerEnable.value,
mode: headerMode.value,
menuAlign: headerMenuAlign.value,
},
breadcrumb: {
enable: breadcrumbEnable.value,
showIcon: breadcrumbShowIcon.value,
showHome: breadcrumbShowHome.value,
styleType: breadcrumbStyleType.value,
hideOnlyOne: breadcrumbHideOnlyOne.value,
},
tabbar: {
enable: tabbarEnable.value,
showIcon: tabbarShowIcon.value,
showMore: tabbarShowMore.value,
showMaximize: tabbarShowMaximize.value,
persist: tabbarPersist.value,
draggable: tabbarDraggable.value,
wheelable: tabbarWheelable.value,
styleType: tabbarStyleType.value,
maxCount: tabbarMaxCount.value,
middleClickToClose: tabbarMiddleClickToClose.value,
},
navigation: {
styleType: navigationStyleType.value,
split: navigationSplit.value,
accordion: navigationAccordion.value,
},
widget: {
globalSearch: widgetGlobalSearch.value,
fullscreen: widgetFullscreen.value,
languageToggle: widgetLanguageToggle.value,
notification: widgetNotification.value,
themeToggle: widgetThemeToggle.value,
sidebarToggle: widgetSidebarToggle.value,
lockScreen: widgetLockScreen.value,
refresh: widgetRefresh.value,
},
};
}
function applyPreferences() {
const config = buildConfig();
updatePreferences({
app: config.app,
transition: config.transition,
theme: config.theme,
sidebar: config.sidebar,
header: config.header,
breadcrumb: config.breadcrumb,
tabbar: config.tabbar,
navigation: config.navigation,
widget: config.widget,
});
}
async function save() {
if (loading.value) return;
try {
applyPreferences();
const applicationId = getCurrentApplicationId();
const currentConfig = (await getPreferencesConfigApi(applicationId)) ?? {};
const newConfig = mergePreferencesConfig(currentConfig, buildConfig());
await updatePreferencesConfigApi(newConfig, applicationId);
ElMessage.success($t('ui-config.saveSuccess'));
} catch {
ElMessage.error($t('ui-config.saveError'));
}
}
function reset() {
loadConfig();
}
defineExpose({ reset, save });
onMounted(() => {
loadConfig();
});
</script>
<template>
<div v-loading="loading" class="grid grid-cols-3 gap-6 p-4">
<!-- 外观 -->
<div class="preferences-column">
<Block :title="$t('preferences.theme.title')">
<Theme
v-model="themeMode"
v-model:theme-semi-dark-sidebar="themeSemiDarkSidebar"
v-model:theme-semi-dark-header="themeSemiDarkHeader"
/>
</Block>
<Block :title="$t('preferences.theme.builtin.title')">
<BuiltinTheme
v-model="themeBuiltinType"
v-model:theme-color-primary="themeColorPrimary"
:is-dark="isDark"
/>
</Block>
<Block :title="$t('preferences.animation.title')">
<Animation
v-model:transition-enable="transitionEnable"
v-model:transition-loading="transitionLoading"
v-model:transition-name="transitionName"
v-model:transition-progress="transitionProgress"
/>
</Block>
<Block :title="$t('preferences.theme.radius')">
<Radius v-model="themeRadius" />
</Block>
<Block :title="$t('preferences.other')">
<ColorMode
v-model:app-color-gray-mode="appColorGrayMode"
v-model:app-color-weak-mode="appColorWeakMode"
/>
</Block>
</div>
<!-- 布局 -->
<div class="preferences-column">
<Block :title="$t('preferences.layout')">
<Layout v-model="appLayout" />
</Block>
<Block :title="$t('preferences.content')">
<Content v-model="appContentCompact" />
</Block>
<Block :title="$t('preferences.sidebar.title')">
<Sidebar
v-model:sidebar-enable="sidebarEnable"
v-model:sidebar-width="sidebarWidth"
v-model:sidebar-collapsed="sidebarCollapsed"
v-model:sidebar-collapsed-show-title="sidebarCollapsedShowTitle"
v-model:sidebar-auto-activate-child="sidebarAutoActivateChild"
v-model:sidebar-expand-on-hover="sidebarExpandOnHover"
v-model:sidebar-collapsed-button="sidebarCollapsedButton"
v-model:sidebar-fixed-button="sidebarFixedButton"
:current-layout="appLayout"
:disabled="!isSideMode"
/>
</Block>
<Block :title="$t('preferences.header.title')">
<Header
v-model:header-enable="headerEnable"
v-model:header-mode="headerMode"
v-model:header-menu-align="headerMenuAlign"
:disabled="isFullContent"
/>
</Block>
<Block :title="$t('preferences.navigationMenu.title')">
<Navigation
v-model:navigation-style-type="navigationStyleType"
v-model:navigation-split="navigationSplit"
v-model:navigation-accordion="navigationAccordion"
:disabled="isFullContent"
:disabled-navigation-split="!isMixedNav"
/>
</Block>
</div>
<!-- 布局 -->
<div class="preferences-column">
<Block :title="$t('preferences.breadcrumb.title')">
<Breadcrumb
v-model:breadcrumb-enable="breadcrumbEnable"
v-model:breadcrumb-show-icon="breadcrumbShowIcon"
v-model:breadcrumb-show-home="breadcrumbShowHome"
v-model:breadcrumb-style-type="breadcrumbStyleType"
v-model:breadcrumb-hide-only-one="breadcrumbHideOnlyOne"
:disabled="
isFullContent || isMixedNav || isHeaderNav || !headerEnable
"
/>
</Block>
<Block :title="$t('preferences.tabbar.title')">
<Tabbar
v-model:tabbar-enable="tabbarEnable"
v-model:tabbar-show-icon="tabbarShowIcon"
v-model:tabbar-show-more="tabbarShowMore"
v-model:tabbar-show-maximize="tabbarShowMaximize"
v-model:tabbar-persist="tabbarPersist"
v-model:tabbar-draggable="tabbarDraggable"
v-model:tabbar-wheelable="tabbarWheelable"
v-model:tabbar-style-type="tabbarStyleType"
v-model:tabbar-max-count="tabbarMaxCount"
v-model:tabbar-middle-click-to-close="tabbarMiddleClickToClose"
/>
</Block>
<Block :title="$t('preferences.widget.title')">
<Widget
v-model:app-preferences-button-position="appPreferencesButtonPosition"
v-model:widget-fullscreen="widgetFullscreen"
v-model:widget-global-search="widgetGlobalSearch"
v-model:widget-language-toggle="widgetLanguageToggle"
v-model:widget-lock-screen="widgetLockScreen"
v-model:widget-notification="widgetNotification"
v-model:widget-refresh="widgetRefresh"
v-model:widget-sidebar-toggle="widgetSidebarToggle"
v-model:widget-theme-toggle="widgetThemeToggle"
/>
</Block>
</div>
</div>
</template>
<style scoped>
.preferences-column {
min-width: 0;
}
</style>