feat: 添加完整的前端管理系统 (VbenAdmin)

- 添加基于 VbenAdmin + Vue3 + Element Plus 的前端管理系统
- 包含完整的 UI 组件库和工具链
- 支持多应用架构 (web-ele, backend-mock, playground)
- 包含完整的开发规范和配置
- 修复 admin 目录的子模块问题,确保正确提交
This commit is contained in:
万物街
2025-08-23 13:24:04 +08:00
parent 43626e5bf2
commit dc6e9baec0
1406 changed files with 133197 additions and 1 deletions

View File

@@ -0,0 +1,15 @@
# @vben/backend-mock
## Description
Vben Admin 数据 mock 服务,没有对接任何的数据库,所有数据都是模拟的,用于前端开发时提供数据支持。线上环境不再提供 mock 集成,可自行部署服务或者对接真实数据,由于 `mock.js` 等工具有一些限制,比如上传文件不行、无法模拟复杂的逻辑等,所以这里使用了真实的后端服务来实现。唯一麻烦的是本地需要同时启动后端服务和前端服务,但是这样可以更好的模拟真实环境。该服务不需要手动启动,已经集成在 vite 插件内,随应用一起启用。
## Running the app
```bash
# development
$ pnpm run start
# production mode
$ pnpm run build
```

View File

@@ -0,0 +1,16 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_CODES } from '~/utils/mock-data';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
export default eventHandler((event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const codes =
MOCK_CODES.find((item) => item.username === userinfo.username)?.codes ?? [];
return useResponseSuccess(codes);
});

View File

@@ -0,0 +1,42 @@
import { defineEventHandler, readBody, setResponseStatus } from 'h3';
import {
clearRefreshTokenCookie,
setRefreshTokenCookie,
} from '~/utils/cookie-utils';
import { generateAccessToken, generateRefreshToken } from '~/utils/jwt-utils';
import { MOCK_USERS } from '~/utils/mock-data';
import {
forbiddenResponse,
useResponseError,
useResponseSuccess,
} from '~/utils/response';
export default defineEventHandler(async (event) => {
const { password, username } = await readBody(event);
if (!password || !username) {
setResponseStatus(event, 400);
return useResponseError(
'BadRequestException',
'Username and password are required',
);
}
const findUser = MOCK_USERS.find(
(item) => item.username === username && item.password === password,
);
if (!findUser) {
clearRefreshTokenCookie(event);
return forbiddenResponse(event, 'Username or password is incorrect.');
}
const accessToken = generateAccessToken(findUser);
const refreshToken = generateRefreshToken(findUser);
setRefreshTokenCookie(event, refreshToken);
return useResponseSuccess({
...findUser,
accessToken,
});
});

View File

@@ -0,0 +1,17 @@
import { defineEventHandler } from 'h3';
import {
clearRefreshTokenCookie,
getRefreshTokenFromCookie,
} from '~/utils/cookie-utils';
import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler(async (event) => {
const refreshToken = getRefreshTokenFromCookie(event);
if (!refreshToken) {
return useResponseSuccess('');
}
clearRefreshTokenCookie(event);
return useResponseSuccess('');
});

View File

@@ -0,0 +1,35 @@
import { defineEventHandler } from 'h3';
import {
clearRefreshTokenCookie,
getRefreshTokenFromCookie,
setRefreshTokenCookie,
} from '~/utils/cookie-utils';
import { generateAccessToken, verifyRefreshToken } from '~/utils/jwt-utils';
import { MOCK_USERS } from '~/utils/mock-data';
import { forbiddenResponse } from '~/utils/response';
export default defineEventHandler(async (event) => {
const refreshToken = getRefreshTokenFromCookie(event);
if (!refreshToken) {
return forbiddenResponse(event);
}
clearRefreshTokenCookie(event);
const userinfo = verifyRefreshToken(refreshToken);
if (!userinfo) {
return forbiddenResponse(event);
}
const findUser = MOCK_USERS.find(
(item) => item.username === userinfo.username,
);
if (!findUser) {
return forbiddenResponse(event);
}
const accessToken = generateAccessToken(findUser);
setRefreshTokenCookie(event, refreshToken);
return accessToken;
});

View File

@@ -0,0 +1,32 @@
import { eventHandler, setHeader } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse } from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const data = `
{
"code": 0,
"message": "success",
"data": [
{
"id": 123456789012345678901234567890123456789012345678901234567890,
"name": "John Doe",
"age": 30,
"email": "john-doe@demo.com"
},
{
"id": 987654321098765432109876543210987654321098765432109876543210,
"name": "Jane Smith",
"age": 25,
"email": "jane@demo.com"
}
]
}
`;
setHeader(event, 'Content-Type', 'application/json');
return data;
});

View File

@@ -0,0 +1,15 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENUS } from '~/utils/mock-data';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const menus =
MOCK_MENUS.find((item) => item.username === userinfo.username)?.menus ?? [];
return useResponseSuccess(menus);
});

View File

@@ -0,0 +1,8 @@
import { eventHandler, getQuery, setResponseStatus } from 'h3';
import { useResponseError } from '~/utils/response';
export default eventHandler((event) => {
const { status } = getQuery(event);
setResponseStatus(event, Number(status));
return useResponseError(`${status}`);
});

View File

@@ -0,0 +1,16 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import {
sleep,
unAuthorizedResponse,
useResponseSuccess,
} from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
await sleep(600);
return useResponseSuccess(null);
});

View File

@@ -0,0 +1,16 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import {
sleep,
unAuthorizedResponse,
useResponseSuccess,
} from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
await sleep(1000);
return useResponseSuccess(null);
});

View File

@@ -0,0 +1,16 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import {
sleep,
unAuthorizedResponse,
useResponseSuccess,
} from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
await sleep(2000);
return useResponseSuccess(null);
});

View File

@@ -0,0 +1,62 @@
import { faker } from '@faker-js/faker';
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
function generateMockDataList(count: number) {
const dataList = [];
for (let i = 0; i < count; i++) {
const dataItem: Record<string, any> = {
id: faker.string.uuid(),
pid: 0,
name: faker.commerce.department(),
status: faker.helpers.arrayElement([0, 1]),
createTime: formatterCN.format(
faker.date.between({ from: '2021-01-01', to: '2022-12-31' }),
),
remark: faker.lorem.sentence(),
};
if (faker.datatype.boolean()) {
dataItem.children = Array.from(
{ length: faker.number.int({ min: 1, max: 5 }) },
() => ({
id: faker.string.uuid(),
pid: dataItem.id,
name: faker.commerce.department(),
status: faker.helpers.arrayElement([0, 1]),
createTime: formatterCN.format(
faker.date.between({ from: '2023-01-01', to: '2023-12-31' }),
),
remark: faker.lorem.sentence(),
}),
);
}
dataList.push(dataItem);
}
return dataList;
}
const mockData = generateMockDataList(10);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const listData = structuredClone(mockData);
return useResponseSuccess(listData);
});

View File

@@ -0,0 +1,13 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
return useResponseSuccess(MOCK_MENU_LIST);
});

View File

@@ -0,0 +1,29 @@
import { eventHandler, getQuery } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
const namesMap: Record<string, any> = {};
function getNames(menus: any[]) {
menus.forEach((menu) => {
namesMap[menu.name] = String(menu.id);
if (menu.children) {
getNames(menu.children);
}
});
}
getNames(MOCK_MENU_LIST);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const { id, name } = getQuery(event);
return (name as string) in namesMap &&
(!id || namesMap[name as string] !== String(id))
? useResponseSuccess(true)
: useResponseSuccess(false);
});

View File

@@ -0,0 +1,29 @@
import { eventHandler, getQuery } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
const pathMap: Record<string, any> = { '/': 0 };
function getPaths(menus: any[]) {
menus.forEach((menu) => {
pathMap[menu.path] = String(menu.id);
if (menu.children) {
getPaths(menu.children);
}
});
}
getPaths(MOCK_MENU_LIST);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const { id, path } = getQuery(event);
return (path as string) in pathMap &&
(!id || pathMap[path as string] !== String(id))
? useResponseSuccess(true)
: useResponseSuccess(false);
});

View File

@@ -0,0 +1,84 @@
import { faker } from '@faker-js/faker';
import { eventHandler, getQuery } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { getMenuIds, MOCK_MENU_LIST } from '~/utils/mock-data';
import { unAuthorizedResponse, usePageResponseSuccess } from '~/utils/response';
const formatterCN = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const menuIds = getMenuIds(MOCK_MENU_LIST);
function generateMockDataList(count: number) {
const dataList = [];
for (let i = 0; i < count; i++) {
const dataItem: Record<string, any> = {
id: faker.string.uuid(),
name: faker.commerce.product(),
status: faker.helpers.arrayElement([0, 1]),
createTime: formatterCN.format(
faker.date.between({ from: '2022-01-01', to: '2025-01-01' }),
),
permissions: faker.helpers.arrayElements(menuIds),
remark: faker.lorem.sentence(),
};
dataList.push(dataItem);
}
return dataList;
}
const mockData = generateMockDataList(100);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const {
page = 1,
pageSize = 20,
name,
id,
remark,
startTime,
endTime,
status,
} = getQuery(event);
let listData = structuredClone(mockData);
if (name) {
listData = listData.filter((item) =>
item.name.toLowerCase().includes(String(name).toLowerCase()),
);
}
if (id) {
listData = listData.filter((item) =>
item.id.toLowerCase().includes(String(id).toLowerCase()),
);
}
if (remark) {
listData = listData.filter((item) =>
item.remark?.toLowerCase()?.includes(String(remark).toLowerCase()),
);
}
if (startTime) {
listData = listData.filter((item) => item.createTime >= startTime);
}
if (endTime) {
listData = listData.filter((item) => item.createTime <= endTime);
}
if (['0', '1'].includes(status as string)) {
listData = listData.filter((item) => item.status === Number(status));
}
return usePageResponseSuccess(page as string, pageSize as string, listData);
});

View File

@@ -0,0 +1,117 @@
import { faker } from '@faker-js/faker';
import { eventHandler, getQuery } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import {
sleep,
unAuthorizedResponse,
usePageResponseSuccess,
} from '~/utils/response';
function generateMockDataList(count: number) {
const dataList = [];
for (let i = 0; i < count; i++) {
const dataItem = {
id: faker.string.uuid(),
imageUrl: faker.image.avatar(),
imageUrl2: faker.image.avatar(),
open: faker.datatype.boolean(),
status: faker.helpers.arrayElement(['success', 'error', 'warning']),
productName: faker.commerce.productName(),
price: faker.commerce.price(),
currency: faker.finance.currencyCode(),
quantity: faker.number.int({ min: 1, max: 100 }),
available: faker.datatype.boolean(),
category: faker.commerce.department(),
releaseDate: faker.date.past(),
rating: faker.number.float({ min: 1, max: 5 }),
description: faker.commerce.productDescription(),
weight: faker.number.float({ min: 0.1, max: 10 }),
color: faker.color.human(),
inProduction: faker.datatype.boolean(),
tags: Array.from({ length: 3 }, () => faker.commerce.productAdjective()),
};
dataList.push(dataItem);
}
return dataList;
}
const mockData = generateMockDataList(100);
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
await sleep(600);
const { page, pageSize, sortBy, sortOrder } = getQuery(event);
// 规范化分页参数,处理 string[]
const pageRaw = Array.isArray(page) ? page[0] : page;
const pageSizeRaw = Array.isArray(pageSize) ? pageSize[0] : pageSize;
const pageNumber = Math.max(
1,
Number.parseInt(String(pageRaw ?? '1'), 10) || 1,
);
const pageSizeNumber = Math.min(
100,
Math.max(1, Number.parseInt(String(pageSizeRaw ?? '10'), 10) || 10),
);
const listData = structuredClone(mockData);
// 规范化 query 入参,兼容 string[]
const sortKeyRaw = Array.isArray(sortBy) ? sortBy[0] : sortBy;
const sortOrderRaw = Array.isArray(sortOrder) ? sortOrder[0] : sortOrder;
// 检查 sortBy 是否是 listData 元素的合法属性键
if (
typeof sortKeyRaw === 'string' &&
listData[0] &&
Object.prototype.hasOwnProperty.call(listData[0], sortKeyRaw)
) {
// 定义数组元素的类型
type ItemType = (typeof listData)[0];
const sortKey = sortKeyRaw as keyof ItemType; // 将 sortBy 断言为合法键
const isDesc = sortOrderRaw === 'desc';
listData.sort((a, b) => {
const aValue = a[sortKey] as unknown;
const bValue = b[sortKey] as unknown;
let result = 0;
if (typeof aValue === 'number' && typeof bValue === 'number') {
result = aValue - bValue;
} else if (aValue instanceof Date && bValue instanceof Date) {
result = aValue.getTime() - bValue.getTime();
} else if (typeof aValue === 'boolean' && typeof bValue === 'boolean') {
if (aValue === bValue) {
result = 0;
} else {
result = aValue ? 1 : -1;
}
} else {
const aStr = String(aValue);
const bStr = String(bValue);
const aNum = Number(aStr);
const bNum = Number(bStr);
result =
Number.isFinite(aNum) && Number.isFinite(bNum)
? aNum - bNum
: aStr.localeCompare(bStr, undefined, {
numeric: true,
sensitivity: 'base',
});
}
return isDesc ? -result : result;
});
}
return usePageResponseSuccess(
String(pageNumber),
String(pageSizeNumber),
listData,
);
});

View File

@@ -0,0 +1,3 @@
import { defineEventHandler } from 'h3';
export default defineEventHandler(() => 'Test get handler');

View File

@@ -0,0 +1,3 @@
import { defineEventHandler } from 'h3';
export default defineEventHandler(() => 'Test post handler');

View File

@@ -0,0 +1,14 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
export default eventHandler((event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
return useResponseSuccess({
url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
});
// return useResponseError("test")
});

View File

@@ -0,0 +1,11 @@
import { eventHandler } from 'h3';
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
export default eventHandler((event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
return useResponseSuccess(userinfo);
});

View File

@@ -0,0 +1,7 @@
import type { NitroErrorHandler } from 'nitropack';
const errorHandler: NitroErrorHandler = function (error, event) {
event.node.res.end(`[Error Handler] ${error.stack}`);
};
export default errorHandler;

View File

@@ -0,0 +1,20 @@
import { defineEventHandler } from 'h3';
import { forbiddenResponse, sleep } from '~/utils/response';
export default defineEventHandler(async (event) => {
event.node.res.setHeader(
'Access-Control-Allow-Origin',
event.headers.get('Origin') ?? '*',
);
if (event.method === 'OPTIONS') {
event.node.res.statusCode = 204;
event.node.res.statusMessage = 'No Content.';
return 'OK';
} else if (
['DELETE', 'PATCH', 'POST', 'PUT'].includes(event.method) &&
event.path.startsWith('/api/system/')
) {
await sleep(Math.floor(Math.random() * 2000));
return forbiddenResponse(event, '演示环境,禁止修改');
}
});

View File

@@ -0,0 +1,20 @@
import errorHandler from './error';
process.env.COMPATIBILITY_DATE = new Date().toISOString();
export default defineNitroConfig({
devErrorHandler: errorHandler,
errorHandler: '~/error',
routeRules: {
'/api/**': {
cors: true,
headers: {
'Access-Control-Allow-Credentials': 'true',
'Access-Control-Allow-Headers':
'Accept, Authorization, Content-Length, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since, X-CSRF-TOKEN, X-Requested-With',
'Access-Control-Allow-Methods': 'GET,HEAD,PUT,PATCH,POST,DELETE',
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': '*',
},
},
},
});

View File

@@ -0,0 +1,21 @@
{
"name": "@vben/backend-mock",
"version": "0.0.1",
"description": "",
"private": true,
"license": "MIT",
"author": "",
"scripts": {
"build": "nitro build",
"start": "nitro dev"
},
"dependencies": {
"@faker-js/faker": "catalog:",
"jsonwebtoken": "catalog:",
"nitropack": "catalog:"
},
"devDependencies": {
"@types/jsonwebtoken": "catalog:",
"h3": "catalog:"
}
}

View File

@@ -0,0 +1,15 @@
import { defineEventHandler } from 'h3';
export default defineEventHandler(() => {
return `
<h1>Hello Vben Admin</h1>
<h2>Mock service is starting</h2>
<ul>
<li><a href="/api/user">/api/user/info</a></li>
<li><a href="/api/menu">/api/menu/all</a></li>
<li><a href="/api/auth/codes">/api/auth/codes</a></li>
<li><a href="/api/auth/login">/api/auth/login</a></li>
<li><a href="/api/upload">/api/upload</a></li>
</ul>
`;
});

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./.nitro/types/tsconfig.json"
}

View File

@@ -0,0 +1,28 @@
import type { EventHandlerRequest, H3Event } from 'h3';
import { deleteCookie, getCookie, setCookie } from 'h3';
export function clearRefreshTokenCookie(event: H3Event<EventHandlerRequest>) {
deleteCookie(event, 'jwt', {
httpOnly: true,
sameSite: 'none',
secure: true,
});
}
export function setRefreshTokenCookie(
event: H3Event<EventHandlerRequest>,
refreshToken: string,
) {
setCookie(event, 'jwt', refreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60, // unit: seconds
sameSite: 'none',
secure: true,
});
}
export function getRefreshTokenFromCookie(event: H3Event<EventHandlerRequest>) {
const refreshToken = getCookie(event, 'jwt');
return refreshToken;
}

View File

@@ -0,0 +1,77 @@
import type { EventHandlerRequest, H3Event } from 'h3';
import type { UserInfo } from './mock-data';
import { getHeader } from 'h3';
import jwt from 'jsonwebtoken';
import { MOCK_USERS } from './mock-data';
// TODO: Replace with your own secret key
const ACCESS_TOKEN_SECRET = 'access_token_secret';
const REFRESH_TOKEN_SECRET = 'refresh_token_secret';
export interface UserPayload extends UserInfo {
iat: number;
exp: number;
}
export function generateAccessToken(user: UserInfo) {
return jwt.sign(user, ACCESS_TOKEN_SECRET, { expiresIn: '7d' });
}
export function generateRefreshToken(user: UserInfo) {
return jwt.sign(user, REFRESH_TOKEN_SECRET, {
expiresIn: '30d',
});
}
export function verifyAccessToken(
event: H3Event<EventHandlerRequest>,
): null | Omit<UserInfo, 'password'> {
const authHeader = getHeader(event, 'Authorization');
if (!authHeader?.startsWith('Bearer')) {
return null;
}
const tokenParts = authHeader.split(' ');
if (tokenParts.length !== 2) {
return null;
}
const token = tokenParts[1] as string;
try {
const decoded = jwt.verify(
token,
ACCESS_TOKEN_SECRET,
) as unknown as UserPayload;
const username = decoded.username;
const user = MOCK_USERS.find((item) => item.username === username);
if (!user) {
return null;
}
const { password: _pwd, ...userinfo } = user;
return userinfo;
} catch {
return null;
}
}
export function verifyRefreshToken(
token: string,
): null | Omit<UserInfo, 'password'> {
try {
const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET) as UserPayload;
const username = decoded.username;
const user = MOCK_USERS.find(
(item) => item.username === username,
) as UserInfo;
if (!user) {
return null;
}
const { password: _pwd, ...userinfo } = user;
return userinfo;
} catch {
return null;
}
}

View File

@@ -0,0 +1,390 @@
export interface UserInfo {
id: number;
password: string;
realName: string;
roles: string[];
username: string;
homePath?: string;
}
export const MOCK_USERS: UserInfo[] = [
{
id: 0,
password: '123456',
realName: 'Vben',
roles: ['super'],
username: 'vben',
},
{
id: 1,
password: '123456',
realName: 'Admin',
roles: ['admin'],
username: 'admin',
homePath: '/workspace',
},
{
id: 2,
password: '123456',
realName: 'Jack',
roles: ['user'],
username: 'jack',
homePath: '/analytics',
},
];
export const MOCK_CODES = [
// super
{
codes: ['AC_100100', 'AC_100110', 'AC_100120', 'AC_100010'],
username: 'vben',
},
{
// admin
codes: ['AC_100010', 'AC_100020', 'AC_100030'],
username: 'admin',
},
{
// user
codes: ['AC_1000001', 'AC_1000002'],
username: 'jack',
},
];
const dashboardMenus = [
{
meta: {
order: -1,
title: 'page.dashboard.title',
},
name: 'Dashboard',
path: '/dashboard',
redirect: '/analytics',
children: [
{
name: 'Analytics',
path: '/analytics',
component: '/dashboard/analytics/index',
meta: {
affixTab: true,
title: 'page.dashboard.analytics',
},
},
{
name: 'Workspace',
path: '/workspace',
component: '/dashboard/workspace/index',
meta: {
title: 'page.dashboard.workspace',
},
},
],
},
];
const createDemosMenus = (role: 'admin' | 'super' | 'user') => {
const roleWithMenus = {
admin: {
component: '/demos/access/admin-visible',
meta: {
icon: 'mdi:button-cursor',
title: 'demos.access.adminVisible',
},
name: 'AccessAdminVisibleDemo',
path: '/demos/access/admin-visible',
},
super: {
component: '/demos/access/super-visible',
meta: {
icon: 'mdi:button-cursor',
title: 'demos.access.superVisible',
},
name: 'AccessSuperVisibleDemo',
path: '/demos/access/super-visible',
},
user: {
component: '/demos/access/user-visible',
meta: {
icon: 'mdi:button-cursor',
title: 'demos.access.userVisible',
},
name: 'AccessUserVisibleDemo',
path: '/demos/access/user-visible',
},
};
return [
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: 'demos.title',
},
name: 'Demos',
path: '/demos',
redirect: '/demos/access',
children: [
{
name: 'AccessDemos',
path: '/demosaccess',
meta: {
icon: 'mdi:cloud-key-outline',
title: 'demos.access.backendPermissions',
},
redirect: '/demos/access/page-control',
children: [
{
name: 'AccessPageControlDemo',
path: '/demos/access/page-control',
component: '/demos/access/index',
meta: {
icon: 'mdi:page-previous-outline',
title: 'demos.access.pageAccess',
},
},
{
name: 'AccessButtonControlDemo',
path: '/demos/access/button-control',
component: '/demos/access/button-control',
meta: {
icon: 'mdi:button-cursor',
title: 'demos.access.buttonControl',
},
},
{
name: 'AccessMenuVisible403Demo',
path: '/demos/access/menu-visible-403',
component: '/demos/access/menu-visible-403',
meta: {
authority: ['no-body'],
icon: 'mdi:button-cursor',
menuVisibleWithForbidden: true,
title: 'demos.access.menuVisible403',
},
},
roleWithMenus[role],
],
},
],
},
];
};
export const MOCK_MENUS = [
{
menus: [...dashboardMenus, ...createDemosMenus('super')],
username: 'vben',
},
{
menus: [...dashboardMenus, ...createDemosMenus('admin')],
username: 'admin',
},
{
menus: [...dashboardMenus, ...createDemosMenus('user')],
username: 'jack',
},
];
export const MOCK_MENU_LIST = [
{
id: 1,
name: 'Workspace',
status: 1,
type: 'menu',
icon: 'mdi:dashboard',
path: '/workspace',
component: '/dashboard/workspace/index',
meta: {
icon: 'carbon:workspace',
title: 'page.dashboard.workspace',
affixTab: true,
order: 0,
},
},
{
id: 2,
meta: {
icon: 'carbon:settings',
order: 9997,
title: 'system.title',
badge: 'new',
badgeType: 'normal',
badgeVariants: 'primary',
},
status: 1,
type: 'catalog',
name: 'System',
path: '/system',
children: [
{
id: 201,
pid: 2,
path: '/system/menu',
name: 'SystemMenu',
authCode: 'System:Menu:List',
status: 1,
type: 'menu',
meta: {
icon: 'carbon:menu',
title: 'system.menu.title',
},
component: '/system/menu/list',
children: [
{
id: 20_101,
pid: 201,
name: 'SystemMenuCreate',
status: 1,
type: 'button',
authCode: 'System:Menu:Create',
meta: { title: 'common.create' },
},
{
id: 20_102,
pid: 201,
name: 'SystemMenuEdit',
status: 1,
type: 'button',
authCode: 'System:Menu:Edit',
meta: { title: 'common.edit' },
},
{
id: 20_103,
pid: 201,
name: 'SystemMenuDelete',
status: 1,
type: 'button',
authCode: 'System:Menu:Delete',
meta: { title: 'common.delete' },
},
],
},
{
id: 202,
pid: 2,
path: '/system/dept',
name: 'SystemDept',
status: 1,
type: 'menu',
authCode: 'System:Dept:List',
meta: {
icon: 'carbon:container-services',
title: 'system.dept.title',
},
component: '/system/dept/list',
children: [
{
id: 20_401,
pid: 201,
name: 'SystemDeptCreate',
status: 1,
type: 'button',
authCode: 'System:Dept:Create',
meta: { title: 'common.create' },
},
{
id: 20_402,
pid: 201,
name: 'SystemDeptEdit',
status: 1,
type: 'button',
authCode: 'System:Dept:Edit',
meta: { title: 'common.edit' },
},
{
id: 20_403,
pid: 201,
name: 'SystemDeptDelete',
status: 1,
type: 'button',
authCode: 'System:Dept:Delete',
meta: { title: 'common.delete' },
},
],
},
],
},
{
id: 9,
meta: {
badgeType: 'dot',
order: 9998,
title: 'demos.vben.title',
icon: 'carbon:data-center',
},
name: 'Project',
path: '/vben-admin',
type: 'catalog',
status: 1,
children: [
{
id: 901,
pid: 9,
name: 'VbenDocument',
path: '/vben-admin/document',
component: 'IFrameView',
type: 'embedded',
status: 1,
meta: {
icon: 'carbon:book',
iframeSrc: 'https://doc.vben.pro',
title: 'demos.vben.document',
},
},
{
id: 902,
pid: 9,
name: 'VbenGithub',
path: '/vben-admin/github',
component: 'IFrameView',
type: 'link',
status: 1,
meta: {
icon: 'carbon:logo-github',
link: 'https://github.com/vbenjs/vue-vben-admin',
title: 'Github',
},
},
{
id: 903,
pid: 9,
name: 'VbenAntdv',
path: '/vben-admin/antdv',
component: 'IFrameView',
type: 'link',
status: 0,
meta: {
icon: 'carbon:hexagon-vertical-solid',
badgeType: 'dot',
link: 'https://ant.vben.pro',
title: 'demos.vben.antdv',
},
},
],
},
{
id: 10,
component: '_core/about/index',
type: 'menu',
status: 1,
meta: {
icon: 'lucide:copyright',
order: 9999,
title: 'demos.vben.about',
},
name: 'About',
path: '/about',
},
];
export function getMenuIds(menus: any[]) {
const ids: number[] = [];
menus.forEach((item) => {
ids.push(item.id);
if (item.children && item.children.length > 0) {
ids.push(...getMenuIds(item.children));
}
});
return ids;
}

View File

@@ -0,0 +1,70 @@
import type { EventHandlerRequest, H3Event } from 'h3';
import { setResponseStatus } from 'h3';
export function useResponseSuccess<T = any>(data: T) {
return {
code: 0,
data,
error: null,
message: 'ok',
};
}
export function usePageResponseSuccess<T = any>(
page: number | string,
pageSize: number | string,
list: T[],
{ message = 'ok' } = {},
) {
const pageData = pagination(
Number.parseInt(`${page}`),
Number.parseInt(`${pageSize}`),
list,
);
return {
...useResponseSuccess({
items: pageData,
total: list.length,
}),
message,
};
}
export function useResponseError(message: string, error: any = null) {
return {
code: -1,
data: null,
error,
message,
};
}
export function forbiddenResponse(
event: H3Event<EventHandlerRequest>,
message = 'Forbidden Exception',
) {
setResponseStatus(event, 403);
return useResponseError(message, message);
}
export function unAuthorizedResponse(event: H3Event<EventHandlerRequest>) {
setResponseStatus(event, 401);
return useResponseError('Unauthorized Exception', 'Unauthorized Exception');
}
export function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function pagination<T = any>(
pageNo: number,
pageSize: number,
array: T[],
): T[] {
const offset = (pageNo - 1) * Number(pageSize);
return offset + Number(pageSize) >= array.length
? array.slice(offset)
: array.slice(offset, offset + Number(pageSize));
}

View File

@@ -0,0 +1,7 @@
# public path
VITE_BASE=/
# Basic interface address SPA
VITE_GLOB_API_URL=/api
VITE_VISUALIZER=true

View File

@@ -0,0 +1,16 @@
# 端口号
VITE_PORT=5777
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=/api
# 是否开启 Nitro Mock服务true 为开启false 为关闭
VITE_NITRO_MOCK=true
# 是否打开 devtoolstrue 为打开false 为关闭
VITE_DEVTOOLS=false
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

View File

@@ -0,0 +1,19 @@
VITE_BASE=/
# 接口地址
VITE_GLOB_API_URL=https://mock-napi.vben.pro/api
# 是否开启压缩,可以设置为 none, brotli, gzip
VITE_COMPRESS=none
# 是否开启 PWA
VITE_PWA=false
# vue-router 的模式
VITE_ROUTER_HISTORY=hash
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true
# 打包后是否生成dist.zip
VITE_ARCHIVER=true

View File

@@ -0,0 +1,35 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="renderer" content="webkit" />
<meta name="description" content="A Modern Back-end Management System" />
<meta name="keywords" content="Vben Admin Vue3 Vite" />
<meta name="author" content="Vben" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=0"
/>
<!-- 由 vite 注入 VITE_APP_TITLE 变量,在 .env 文件内配置 -->
<title><%= VITE_APP_TITLE %></title>
<link rel="icon" href="/favicon.ico" />
<script>
// 生产环境下注入百度统计
if (window._VBEN_ADMIN_PRO_APP_CONF_) {
var _hmt = _hmt || [];
(function () {
var hm = document.createElement('script');
hm.src =
'https://hm.baidu.com/hm.js?97352b16ed2df8c3860cf5a1a65fb4dd';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(hm, s);
})();
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
{
"name": "@vben/web-ele",
"version": "5.5.9",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "apps/web-ele"
},
"license": "MIT",
"author": {
"name": "vben",
"email": "ann.vben@gmail.com",
"url": "https://github.com/anncwb"
},
"type": "module",
"scripts": {
"build": "pnpm vite build --mode production",
"build:analyze": "pnpm vite build --mode analyze",
"dev": "pnpm vite --mode development",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit --skipLibCheck"
},
"imports": {
"#/*": "./src/*"
},
"dependencies": {
"@vben/access": "workspace:*",
"@vben/common-ui": "workspace:*",
"@vben/constants": "workspace:*",
"@vben/hooks": "workspace:*",
"@vben/icons": "workspace:*",
"@vben/layouts": "workspace:*",
"@vben/locales": "workspace:*",
"@vben/plugins": "workspace:*",
"@vben/preferences": "workspace:*",
"@vben/request": "workspace:*",
"@vben/stores": "workspace:*",
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/core": "catalog:",
"dayjs": "catalog:",
"element-plus": "catalog:",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:"
},
"devDependencies": {
"unplugin-element-plus": "catalog:"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,331 @@
/**
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
*/
import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { ElNotification } from 'element-plus';
const ElButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/button/index'),
import('element-plus/es/components/button/style/css'),
]).then(([res]) => res.ElButton),
);
const ElCheckbox = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox/style/css'),
]).then(([res]) => res.ElCheckbox),
);
const ElCheckboxButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-button/style/css'),
]).then(([res]) => res.ElCheckboxButton),
);
const ElCheckboxGroup = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/checkbox/index'),
import('element-plus/es/components/checkbox-group/style/css'),
]).then(([res]) => res.ElCheckboxGroup),
);
const ElDatePicker = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/date-picker/index'),
import('element-plus/es/components/date-picker/style/css'),
]).then(([res]) => res.ElDatePicker),
);
const ElDivider = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/divider/index'),
import('element-plus/es/components/divider/style/css'),
]).then(([res]) => res.ElDivider),
);
const ElInput = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/input/index'),
import('element-plus/es/components/input/style/css'),
]).then(([res]) => res.ElInput),
);
const ElInputNumber = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/input-number/index'),
import('element-plus/es/components/input-number/style/css'),
]).then(([res]) => res.ElInputNumber),
);
const ElRadio = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio/style/css'),
]).then(([res]) => res.ElRadio),
);
const ElRadioButton = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio-button/style/css'),
]).then(([res]) => res.ElRadioButton),
);
const ElRadioGroup = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/radio/index'),
import('element-plus/es/components/radio-group/style/css'),
]).then(([res]) => res.ElRadioGroup),
);
const ElSelectV2 = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/select-v2/index'),
import('element-plus/es/components/select-v2/style/css'),
]).then(([res]) => res.ElSelectV2),
);
const ElSpace = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/space/index'),
import('element-plus/es/components/space/style/css'),
]).then(([res]) => res.ElSpace),
);
const ElSwitch = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/switch/index'),
import('element-plus/es/components/switch/style/css'),
]).then(([res]) => res.ElSwitch),
);
const ElTimePicker = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/time-picker/index'),
import('element-plus/es/components/time-picker/style/css'),
]).then(([res]) => res.ElTimePicker),
);
const ElTreeSelect = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/tree-select/index'),
import('element-plus/es/components/tree-select/style/css'),
]).then(([res]) => res.ElTreeSelect),
);
const ElUpload = defineAsyncComponent(() =>
Promise.all([
import('element-plus/es/components/upload/index'),
import('element-plus/es/components/upload/style/css'),
]).then(([res]) => res.ElUpload),
);
const withDefaultPlaceholder = <T extends Component>(
component: T,
type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => {
return defineComponent({
name: component.name,
inheritAttrs: false,
setup: (props: any, { attrs, expose, slots }) => {
const placeholder =
props?.placeholder ||
attrs?.placeholder ||
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
},
});
};
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
export type ComponentType =
| 'ApiSelect'
| 'ApiTreeSelect'
| 'Checkbox'
| 'CheckboxGroup'
| 'DatePicker'
| 'Divider'
| 'IconPicker'
| 'Input'
| 'InputNumber'
| 'RadioGroup'
| 'Select'
| 'Space'
| 'Switch'
| 'TimePicker'
| 'TreeSelect'
| 'Upload'
| BaseFormComponentType;
async function initComponentAdapter() {
const components: Partial<Record<ComponentType, Component>> = {
// 如果你的组件体积比较大,可以使用异步加载
// Button: () =>
// import('xxx').then((res) => res.Button),
ApiSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiSelect',
},
'select',
{
component: ElSelectV2,
loadingSlot: 'loading',
visibleEvent: 'onVisibleChange',
},
),
ApiTreeSelect: withDefaultPlaceholder(
{
...ApiComponent,
name: 'ApiTreeSelect',
},
'select',
{
component: ElTreeSelect,
props: { label: 'label', children: 'children' },
nodeKey: 'value',
loadingSlot: 'loading',
optionsPropName: 'data',
visibleEvent: 'onVisibleChange',
},
),
Checkbox: ElCheckbox,
CheckboxGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options, isButton } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(isButton ? ElCheckboxButton : ElCheckbox, option),
);
}
}
return h(
ElCheckboxGroup,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
// 自定义默认按钮
DefaultButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'info' }, slots);
},
// 自定义主要按钮
PrimaryButton: (props, { attrs, slots }) => {
return h(ElButton, { ...props, attrs, type: 'primary' }, slots);
},
Divider: ElDivider,
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
iconSlot: 'append',
modelValueProp: 'model-value',
inputComponent: ElInput,
}),
Input: withDefaultPlaceholder(ElInput, 'input'),
InputNumber: withDefaultPlaceholder(ElInputNumber, 'input'),
RadioGroup: (props, { attrs, slots }) => {
let defaultSlot;
if (Reflect.has(slots, 'default')) {
defaultSlot = slots.default;
} else {
const { options } = attrs;
if (Array.isArray(options)) {
defaultSlot = () =>
options.map((option) =>
h(attrs.isButton ? ElRadioButton : ElRadio, option),
);
}
}
return h(
ElRadioGroup,
{ ...props, ...attrs },
{ ...slots, default: defaultSlot },
);
},
Select: (props, { attrs, slots }) => {
return h(ElSelectV2, { ...props, attrs }, slots);
},
Space: ElSpace,
Switch: ElSwitch,
TimePicker: (props, { attrs, slots }) => {
const { name, id, isRange } = props;
const extraProps: Recordable<any> = {};
if (isRange) {
if (name && !Array.isArray(name)) {
extraProps.name = [name, `${name}_end`];
}
if (id && !Array.isArray(id)) {
extraProps.id = [id, `${id}_end`];
}
}
return h(
ElTimePicker,
{
...props,
...attrs,
...extraProps,
},
slots,
);
},
DatePicker: (props, { attrs, slots }) => {
const { name, id, type } = props;
const extraProps: Recordable<any> = {};
if (type && type.includes('range')) {
if (name && !Array.isArray(name)) {
extraProps.name = [name, `${name}_end`];
}
if (id && !Array.isArray(id)) {
extraProps.id = [id, `${id}_end`];
}
}
return h(
ElDatePicker,
{
...props,
...attrs,
...extraProps,
},
slots,
);
},
TreeSelect: withDefaultPlaceholder(ElTreeSelect, 'select'),
Upload: ElUpload,
};
// 将组件注册到全局共享状态中
globalShareState.setComponents(components);
// 定义全局共享状态中的消息提示
globalShareState.defineMessage({
// 复制成功消息提示
copyPreferencesSuccess: (title, content) => {
ElNotification({
title,
message: content,
position: 'bottom-right',
duration: 0,
type: 'success',
});
},
});
}
export { initComponentAdapter };

View File

@@ -0,0 +1,41 @@
import type {
VbenFormSchema as FormSchema,
VbenFormProps,
} from '@vben/common-ui';
import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
modelPropNameMap: {
Upload: 'fileList',
CheckboxGroup: 'model-value',
},
},
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
}
const useVbenForm = useForm<ComponentType>;
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@@ -0,0 +1,70 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
import { ElButton, ElImage } from 'element-plus';
import { useVbenForm } from './form';
setupVbenVxeTable({
configVxeTable: (vxeUI) => {
vxeUI.setConfig({
grid: {
align: 'center',
border: false,
columnConfig: {
resizable: true,
},
minHeight: 180,
formConfig: {
// 全局禁用vxe-table的表单配置使用formOptions
enabled: false,
},
proxyConfig: {
autoLoad: true,
response: {
result: 'items',
total: 'total',
list: 'items',
},
showActiveMsg: true,
showResponseMsg: false,
},
round: true,
showOverflow: true,
size: 'small',
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },
vxeUI.renderer.add('CellImage', {
renderTableDefault(_renderOpts, params) {
const { column, row } = params;
const src = row[column.field];
return h(ElImage, { src, previewSrcList: [src] });
},
});
// 表格配置项可以用 cellRender: { name: 'CellLink' },
vxeUI.renderer.add('CellLink', {
renderTableDefault(renderOpts) {
const { props } = renderOpts;
return h(
ElButton,
{ size: 'small', link: true },
{ default: () => props?.text },
);
},
});
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
// vxeUI.formats.add
},
useVbenForm,
});
export { useVbenVxeGrid };
export type * from '@vben/plugins/vxe-table';

View File

@@ -0,0 +1,334 @@
import { requestClient } from '#/api/request';
// 用户相关接口
export interface User {
id: string;
username: string;
realname: string;
avatar?: string;
email?: string;
mobile?: string;
status: number;
roles: Role[];
created_at: string;
updated_at: string;
}
export interface CreateUserDto {
username: string;
realname: string;
password: string;
avatar?: string;
email?: string;
mobile?: string;
role_ids: string[];
status: number;
}
export interface UpdateUserDto {
realname?: string;
avatar?: string;
email?: string;
mobile?: string;
role_ids?: string[];
status?: number;
}
export interface UserListParams {
page?: number;
limit?: number;
username?: string;
realname?: string;
status?: number;
role_id?: string;
}
// 角色相关接口
export interface Role {
id: string;
name: string;
code: string;
sort: number;
remark?: string;
status: number;
permissions: Permission[];
created_at: string;
updated_at: string;
}
export interface CreateRoleDto {
name: string;
code: string;
sort: number;
remark?: string;
status: number;
permission_ids: string[];
}
export interface UpdateRoleDto {
name?: string;
code?: string;
sort?: number;
remark?: string;
status?: number;
permission_ids?: string[];
}
export interface RoleListParams {
page?: number;
limit?: number;
name?: string;
code?: string;
status?: number;
}
// 权限相关接口
export interface Permission {
id: string;
parent_id?: string;
name: string;
code: string;
type: 'button' | 'menu';
path?: string;
component?: string;
icon?: string;
sort: number;
status: number;
children?: Permission[];
created_at: string;
updated_at: string;
}
// 菜单相关接口
export interface Menu {
id: string;
parent_id?: string;
type: 'button' | 'directory' | 'menu';
name: string;
title: string;
path?: string;
component?: string;
icon?: string;
sort: number;
is_link: boolean;
link_url?: string;
is_show: boolean;
permission?: string;
status: number;
children?: Menu[];
created_at: string;
updated_at: string;
}
export interface CreateMenuDto {
parent_id?: string;
type: 'button' | 'directory' | 'menu';
name: string;
title: string;
path?: string;
component?: string;
icon?: string;
sort: number;
is_link: boolean;
link_url?: string;
is_show: boolean;
permission?: string;
status: number;
}
export interface UpdateMenuDto {
parent_id?: string;
type?: 'button' | 'directory' | 'menu';
name?: string;
title?: string;
path?: string;
component?: string;
icon?: string;
sort?: number;
is_link?: boolean;
link_url?: string;
is_show?: boolean;
permission?: string;
status?: number;
}
export interface MenuListParams {
name?: string;
title?: string;
status?: number;
}
// API 接口定义
// 用户管理接口
export const getUserListApi = (params: UserListParams) => {
return requestClient.get<{
limit: number;
list: User[];
page: number;
total: number;
}>('/api/system/users', { params });
};
export const getUserByIdApi = (id: string) => {
return requestClient.get<User>(`/api/system/users/${id}`);
};
export const createUserApi = (data: CreateUserDto) => {
return requestClient.post<User>('/api/system/users', data);
};
export const updateUserApi = (id: string, data: UpdateUserDto) => {
return requestClient.put<User>(`/api/system/users/${id}`, data);
};
export const deleteUserApi = (id: string) => {
return requestClient.delete(`/api/system/users/${id}`);
};
export const batchDeleteAdminApi = (ids: string[]) => {
return requestClient.delete('/admin/user/batch-delete', { data: { ids } });
};
// 管理员相关接口
export interface AdminUser {
id: string;
username: string;
realname: string;
avatar?: string;
email?: string;
mobile?: string;
status: number;
roles: Role[];
created_at: string;
updated_at: string;
}
export interface CreateAdminParams {
username: string;
realname: string;
password: string;
avatar?: string;
email?: string;
mobile?: string;
role_ids: string[];
status: number;
}
export interface UpdateAdminParams {
realname?: string;
avatar?: string;
email?: string;
mobile?: string;
role_ids?: string[];
status?: number;
}
export const getAdminListApi = (params: UserListParams) => {
return requestClient.get('/admin/user/list', {
params,
});
};
export const createAdminApi = (data: CreateAdminParams) => {
return requestClient.post('/admin/user/create', data);
};
export const updateAdminApi = (id: string, data: UpdateAdminParams) => {
return requestClient.put(`/admin/user/update/${id}`, data);
};
export const deleteAdminApi = (id: string) => {
return requestClient.delete(`/admin/user/delete/${id}`);
};
export const setAdminRolesApi = (id: string, role_ids: string[]) => {
return requestClient.put(`/admin/user/roles/${id}`, { role_ids });
};
export const getAdminRolesApi = (id: string) => {
return requestClient.get(`/admin/user/roles/${id}`);
};
export const lockUserApi = (id: string) => {
return requestClient.post(`/api/system/users/${id}/lock`);
};
export const unlockUserApi = (id: string) => {
return requestClient.post(`/api/system/users/${id}/unlock`);
};
export const resetPasswordApi = (id: string, password: string) => {
return requestClient.post(`/api/system/users/${id}/reset-password`, {
password,
});
};
// 角色管理接口
export const getRoleListApi = (params: RoleListParams) => {
return requestClient.get<{
limit: number;
list: Role[];
page: number;
total: number;
}>('/api/system/roles', { params });
};
export const getAllRolesApi = () => {
return requestClient.get<Role[]>('/api/system/roles/all');
};
export const getRoleByIdApi = (id: string) => {
return requestClient.get<Role>(`/api/system/roles/${id}`);
};
export const createRoleApi = (data: CreateRoleDto) => {
return requestClient.post<Role>('/api/system/roles', data);
};
export const updateRoleApi = (id: string, data: UpdateRoleDto) => {
return requestClient.put<Role>(`/api/system/roles/${id}`, data);
};
export const deleteRoleApi = (id: string) => {
return requestClient.delete(`/api/system/roles/${id}`);
};
// 权限管理接口
export const getPermissionListApi = () => {
return requestClient.get<Permission[]>('/api/system/permissions');
};
export const getPermissionTreeApi = () => {
return requestClient.get<Permission[]>('/api/system/permissions/tree');
};
// 菜单管理接口
export const getMenuListApi = (params: MenuListParams) => {
return requestClient.get<Menu[]>('/api/system/menus', { params });
};
export const getMenuTreeApi = () => {
return requestClient.get<Menu[]>('/api/system/menus/tree');
};
export const getMenuByIdApi = (id: string) => {
return requestClient.get<Menu>(`/api/system/menus/${id}`);
};
export const createMenuApi = (data: CreateMenuDto) => {
return requestClient.post<Menu>('/api/system/menus', data);
};
export const updateMenuApi = (id: string, data: UpdateMenuDto) => {
return requestClient.put<Menu>(`/api/system/menus/${id}`, data);
};
export const deleteMenuApi = (id: string) => {
return requestClient.delete(`/api/system/menus/${id}`);
};
// 获取用户菜单(用于导航)
export const getUserMenusApi = () => {
return requestClient.get<Menu[]>('/api/system/menus/user');
};

View File

@@ -0,0 +1,350 @@
import { requestClient } from '#/api/request';
// 文件上传相关接口
export interface UploadFile {
id: string;
original_name: string;
filename: string;
path: string;
url: string;
mime_type: string;
size: number;
driver: string;
created_at: string;
}
export interface UploadResponse {
file: UploadFile;
url: string;
}
// 字典相关接口
export interface Dictionary {
id: string;
parent_id?: string;
name: string;
code: string;
value?: string;
sort: number;
status: number;
remark?: string;
children?: Dictionary[];
created_at: string;
updated_at: string;
}
export interface CreateDictionaryDto {
parent_id?: string;
name: string;
code: string;
value?: string;
sort: number;
status: number;
remark?: string;
}
export interface UpdateDictionaryDto {
parent_id?: string;
name?: string;
code?: string;
value?: string;
sort?: number;
status?: number;
remark?: string;
}
export interface DictionaryListParams {
page?: number;
limit?: number;
name?: string;
code?: string;
status?: number;
parent_id?: string;
}
// 系统日志相关接口
export interface SystemLog {
id: string;
user_id?: string;
username?: string;
action: string;
method: string;
url: string;
ip: string;
user_agent: string;
request_data?: any;
response_data?: any;
status_code: number;
duration: number;
created_at: string;
}
export interface LogListParams {
page?: number;
limit?: number;
user_id?: string;
username?: string;
action?: string;
method?: string;
status_code?: number;
start_date?: string;
end_date?: string;
}
// 系统信息接口
export interface SystemInfo {
server: {
arch: string;
memory_usage: {
external: number;
heapTotal: number;
heapUsed: number;
rss: number;
};
node_version: string;
os: string;
uptime: number;
};
database: {
size: string;
type: string;
version: string;
};
redis?: {
connected_clients: number;
memory: string;
version: string;
};
application: {
environment: string;
name: string;
timezone: string;
version: string;
};
}
// API 接口定义
// 文件上传接口
export const uploadFileApi = (file: File, type?: string) => {
const formData = new FormData();
formData.append('file', file);
if (type) {
formData.append('type', type);
}
return requestClient.post<UploadResponse>('/api/system/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
export const uploadMultipleFilesApi = (files: File[], type?: string) => {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
if (type) {
formData.append('type', type);
}
return requestClient.post<UploadResponse[]>(
'/api/system/upload/multiple',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
};
export const getFileListApi = (params: {
filename?: string;
limit?: number;
page?: number;
type?: string;
}) => {
return requestClient.get<{
limit: number;
list: UploadFile[];
page: number;
total: number;
}>('/api/system/files', { params });
};
export const deleteFileApi = (id: string) => {
return requestClient.delete(`/api/system/files/${id}`);
};
// 字典管理接口
export const getDictionaryListApi = (params: DictionaryListParams) => {
return requestClient.get<{
limit: number;
list: Dictionary[];
page: number;
total: number;
}>('/api/system/dictionaries', { params });
};
export const getDictionaryTreeApi = (code?: string) => {
return requestClient.get<Dictionary[]>('/api/system/dictionaries/tree', {
params: { code },
});
};
export const getDictionaryByIdApi = (id: string) => {
return requestClient.get<Dictionary>(`/api/system/dictionaries/${id}`);
};
export const getDictionaryByCodeApi = (code: string) => {
return requestClient.get<Dictionary[]>(
`/api/system/dictionaries/code/${code}`,
);
};
export const createDictionaryApi = (data: CreateDictionaryDto) => {
return requestClient.post<Dictionary>('/api/system/dictionaries', data);
};
export const updateDictionaryApi = (id: string, data: UpdateDictionaryDto) => {
return requestClient.put<Dictionary>(`/api/system/dictionaries/${id}`, data);
};
export const deleteDictionaryApi = (id: string) => {
return requestClient.delete(`/api/system/dictionaries/${id}`);
};
// 系统日志接口
export const getSystemLogListApi = (params: LogListParams) => {
return requestClient.get<{
limit: number;
list: SystemLog[];
page: number;
total: number;
}>('/api/system/logs', { params });
};
export const getSystemLogByIdApi = (id: string) => {
return requestClient.get<SystemLog>(`/api/system/logs/${id}`);
};
export const clearSystemLogsApi = (days?: number) => {
return requestClient.delete('/api/system/logs/clear', {
params: { days },
});
};
export const exportSystemLogsApi = (params: LogListParams) => {
return requestClient.get('/api/system/logs/export', {
params,
responseType: 'blob',
});
};
// 系统信息接口
export const getSystemInfoApi = () => {
return requestClient.get<SystemInfo>('/api/system/info');
};
// 健康检查接口
export const getHealthCheckApi = () => {
return requestClient.get<{
checks: {
database: { responseTime?: number; status: 'down' | 'up' };
disk: { status: 'critical' | 'ok' | 'warning'; usage: number };
memory: { status: 'critical' | 'ok' | 'warning'; usage: number };
redis?: { responseTime?: number; status: 'down' | 'up' };
};
status: 'error' | 'ok';
timestamp: string;
uptime: number;
}>('/api/system/health');
};
// 缓存管理接口
export const getCacheInfoApi = () => {
return requestClient.get<{
hits: number;
keys: number;
memory: string;
misses: number;
}>('/api/system/cache/info');
};
export const clearCacheApi = (pattern?: string) => {
return requestClient.delete('/api/system/cache/clear', {
params: { pattern },
});
};
// 系统配置接口
export const getSystemConfigApi = () => {
return requestClient.get<Record<string, any>>('/api/system/config');
};
export const updateSystemConfigApi = (data: Record<string, any>) => {
return requestClient.put('/api/system/config', data);
};
// 数据备份接口
export const createBackupApi = () => {
return requestClient.post<{
created_at: string;
filename: string;
size: number;
}>('/api/system/backup');
};
export const getBackupListApi = () => {
return requestClient.get<
{
created_at: string;
filename: string;
size: number;
}[]
>('/api/system/backup/list');
};
export const downloadBackupApi = (filename: string) => {
return requestClient.get(`/api/system/backup/download/${filename}`, {
responseType: 'blob',
});
};
export const deleteBackupApi = (filename: string) => {
return requestClient.delete(`/api/system/backup/${filename}`);
};
// 系统通知接口
export interface SystemNotification {
id: string;
title: string;
content: string;
type: 'error' | 'info' | 'success' | 'warning';
is_read: boolean;
created_at: string;
}
export const getNotificationListApi = (params: {
is_read?: boolean;
limit?: number;
page?: number;
}) => {
return requestClient.get<{
list: SystemNotification[];
total: number;
unread_count: number;
}>('/api/system/notifications', { params });
};
export const markNotificationReadApi = (id: string) => {
return requestClient.post(`/api/system/notifications/${id}/read`);
};
export const markAllNotificationsReadApi = () => {
return requestClient.post('/api/system/notifications/read-all');
};
export const deleteNotificationApi = (id: string) => {
return requestClient.delete(`/api/system/notifications/${id}`);
};

View File

@@ -0,0 +1,141 @@
import { requestClient } from '#/api/request';
/**
* 邮件配置接口
*/
export interface EmailConfig {
// SMTP配置
smtp_host?: string;
smtp_port?: number;
smtp_username?: string;
smtp_password?: string;
smtp_from_email?: string;
smtp_from_name?: string;
smtp_encryption?: string;
smtp_enabled?: boolean;
// 邮件模板配置
register_subject?: string;
register_content?: string;
register_enabled?: boolean;
reset_subject?: string;
reset_content?: string;
reset_enabled?: boolean;
notify_subject?: string;
notify_content?: string;
notify_enabled?: boolean;
}
/**
* 邮件模板预览数据
*/
export interface EmailTemplatePreview {
subject: string;
content: string;
}
/**
* 获取邮件配置
* @param type 配置类型 smtp | template
*/
export function getEmailConfigApi(type: string) {
return requestClient.get<EmailConfig>(`/api/common/email/config/${type}`);
}
/**
* 更新邮件配置
* @param type 配置类型 smtp | template
* @param data 配置数据
*/
export function updateEmailConfigApi(type: string, data: Partial<EmailConfig>) {
return requestClient.put(`/api/common/email/config/${type}`, data);
}
/**
* 测试邮件发送
* @param email 收件人邮箱
* @param type 邮件类型 register | reset | notify
*/
export function testEmailApi(email: string, type: string) {
return requestClient.post('/api/common/email/test', {
email,
type,
});
}
/**
* 预览邮件模板
* @param type 模板类型 register | reset | notify
*/
export function previewEmailTemplateApi(type: string) {
return requestClient.get<EmailTemplatePreview>(`/api/common/email/template/preview/${type}`);
}
/**
* 重置邮件配置
* @param type 配置类型 smtp | template
*/
export function resetEmailConfigApi(type: string) {
return requestClient.post(`/api/common/email/config/reset/${type}`);
}
/**
* 获取邮件发送统计
*/
export function getEmailStatsApi() {
return requestClient.get('/api/common/email/stats');
}
/**
* 获取邮件发送日志
* @param params 查询参数
*/
export function getEmailLogsApi(params?: {
page?: number;
limit?: number;
type?: string;
status?: string;
start_date?: string;
end_date?: string;
}) {
return requestClient.get('/api/common/email/logs', { params });
}
/**
* 清空邮件发送队列
*/
export function clearEmailQueueApi() {
return requestClient.post('/api/common/email/queue/clear');
}
/**
* 重试失败的邮件
* @param id 邮件ID
*/
export function retryEmailApi(id: number) {
return requestClient.post(`/api/common/email/retry/${id}`);
}
/**
* 批量重试失败的邮件
* @param ids 邮件ID数组
*/
export function batchRetryEmailApi(ids: number[]) {
return requestClient.post('/api/common/email/batch-retry', { ids });
}
/**
* 删除邮件日志
* @param id 邮件ID
*/
export function deleteEmailLogApi(id: number) {
return requestClient.delete(`/api/common/email/log/${id}`);
}
/**
* 批量删除邮件日志
* @param ids 邮件ID数组
*/
export function batchDeleteEmailLogApi(ids: number[]) {
return requestClient.delete('/api/common/email/logs', { data: { ids } });
}

View File

@@ -0,0 +1,10 @@
// 统一导出系统管理相关的所有API接口
// 认证授权相关
export * from './auth';
// 通用功能相关
export * from './common';
// 系统设置相关
export * from './settings';

View File

@@ -0,0 +1,211 @@
import { requestClient } from '#/api/request'
// 登录配置接口
export interface LoginConfig {
methods?: {
username_enabled: boolean
mobile_enabled: boolean
email_enabled: boolean
sms_enabled: boolean
oauth_enabled: boolean
guest_enabled: boolean
}
security?: {
captcha_enabled: boolean
captcha_type: 'image' | 'slide' | 'click'
max_fail_attempts: number
lock_duration: number
password_strength_enabled: boolean
password_min_length: number
password_complexity: string[]
password_expire_enabled: boolean
password_expire_days: number
single_sign_on_enabled: boolean
}
oauth?: {
wechat_enabled: boolean
wechat_app_id: string
wechat_secret: string
wechat_redirect_uri: string
qq_enabled: boolean
qq_app_id: string
qq_secret: string
qq_redirect_uri: string
github_enabled: boolean
github_client_id: string
github_secret: string
github_redirect_uri: string
}
register?: {
register_enabled: boolean
register_methods: string[]
register_verification: 'none' | 'email' | 'sms' | 'manual'
register_captcha_enabled: boolean
agreement_required: boolean
agreement_content: string
default_role: string
reward_enabled: boolean
reward_points: number
reward_balance: number
}
}
// 登录统计接口
export interface LoginStats {
total_logins: number
today_logins: number
failed_logins: number
locked_accounts: number
oauth_logins: number
guest_logins: number
}
// 登录记录接口
export interface LoginRecord {
id: number
user_id: number
username: string
login_type: 'username' | 'mobile' | 'email' | 'sms' | 'oauth' | 'guest'
login_ip: string
login_location: string
user_agent: string
login_time: string
logout_time?: string
status: 'success' | 'failed' | 'locked'
fail_reason?: string
}
// 在线用户接口
export interface OnlineUser {
id: number
user_id: number
username: string
nickname: string
avatar: string
login_ip: string
login_location: string
login_time: string
last_activity: string
device_type: string
browser: string
os: string
}
// 获取登录配置
export function getLoginConfigApi() {
return requestClient.get<LoginConfig>('/api/common/login/config')
}
// 更新登录配置
export function updateLoginConfigApi(data: {
type: 'methods' | 'security' | 'oauth' | 'register'
config: any
}) {
return requestClient.put('/api/common/login/config', data)
}
// 重置登录配置
export function resetLoginConfigApi(type: 'methods' | 'security' | 'oauth' | 'register') {
return requestClient.post('/api/common/login/config/reset', { type })
}
// 测试登录配置
export function testLoginConfigApi(data: {
type: 'captcha' | 'oauth' | 'sms'
config: any
}) {
return requestClient.post('/api/common/login/config/test', data)
}
// 获取登录统计
export function getLoginStatsApi() {
return requestClient.get<LoginStats>('/api/common/login/stats')
}
// 获取登录记录
export function getLoginRecordsApi(params?: {
page?: number
limit?: number
user_id?: number
login_type?: string
status?: string
start_time?: string
end_time?: string
}) {
return requestClient.get<{
list: LoginRecord[]
total: number
page: number
limit: number
}>('/api/common/login/records', { params })
}
// 获取在线用户
export function getOnlineUsersApi(params?: {
page?: number
limit?: number
username?: string
device_type?: string
}) {
return requestClient.get<{
list: OnlineUser[]
total: number
page: number
limit: number
}>('/api/common/login/online', { params })
}
// 强制下线用户
export function forceLogoutApi(userId: number) {
return requestClient.post('/api/common/login/force-logout', { user_id: userId })
}
// 批量强制下线用户
export function batchForceLogoutApi(userIds: number[]) {
return requestClient.post('/api/common/login/batch-force-logout', { user_ids: userIds })
}
// 解锁账户
export function unlockAccountApi(userId: number) {
return requestClient.post('/api/common/login/unlock-account', { user_id: userId })
}
// 批量解锁账户
export function batchUnlockAccountApi(userIds: number[]) {
return requestClient.post('/api/common/login/batch-unlock-account', { user_ids: userIds })
}
// 清理登录记录
export function cleanLoginRecordsApi(data: {
days?: number
status?: string
}) {
return requestClient.post('/api/common/login/clean-records', data)
}
// 导出登录记录
export function exportLoginRecordsApi(params?: {
user_id?: number
login_type?: string
status?: string
start_time?: string
end_time?: string
}) {
return requestClient.get('/api/common/login/export-records', {
params,
responseType: 'blob'
})
}
// 验证登录配置
export function validateLoginConfigApi(data: {
type: 'methods' | 'security' | 'oauth' | 'register'
config: any
}) {
return requestClient.post('/api/common/login/config/validate', data)
}
// 获取登录配置模板
export function getLoginConfigTemplateApi(type: 'methods' | 'security' | 'oauth' | 'register') {
return requestClient.get(`/api/common/login/config/template/${type}`)
}

View File

@@ -0,0 +1,220 @@
import { requestClient } from '#/api/request'
// 支付配置接口
export interface PaymentConfig {
// 支付宝配置
alipay?: {
alipay_app_id?: string
alipay_gateway_url?: string
alipay_private_key?: string
alipay_public_key?: string
alipay_sign_type?: string
alipay_charset?: string
alipay_notify_url?: string
alipay_return_url?: string
alipay_enabled?: boolean
}
// 微信支付配置
wechat?: {
wechat_app_id?: string
wechat_mch_id?: string
wechat_key?: string
wechat_secret?: string
wechat_cert_path?: string
wechat_key_path?: string
wechat_notify_url?: string
wechat_trade_type?: string
wechat_enabled?: boolean
}
// 通用配置
general?: {
default_method?: string
timeout?: number
min_amount?: number
max_amount?: number
success_url?: string
fail_url?: string
balance_enabled?: boolean
points_enabled?: boolean
points_ratio?: number
}
}
// 支付统计接口
export interface PaymentStats {
total_amount: number
total_count: number
success_count: number
fail_count: number
pending_count: number
today_amount: number
today_count: number
alipay_amount: number
alipay_count: number
wechat_amount: number
wechat_count: number
balance_amount: number
balance_count: number
}
// 支付记录接口
export interface PaymentRecord {
id: number
order_id: string
user_id: number
method: string
amount: number
status: string
trade_no?: string
transaction_id?: string
subject: string
body?: string
notify_data?: any
created_at: string
updated_at: string
user?: {
id: number
username: string
nickname?: string
}
}
// 退款记录接口
export interface RefundRecord {
id: number
payment_id: number
refund_no: string
amount: number
reason: string
status: string
refund_data?: any
created_at: string
updated_at: string
payment?: PaymentRecord
}
// 测试支付参数
export interface TestPaymentParams {
method: string
amount: number
subject: string
body?: string
}
// 测试支付结果
export interface TestPaymentResult {
order_id: string
payUrl?: string
qrCode?: string
message: string
}
// 获取支付配置
export function getPaymentConfigApi() {
return requestClient.get<PaymentConfig>('/admin/settings/payment')
}
// 更新支付配置
export function updatePaymentConfigApi(data: {
type: 'alipay' | 'wechat' | 'general'
config: any
}) {
return requestClient.put('/admin/settings/payment', data)
}
// 测试支付
export function testPaymentApi(data: TestPaymentParams) {
return requestClient.post<TestPaymentResult>('/admin/payment/test', data)
}
// 重置支付配置
export function resetPaymentConfigApi(type: 'alipay' | 'wechat' | 'general') {
return requestClient.delete(`/admin/settings/payment/${type}`)
}
// 获取支付统计
export function getPaymentStatsApi(params?: {
start_date?: string
end_date?: string
method?: string
}) {
return requestClient.get<PaymentStats>('/admin/payment/stats', { params })
}
// 获取支付记录
export function getPaymentRecordsApi(params?: {
page?: number
limit?: number
method?: string
status?: string
user_id?: number
order_id?: string
start_date?: string
end_date?: string
}) {
return requestClient.get<{
data: PaymentRecord[]
total: number
page: number
limit: number
}>('/admin/payment/records', { params })
}
// 获取退款记录
export function getRefundRecordsApi(params?: {
page?: number
limit?: number
status?: string
payment_id?: number
start_date?: string
end_date?: string
}) {
return requestClient.get<{
data: RefundRecord[]
total: number
page: number
limit: number
}>('/admin/payment/refunds', { params })
}
// 处理退款
export function processRefundApi(data: {
payment_id: number
amount: number
reason: string
}) {
return requestClient.post('/admin/payment/refund', data)
}
// 查询支付状态
export function queryPaymentStatusApi(orderId: string) {
return requestClient.get(`/admin/payment/query/${orderId}`)
}
// 同步支付状态
export function syncPaymentStatusApi(paymentId: number) {
return requestClient.post(`/admin/payment/sync/${paymentId}`)
}
// 批量同步支付状态
export function batchSyncPaymentStatusApi(paymentIds: number[]) {
return requestClient.post('/admin/payment/batch-sync', { payment_ids: paymentIds })
}
// 关闭支付订单
export function closePaymentOrderApi(orderId: string) {
return requestClient.post(`/admin/payment/close/${orderId}`)
}
// 验证支付配置
export function validatePaymentConfigApi(data: {
type: 'alipay' | 'wechat'
config: any
}) {
return requestClient.post('/admin/payment/validate', data)
}
// 获取支付方式模板
export function getPaymentMethodTemplateApi(method: 'alipay' | 'wechat') {
return requestClient.get(`/admin/payment/template/${method}`)
}

View File

@@ -0,0 +1,229 @@
import { requestClient } from '#/api/request';
// 安全配置接口
export interface SecurityConfig {
password?: {
enablePasswordStrength: boolean;
minPasswordLength: number;
requireLowercase: boolean;
requireUppercase: boolean;
requireNumbers: boolean;
requireSpecialChars: boolean;
forbidCommonPasswords: boolean;
passwordExpireDays: number;
passwordHistoryLimit: number;
forcePasswordChange: boolean;
};
login?: {
maxLoginAttempts: number;
lockoutDuration: number;
enableLoginCaptcha: boolean;
captchaTriggerAttempts: number;
enableTwoFactor: boolean;
forceTwoFactor: boolean;
sessionTimeout: number;
enableSingleSignOn: boolean;
recordLoginLog: boolean;
};
ip?: {
enableIpControl: boolean;
accessMode: 'whitelist' | 'blacklist';
ipWhitelist: string[];
ipBlacklist: string[];
adminIpWhitelist: string[];
};
audit?: {
enableAudit: boolean;
auditLoginLogout: boolean;
auditUserManagement: boolean;
auditRoleManagement: boolean;
auditPermissionManagement: boolean;
auditSystemConfig: boolean;
auditDataExport: boolean;
auditFileUpload: boolean;
auditSensitiveOperations: boolean;
auditLogRetention: number;
enableSecondaryConfirm: boolean;
confirmDeleteUser: boolean;
confirmResetPassword: boolean;
confirmModifyRole: boolean;
confirmSystemBackup: boolean;
confirmSystemRestore: boolean;
confirmClearData: boolean;
enableAnomalyDetection: boolean;
};
}
// 安全统计接口
export interface SecurityStats {
totalLoginAttempts: number;
failedLoginAttempts: number;
lockedAccounts: number;
activeAuditLogs: number;
blockedIpCount: number;
securityEvents: {
date: string;
loginAttempts: number;
failedLogins: number;
securityAlerts: number;
}[];
}
// 安全日志接口
export interface SecurityLog {
id: string;
userId: string;
username: string;
action: string;
resource: string;
ip: string;
userAgent: string;
result: 'success' | 'failed' | 'blocked';
riskLevel: 'low' | 'medium' | 'high';
details: string;
createdAt: string;
}
// IP测试结果接口
export interface IpTestResult {
ip: string;
allowed: boolean;
reason: string;
matchedRule?: string;
}
// 更新安全配置参数
export interface UpdateSecurityConfigParams {
type: 'password' | 'login' | 'ip' | 'audit';
config: any;
}
// 获取安全配置
export function getSecurityConfigApi() {
return requestClient.get<SecurityConfig>('/api/common/security/config');
}
// 更新安全配置
export function updateSecurityConfigApi(data: UpdateSecurityConfigParams) {
return requestClient.put('/api/common/security/config', data);
}
// 重置安全配置
export function resetSecurityConfigApi(type: string) {
return requestClient.post(`/api/common/security/config/reset/${type}`);
}
// 测试安全配置
export function testSecurityConfigApi(type: string, config: any) {
return requestClient.post(`/api/common/security/config/test/${type}`, { config });
}
// 获取安全统计
export function getSecurityStatsApi(params?: {
startDate?: string;
endDate?: string;
type?: string;
}) {
return requestClient.get<SecurityStats>('/api/common/security/stats', { params });
}
// 获取安全日志
export function getSecurityLogsApi(params?: {
page?: number;
pageSize?: number;
userId?: string;
action?: string;
result?: string;
riskLevel?: string;
startDate?: string;
endDate?: string;
ip?: string;
}) {
return requestClient.get<{
list: SecurityLog[];
total: number;
page: number;
pageSize: number;
}>('/api/common/security/logs', { params });
}
// 清理安全日志
export function cleanSecurityLogsApi(params: {
beforeDate: string;
logType?: string;
}) {
return requestClient.delete('/api/common/security/logs/clean', { data: params });
}
// 导出安全日志
export function exportSecurityLogsApi(params?: {
startDate?: string;
endDate?: string;
format?: 'excel' | 'csv';
userId?: string;
action?: string;
}) {
return requestClient.post('/api/common/security/logs/export', params, {
responseType: 'blob',
});
}
// 测试IP访问
export function testIpAccessApi() {
return requestClient.get<IpTestResult>('/api/common/security/ip/test');
}
// 解锁账户
export function unlockAccountApi(userId: string) {
return requestClient.post(`/api/common/security/account/unlock/${userId}`);
}
// 批量解锁账户
export function batchUnlockAccountApi(userIds: string[]) {
return requestClient.post('/api/common/security/account/unlock/batch', { userIds });
}
// 强制下线用户
export function forceLogoutUserApi(userId: string) {
return requestClient.post(`/api/common/security/session/logout/${userId}`);
}
// 批量强制下线用户
export function batchForceLogoutUserApi(userIds: string[]) {
return requestClient.post('/api/common/security/session/logout/batch', { userIds });
}
// 获取在线用户
export function getOnlineUsersApi(params?: {
page?: number;
pageSize?: number;
username?: string;
ip?: string;
}) {
return requestClient.get('/api/common/security/session/online', { params });
}
// 验证安全配置
export function validateSecurityConfigApi(type: string, config: any) {
return requestClient.post(`/api/common/security/config/validate/${type}`, { config });
}
// 获取安全配置模板
export function getSecurityConfigTemplateApi(type: string) {
return requestClient.get(`/api/common/security/config/template/${type}`);
}
// 安全扫描
export function securityScanApi() {
return requestClient.post('/api/common/security/scan');
}
// 获取安全建议
export function getSecuritySuggestionsApi() {
return requestClient.get('/api/common/security/suggestions');
}
// 应用安全建议
export function applySecuritySuggestionApi(suggestionId: string) {
return requestClient.post(`/api/common/security/suggestions/apply/${suggestionId}`);
}

View File

@@ -0,0 +1,314 @@
import { requestClient } from '#/api/request';
// 基础设置接口
export interface BasicSettings {
site_name: string;
site_title: string;
site_keywords: string;
site_description: string;
site_logo: string;
site_icon: string;
site_icp: string;
site_copyright: string;
site_status: boolean;
site_close_reason: string;
}
// 邮件设置接口
export interface EmailSettings {
enabled: boolean;
driver: 'mailgun' | 'sendmail' | 'ses' | 'smtp';
smtp_host: string;
smtp_port: number;
smtp_username: string;
smtp_password: string;
smtp_encryption: 'none' | 'ssl' | 'tls';
from_email: string;
from_name: string;
}
// 短信设置接口
export interface SmsSettings {
enabled: boolean;
driver: 'aliyun' | 'huawei' | 'qiniu' | 'tencent';
access_key_id: string;
access_key_secret: string;
sign_name: string;
template_code: string;
code_length: number;
code_expire: number;
rate_limit: number;
test_mobile: string;
}
// 存储设置接口
export interface StorageSettings {
default_driver: 'aliyun_oss' | 'aws_s3' | 'local' | 'qiniu' | 'tencent_cos';
max_file_size: number;
allowed_extensions: string[];
local: {
base_url: string;
storage_path: string;
};
aliyun_oss: {
access_key_id: string;
access_key_secret: string;
bucket: string;
custom_domain?: string;
endpoint: string;
};
tencent_cos: {
bucket: string;
custom_domain?: string;
region: string;
secret_id: string;
secret_key: string;
};
qiniu: {
access_key: string;
bucket: string;
domain: string;
secret_key: string;
};
aws_s3: {
access_key_id: string;
bucket: string;
custom_domain?: string;
region: string;
secret_access_key: string;
};
}
// 支付设置接口
export interface PaymentSettings {
alipay: {
app_id: string;
enabled: boolean;
mode: 'production' | 'sandbox';
notify_url?: string;
private_key: string;
public_key: string;
return_url?: string;
};
wechat: {
api_key: string;
app_id: string;
cert_path?: string;
enabled: boolean;
key_path?: string;
mch_id: string;
mode: 'production' | 'sandbox';
notify_url?: string;
};
unionpay: {
cert_password: string;
cert_path: string;
enabled: boolean;
mer_id: string;
mode: 'production' | 'sandbox';
notify_url?: string;
return_url?: string;
};
}
// 登录设置接口
export interface LoginSettings {
login_methods: {
email: boolean;
github: boolean;
mobile: boolean;
qq: boolean;
username: boolean;
wechat: boolean;
};
captcha: {
enabled: boolean;
expire: number;
length: number;
type: 'email' | 'image' | 'sms';
};
password_policy: {
min_length: number;
require_lowercase: boolean;
require_numbers: boolean;
require_symbols: boolean;
require_uppercase: boolean;
};
session: {
max_sessions: number;
remember_me: boolean;
timeout: number;
};
security: {
force_logout_on_password_change: boolean;
lockout_duration: number;
max_login_attempts: number;
};
}
// API 接口定义
// 基础设置
export const getBasicSettingsApi = () => {
return requestClient.get<BasicSettings>('/api/system/settings/basic');
};
export const updateBasicSettingsApi = (data: Partial<BasicSettings>) => {
return requestClient.put<BasicSettings>('/api/system/settings/basic', data);
};
// 邮件设置
export const getEmailSettingsApi = () => {
return requestClient.get<EmailSettings>('/api/system/settings/email');
};
export const updateEmailSettingsApi = (data: Partial<EmailSettings>) => {
return requestClient.put<EmailSettings>('/api/system/settings/email', data);
};
export const testEmailApi = (email: string) => {
return requestClient.post('/api/system/settings/email/test', { email });
};
// 短信设置
export const getSmsSettingsApi = () => {
return requestClient.get<SmsSettings>('/api/system/settings/sms');
};
export const updateSmsSettingsApi = (data: Partial<SmsSettings>) => {
return requestClient.put<SmsSettings>('/api/system/settings/sms', data);
};
export const testSmsApi = (mobile: string) => {
return requestClient.post('/api/system/settings/sms/test', { mobile });
};
// 存储设置
export const getStorageSettingsApi = () => {
return requestClient.get<StorageSettings>('/api/system/settings/storage');
};
export const updateStorageSettingsApi = (data: Partial<StorageSettings>) => {
return requestClient.put<StorageSettings>(
'/api/system/settings/storage',
data,
);
};
export const testStorageConnectionApi = (driver: string) => {
return requestClient.post('/api/system/settings/storage/test', { driver });
};
// 支付设置
export const getPaymentSettingsApi = () => {
return requestClient.get<PaymentSettings>('/api/system/settings/payment');
};
export const updatePaymentSettingsApi = (data: Partial<PaymentSettings>) => {
return requestClient.put<PaymentSettings>(
'/api/system/settings/payment',
data,
);
};
// 登录设置
export const getLoginSettingsApi = () => {
return requestClient.get<LoginSettings>('/api/system/settings/login');
};
export const updateLoginSettingsApi = (data: Partial<LoginSettings>) => {
return requestClient.put<LoginSettings>('/api/system/settings/login', data);
};
// 获取所有设置
export const getAllSettingsApi = () => {
return requestClient.get<{
basic: BasicSettings;
email: EmailSettings;
login: LoginSettings;
payment: PaymentSettings;
sms: SmsSettings;
storage: StorageSettings;
}>('/api/system/settings');
};
// 重置设置到默认值
export const resetSettingsApi = (
type: 'basic' | 'email' | 'login' | 'payment' | 'sms' | 'storage',
) => {
return requestClient.post(`/api/system/settings/${type}/reset`);
};
// 导出设置
export const exportSettingsApi = () => {
return requestClient.get('/api/system/settings/export', {
responseType: 'blob',
});
};
// 导入设置
export const importSettingsApi = (file: File) => {
const formData = new FormData();
formData.append('file', file);
return requestClient.post('/api/system/settings/import', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
};
// 通知设置接口
export interface NotificationSettings {
email: {
enabled: boolean;
types: string[];
adminEmails: string[];
rateLimit: number;
retryTimes: number;
queueDelay: number;
};
sms: {
enabled: boolean;
types: string[];
adminPhones: string[];
rateLimit: number;
retryTimes: number;
queueDelay: number;
};
system: {
enabled: boolean;
types: string[];
retentionDays: number;
maxPerUser: number;
autoRead: boolean;
};
wechat: {
enabled: boolean;
types: string[];
templateIds: Record<string, string>;
retryTimes: number;
queueDelay: number;
};
}
export interface UpdateNotificationSettingsParams {
type: 'email' | 'sms' | 'system' | 'wechat';
settings: Partial<NotificationSettings[keyof NotificationSettings]>;
}
// 通知设置API
export const getNotificationSettingsApi = () => {
return requestClient.get<NotificationSettings>('/api/system/settings/notification');
};
export const updateNotificationSettingsApi = (data: UpdateNotificationSettingsParams) => {
return requestClient.put<NotificationSettings>('/api/system/settings/notification', data);
};
export const resetNotificationSettingsApi = (type: 'email' | 'sms' | 'system' | 'wechat') => {
return requestClient.post(`/api/system/settings/notification/${type}/reset`);
};
export const testNotificationApi = (type: 'email' | 'sms' | 'system' | 'wechat') => {
return requestClient.post(`/api/system/settings/notification/${type}/test`);
};

View File

@@ -0,0 +1,184 @@
import { requestClient } from '#/api/request';
// 短信配置接口
export interface SmsConfig {
provider: 'aliyun' | 'tencent';
access_key_id?: string;
access_key_secret?: string;
sign_name?: string;
region?: string;
secret_id?: string;
secret_key?: string;
app_id?: string;
sign_content?: string;
enabled: boolean;
debug_mode: boolean;
templates: SmsTemplate[];
rate_limit: {
per_minute: number;
per_hour: number;
per_day: number;
};
}
// 短信模板接口
export interface SmsTemplate {
type: 'verify_code' | 'notification' | 'marketing';
template_id: string;
content: string;
variables?: string;
}
// 短信统计接口
export interface SmsStats {
total_sent: number;
success_count: number;
failed_count: number;
today_sent: number;
this_month_sent: number;
}
// 短信日志接口
export interface SmsLog {
id: number;
mobile: string;
content: string;
template_type: string;
status: 'pending' | 'sent' | 'failed';
error_message?: string;
provider: string;
created_at: string;
sent_at?: string;
}
/**
* 获取短信配置
*/
export async function getSmsConfigApi(): Promise<SmsConfig> {
return requestClient.get('/api/admin/settings/sms');
}
/**
* 更新短信配置
*/
export async function updateSmsConfigApi(data: SmsConfig): Promise<void> {
return requestClient.put('/api/admin/settings/sms', data);
}
/**
* 测试短信发送
*/
export async function testSmsApi(data: {
mobile: string;
template_type: string;
content: string;
}): Promise<void> {
return requestClient.post('/api/admin/settings/sms/test', data);
}
/**
* 重置短信配置
*/
export async function resetSmsConfigApi(): Promise<void> {
return requestClient.post('/api/admin/settings/sms/reset');
}
/**
* 获取短信统计
*/
export async function getSmsStatsApi(): Promise<SmsStats> {
return requestClient.get('/api/admin/settings/sms/stats');
}
/**
* 获取短信日志
*/
export async function getSmsLogsApi(params?: {
page?: number;
limit?: number;
mobile?: string;
status?: string;
start_date?: string;
end_date?: string;
}): Promise<{
data: SmsLog[];
total: number;
page: number;
limit: number;
}> {
return requestClient.get('/api/admin/settings/sms/logs', { params });
}
/**
* 清空短信队列
*/
export async function clearSmsQueueApi(): Promise<void> {
return requestClient.post('/api/admin/settings/sms/clear-queue');
}
/**
* 重试发送短信
*/
export async function retrySmsApi(id: number): Promise<void> {
return requestClient.post(`/api/admin/settings/sms/retry/${id}`);
}
/**
* 批量重试发送短信
*/
export async function batchRetrySmsApi(ids: number[]): Promise<void> {
return requestClient.post('/api/admin/settings/sms/batch-retry', { ids });
}
/**
* 删除短信日志
*/
export async function deleteSmsLogApi(id: number): Promise<void> {
return requestClient.delete(`/api/admin/settings/sms/logs/${id}`);
}
/**
* 批量删除短信日志
*/
export async function batchDeleteSmsLogApi(ids: number[]): Promise<void> {
return requestClient.post('/api/admin/settings/sms/logs/batch-delete', { ids });
}
/**
* 获取短信模板预览
*/
export async function previewSmsTemplateApi(data: {
template_id: string;
variables: Record<string, any>;
}): Promise<{
content: string;
preview: string;
}> {
return requestClient.post('/api/admin/settings/sms/template/preview', data);
}
/**
* 验证短信配置
*/
export async function validateSmsConfigApi(data: Partial<SmsConfig>): Promise<{
valid: boolean;
errors: string[];
}> {
return requestClient.post('/api/admin/settings/sms/validate', data);
}
/**
* 获取短信服务商配置模板
*/
export async function getSmsProviderTemplateApi(provider: string): Promise<{
fields: Array<{
key: string;
label: string;
type: string;
required: boolean;
placeholder?: string;
options?: Array<{ label: string; value: string }>;
}>;
}> {
return requestClient.get(`/api/admin/settings/sms/provider/${provider}/template`);
}

View File

@@ -0,0 +1,207 @@
import { requestClient } from '#/api/request';
// 存储配置接口
export interface StorageConfig {
driver: 'local' | 'oss' | 'cos' | 'qiniu' | 'upyun' | 's3';
// 本地存储配置
local_path?: string;
local_domain?: string;
// 阿里云OSS配置
oss_access_key_id?: string;
oss_access_key_secret?: string;
oss_bucket?: string;
oss_region?: string;
oss_domain?: string;
oss_is_private?: boolean;
// 腾讯云COS配置
cos_secret_id?: string;
cos_secret_key?: string;
cos_bucket?: string;
cos_region?: string;
// 七牛云配置
qiniu_access_key?: string;
qiniu_secret_key?: string;
qiniu_bucket?: string;
qiniu_domain?: string;
// 又拍云配置
upyun_username?: string;
upyun_password?: string;
upyun_bucket?: string;
upyun_domain?: string;
// AWS S3配置
s3_access_key_id?: string;
s3_secret_access_key?: string;
s3_bucket?: string;
s3_region?: string;
s3_endpoint?: string;
// 上传限制
max_size: number;
allowed_types: string[];
// 图片处理
thumbnail_enabled: boolean;
thumbnail_width?: number;
thumbnail_height?: number;
// 其他设置
enabled: boolean;
is_default: boolean;
}
// 存储统计接口
export interface StorageStats {
total_files: number;
total_size: number;
used_space: string;
available_space: string;
files_by_type: Record<string, number>;
upload_trend: Array<{
date: string;
count: number;
size: number;
}>;
}
// 文件信息接口
export interface FileInfo {
id: string;
name: string;
path: string;
url: string;
size: number;
type: string;
mime_type: string;
driver: string;
created_at: string;
updated_at: string;
}
// 上传结果接口
export interface UploadResult {
success: boolean;
url: string;
path: string;
size: number;
type: string;
name: string;
}
/**
* 获取存储配置
*/
export function getStorageConfigApi() {
return requestClient.get<StorageConfig>('/admin/settings/storage');
}
/**
* 更新存储配置
*/
export function updateStorageConfigApi(data: StorageConfig) {
return requestClient.put('/admin/settings/storage', data);
}
/**
* 测试存储配置
*/
export function testStorageApi(formData: FormData) {
return requestClient.post<UploadResult>('/admin/settings/storage/test', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* 重置存储配置
*/
export function resetStorageConfigApi() {
return requestClient.post('/admin/settings/storage/reset');
}
/**
* 获取存储统计信息
*/
export function getStorageStatsApi() {
return requestClient.get<StorageStats>('/admin/settings/storage/stats');
}
/**
* 获取文件列表
*/
export function getFileListApi(params?: {
page?: number;
limit?: number;
type?: string;
driver?: string;
keyword?: string;
}) {
return requestClient.get<{
list: FileInfo[];
total: number;
page: number;
limit: number;
}>('/admin/files', { params });
}
/**
* 删除文件
*/
export function deleteFileApi(id: string) {
return requestClient.delete(`/admin/files/${id}`);
}
/**
* 批量删除文件
*/
export function batchDeleteFilesApi(ids: string[]) {
return requestClient.delete('/admin/files/batch', { data: { ids } });
}
/**
* 上传文件
*/
export function uploadFileApi(formData: FormData) {
return requestClient.post<UploadResult>('/admin/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* 获取文件详情
*/
export function getFileDetailApi(id: string) {
return requestClient.get<FileInfo>(`/admin/files/${id}`);
}
/**
* 清理无效文件
*/
export function cleanInvalidFilesApi() {
return requestClient.post('/admin/files/clean');
}
/**
* 同步文件信息
*/
export function syncFilesApi() {
return requestClient.post('/admin/files/sync');
}
/**
* 验证存储配置
*/
export function validateStorageConfigApi(data: Partial<StorageConfig>) {
return requestClient.post<{ valid: boolean; message?: string }>(
'/admin/settings/storage/validate',
data
);
}
/**
* 获取存储驱动模板配置
*/
export function getStorageDriverTemplateApi(driver: string) {
return requestClient.get<Partial<StorageConfig>>(
`/admin/settings/storage/template/${driver}`
);
}

View File

@@ -0,0 +1,105 @@
import { requestClient } from '#/api/request';
/**
* 系统配置接口
*/
export interface SystemConfig {
id?: number;
site_id?: number;
config_key: string;
value: string;
status?: number;
create_time?: string;
update_time?: string;
addon?: string;
}
/**
* 系统信息接口
*/
export interface SystemInfo {
os: string;
server: string;
php_version: string;
mysql_version: string;
redis_version: string;
node_version: string;
memory_usage: string;
disk_usage: string;
}
/**
* 基本信息配置
*/
export interface BasicConfig {
site_name: string;
site_title: string;
site_description: string;
site_keywords: string;
site_icp: string;
site_copyright: string;
}
/**
* 系统配置
*/
export interface ConfigSettings {
site_status: string;
site_close_reason?: string;
timezone: string;
default_language: string;
page_size: number;
cache_enabled: boolean;
debug_enabled: boolean;
}
/**
* 获取系统配置
* @param type 配置类型
*/
export async function getSystemConfigApi(type: 'basic' | 'config'): Promise<any> {
return requestClient.get(`/api/system/config/${type}`);
}
/**
* 更新系统配置
* @param type 配置类型
* @param data 配置数据
*/
export async function updateSystemConfigApi(
type: 'basic' | 'config',
data: BasicConfig | ConfigSettings,
): Promise<void> {
return requestClient.put(`/api/system/config/${type}`, data);
}
/**
* 获取系统信息
*/
export async function getSystemInfoApi(): Promise<SystemInfo> {
return requestClient.get('/api/system/info');
}
/**
* 导出系统信息
*/
export async function exportSystemInfoApi(): Promise<void> {
return requestClient.get('/api/system/info/export', {
responseType: 'blob',
});
}
/**
* 重置系统配置
* @param type 配置类型
*/
export async function resetSystemConfigApi(type: 'basic' | 'config'): Promise<void> {
return requestClient.post(`/api/system/config/${type}/reset`);
}
/**
* 清除系统缓存
*/
export async function clearSystemCacheApi(): Promise<void> {
return requestClient.post('/api/system/cache/clear');
}

View File

@@ -0,0 +1,51 @@
import { baseRequestClient, requestClient } from '#/api/request';
export namespace AuthApi {
/** 登录接口参数 */
export interface LoginParams {
password?: string;
username?: string;
}
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
}
export interface RefreshTokenResult {
data: string;
status: number;
}
}
/**
* 登录
*/
export async function loginApi(data: AuthApi.LoginParams) {
return requestClient.post<AuthApi.LoginResult>('/auth/login', data);
}
/**
* 刷新accessToken
*/
export async function refreshTokenApi() {
return baseRequestClient.post<AuthApi.RefreshTokenResult>('/auth/refresh', {
withCredentials: true,
});
}
/**
* 退出登录
*/
export async function logoutApi() {
return baseRequestClient.post('/auth/logout', {
withCredentials: true,
});
}
/**
* 获取用户权限码
*/
export async function getAccessCodesApi() {
return requestClient.get<string[]>('/auth/codes');
}

View File

@@ -0,0 +1,3 @@
export * from './auth';
export * from './menu';
export * from './user';

View File

@@ -0,0 +1,10 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户所有菜单
*/
export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
}

View File

@@ -0,0 +1,10 @@
import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/user/info');
}

View File

@@ -0,0 +1 @@
export * from './core';

View File

@@ -0,0 +1,113 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
});
/**
* 重新认证逻辑
*/
async function doReAuthenticate() {
console.warn('Access token or refresh token is invalid or expired. ');
const accessStore = useAccessStore();
const authStore = useAuthStore();
accessStore.setAccessToken(null);
if (
preferences.app.loginExpiredMode === 'modal' &&
accessStore.isAccessChecked
) {
accessStore.setLoginExpired(true);
} else {
await authStore.logout();
}
}
/**
* 刷新token逻辑
*/
async function doRefreshToken() {
const accessStore = useAccessStore();
const resp = await refreshTokenApi();
const newToken = resp.data;
accessStore.setAccessToken(newToken);
return newToken;
}
function formatToken(token: null | string) {
return token ? `Bearer ${token}` : null;
}
// 请求头处理
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
config.headers.Authorization = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// 处理返回的响应数据格式
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: 'code',
dataField: 'data',
successCode: 0,
}),
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
const errorMessage = responseData?.error ?? responseData?.message ?? '';
// 如果没有错误信息,则会根据状态码进行提示
ElMessage.error(errorMessage || msg);
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });

View File

@@ -0,0 +1,17 @@
<script lang="ts" setup>
import { useElementPlusDesignTokens } from '@vben/hooks';
import { ElConfigProvider } from 'element-plus';
import { elementLocale } from '#/locales';
defineOptions({ name: 'App' });
useElementPlusDesignTokens();
</script>
<template>
<ElConfigProvider :locale="elementLocale">
<RouterView />
</ElConfigProvider>
</template>

View File

@@ -0,0 +1,79 @@
import { createApp, watchEffect } from 'vue';
import { registerAccessDirective } from '@vben/access';
import { registerLoadingDirective } from '@vben/common-ui';
import { preferences } from '@vben/preferences';
import { initStores } from '@vben/stores';
import '@vben/styles';
import '@vben/styles/ele';
import { useTitle } from '@vueuse/core';
import { ElLoading } from 'element-plus';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,
// });
// // 设置抽屉的默认配置
// setDefaultDrawerProps({
// zIndex: 2000,
// });
const app = createApp(App);
// 注册Element Plus提供的v-loading指令
app.directive('loading', ElLoading.directive);
// 注册Vben提供的v-loading和v-spinning指令
registerLoadingDirective(app, {
loading: false, // Vben提供的v-loading指令和Element Plus提供的v-loading指令二选一即可此处false表示不注册Vben提供的v-loading指令
spinning: 'spinning',
});
// 国际化 i18n 配置
await setupI18n(app);
// 配置 pinia-tore
await initStores(app, { namespace });
// 安装权限指令
registerAccessDirective(app);
// 初始化 tippy
const { initTippy } = await import('@vben/common-ui/es/tippy');
initTippy(app);
// 配置路由及路由守卫
app.use(router);
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
app.use(MotionPlugin);
// 动态更新标题
watchEffect(() => {
if (preferences.app.dynamicTitle) {
const routeTitle = router.currentRoute.value.meta?.title;
const pageTitle =
(routeTitle ? `${$t(routeTitle)} - ` : '') + preferences.app.name;
useTitle(pageTitle);
}
});
app.mount('#app');
}
export { bootstrap };

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { AuthPageLayout } from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const appName = computed(() => preferences.app.name);
const logo = computed(() => preferences.logo.source);
</script>
<template>
<AuthPageLayout
:app-name="appName"
:logo="logo"
:page-description="$t('authentication.pageDesc')"
:page-title="$t('authentication.pageTitle')"
>
<!-- 自定义工具栏 -->
<!-- <template #toolbar></template> -->
</AuthPageLayout>
</template>

View File

@@ -0,0 +1,157 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, ref, watch } from 'vue';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { useWatermark } from '@vben/hooks';
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
const notifications = ref<NotificationItem[]>([
{
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
date: '3小时前',
isRead: true,
message: '描述信息描述信息描述信息',
title: '收到了 14 份新周报',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '刚刚',
isRead: false,
message: '描述信息描述信息描述信息',
title: '朱偏右 回复了你',
},
{
avatar: 'https://avatar.vercel.sh/1',
date: '2024-01-01',
isRead: false,
message: '描述信息描述信息描述信息',
title: '曲丽丽 评论了你',
},
{
avatar: 'https://avatar.vercel.sh/satori',
date: '1天前',
isRead: false,
message: '描述信息描述信息描述信息',
title: '代办提醒',
},
]);
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const menus = computed(() => [
{
handler: () => {
openWindow(VBEN_DOC_URL, {
target: '_blank',
});
},
icon: BookOpenText,
text: $t('ui.widgets.document'),
},
{
handler: () => {
openWindow(VBEN_GITHUB_URL, {
target: '_blank',
});
},
icon: MdiGithub,
text: 'GitHub',
},
{
handler: () => {
openWindow(`${VBEN_GITHUB_URL}/issues`, {
target: '_blank',
});
},
icon: CircleHelp,
text: $t('ui.widgets.qa'),
},
]);
const avatar = computed(() => {
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
});
async function handleLogout() {
await authStore.logout(false);
}
function handleNoticeClear() {
notifications.value = [];
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
watch(
() => preferences.app.watermark,
async (enable) => {
if (enable) {
await updateWatermark({
content: `${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
});
} else {
destroyWatermark();
}
},
{
immediate: true,
},
);
</script>
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<template #user-dropdown>
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
/>
</template>
<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
/>
</template>
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
:avatar
>
<LoginForm />
</AuthenticationLoginExpiredModal>
</template>
<template #lock-screen>
<LockScreen :avatar @to-login="handleLogout" />
</template>
</BasicLayout>
</template>

View File

@@ -0,0 +1,6 @@
const BasicLayout = () => import('./basic.vue');
const AuthPageLayout = () => import('./auth.vue');
const IFrameView = () => import('@vben/layouts').then((m) => m.IFrameView);
export { AuthPageLayout, BasicLayout, IFrameView };

View File

@@ -0,0 +1,3 @@
# locale
每个app使用的国际化可能不同这里用于扩展国际化的功能例如扩展 dayjs、antd组件库的多语言切换以及app本身的国际化文件。

View File

@@ -0,0 +1,102 @@
import type { Language } from 'element-plus/es/locale';
import type { App } from 'vue';
import type { LocaleSetupOptions, SupportedLanguagesType } from '@vben/locales';
import { ref } from 'vue';
import {
$t,
setupI18n as coreSetup,
loadLocalesMapFromDir,
} from '@vben/locales';
import { preferences } from '@vben/preferences';
import dayjs from 'dayjs';
import enLocale from 'element-plus/es/locale/lang/en';
import defaultLocale from 'element-plus/es/locale/lang/zh-cn';
const elementLocale = ref<Language>(defaultLocale);
const modules = import.meta.glob('./langs/**/*.json');
const localesMap = loadLocalesMapFromDir(
/\.\/langs\/([^/]+)\/(.*)\.json$/,
modules,
);
/**
* 加载应用特有的语言包
* 这里也可以改造为从服务端获取翻译数据
* @param lang
*/
async function loadMessages(lang: SupportedLanguagesType) {
const [appLocaleMessages] = await Promise.all([
localesMap[lang]?.(),
loadThirdPartyMessage(lang),
]);
return appLocaleMessages?.default;
}
/**
* 加载第三方组件库的语言包
* @param lang
*/
async function loadThirdPartyMessage(lang: SupportedLanguagesType) {
await Promise.all([loadElementLocale(lang), loadDayjsLocale(lang)]);
}
/**
* 加载dayjs的语言包
* @param lang
*/
async function loadDayjsLocale(lang: SupportedLanguagesType) {
let locale;
switch (lang) {
case 'en-US': {
locale = await import('dayjs/locale/en');
break;
}
case 'zh-CN': {
locale = await import('dayjs/locale/zh-cn');
break;
}
// 默认使用英语
default: {
locale = await import('dayjs/locale/en');
}
}
if (locale) {
dayjs.locale(locale);
} else {
console.error(`Failed to load dayjs locale for ${lang}`);
}
}
/**
* 加载element-plus的语言包
* @param lang
*/
async function loadElementLocale(lang: SupportedLanguagesType) {
switch (lang) {
case 'en-US': {
elementLocale.value = enLocale;
break;
}
case 'zh-CN': {
elementLocale.value = defaultLocale;
break;
}
}
}
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
await coreSetup(app, {
defaultLocale: preferences.app.locale,
loadMessages,
missingWarn: !import.meta.env.PROD,
...options,
});
}
export { $t, elementLocale, setupI18n };

View File

@@ -0,0 +1,13 @@
{
"title": "Demos",
"elementPlus": "Element Plus",
"form": "Form",
"vben": {
"title": "Project",
"about": "About",
"document": "Document",
"antdv": "Ant Design Vue Version",
"naive-ui": "Naive UI Version",
"element-plus": "Element Plus Version"
}
}

View File

@@ -0,0 +1,14 @@
{
"auth": {
"login": "Login",
"register": "Register",
"codeLogin": "Code Login",
"qrcodeLogin": "Qr Code Login",
"forgetPassword": "Forget Password"
},
"dashboard": {
"title": "Dashboard",
"analytics": "Analytics",
"workspace": "Workspace"
}
}

View File

@@ -0,0 +1,13 @@
{
"title": "演示",
"elementPlus": "Element Plus",
"form": "表单演示",
"vben": {
"title": "项目",
"about": "关于",
"document": "文档",
"antdv": "Ant Design Vue 版本",
"naive-ui": "Naive UI 版本",
"element-plus": "Element Plus 版本"
}
}

View File

@@ -0,0 +1,14 @@
{
"auth": {
"login": "登录",
"register": "注册",
"codeLogin": "验证码登录",
"qrcodeLogin": "二维码登录",
"forgetPassword": "忘记密码"
},
"dashboard": {
"title": "概览",
"analytics": "分析页",
"workspace": "工作台"
}
}

View File

@@ -0,0 +1,31 @@
import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences';
/**
* 应用初始化完成之后再进行页面加载渲染
*/
async function initApplication() {
// name用于指定项目唯一标识
// 用于区分不同项目的偏好设置以及存储数据的key前缀以及其他一些需要隔离的数据
const env = import.meta.env.PROD ? 'prod' : 'dev';
const appVersion = import.meta.env.VITE_APP_VERSION;
const namespace = `${import.meta.env.VITE_APP_NAMESPACE}-${appVersion}-${env}`;
// app偏好设置初始化
await initPreferences({
namespace,
overrides: overridesPreferences,
});
// 启动应用并挂载
// vue应用主要逻辑及视图
const { bootstrap } = await import('./bootstrap');
await bootstrap(namespace);
// 移除并销毁loading
unmountGlobalLoading();
}
initApplication();

View File

@@ -0,0 +1,13 @@
import { defineOverridesPreferences } from '@vben/preferences';
/**
* @description 项目配置文件
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
* !!! 更改配置后请清空缓存,否则可能不生效
*/
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
name: import.meta.env.VITE_APP_TITLE,
},
});

View File

@@ -0,0 +1,42 @@
import type {
ComponentRecordType,
GenerateMenuAndRoutesOptions,
} from '@vben/types';
import { generateAccessible } from '@vben/access';
import { preferences } from '@vben/preferences';
import { ElMessage } from 'element-plus';
import { getAllMenusApi } from '#/api';
import { BasicLayout, IFrameView } from '#/layouts';
import { $t } from '#/locales';
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
async function generateAccess(options: GenerateMenuAndRoutesOptions) {
const pageMap: ComponentRecordType = import.meta.glob('../views/**/*.vue');
const layoutMap: ComponentRecordType = {
BasicLayout,
IFrameView,
};
return await generateAccessible(preferences.app.accessMode, {
...options,
fetchMenuListAsync: async () => {
ElMessage({
duration: 1500,
message: `${$t('common.loadingMenu')}...`,
});
return await getAllMenusApi();
},
// 可以指定没有权限跳转403页面
forbiddenComponent,
// 如果 route.meta.menuVisibleWithForbidden = true
layoutMap,
pageMap,
});
}
export { generateAccess };

View File

@@ -0,0 +1,133 @@
import type { Router } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { startProgress, stopProgress } from '@vben/utils';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import { generateAccess } from './access';
/**
* 通用守卫配置
* @param router
*/
function setupCommonGuard(router: Router) {
// 记录已经加载的页面
const loadedPaths = new Set<string>();
router.beforeEach((to) => {
to.meta.loaded = loadedPaths.has(to.path);
// 页面加载进度条
if (!to.meta.loaded && preferences.transition.progress) {
startProgress();
}
return true;
});
router.afterEach((to) => {
// 记录页面是否加载,如果已经加载,后续的页面切换动画等效果不在重复执行
loadedPaths.add(to.path);
// 关闭页面加载进度条
if (preferences.transition.progress) {
stopProgress();
}
});
}
/**
* 权限访问守卫配置
* @param router
*/
function setupAccessGuard(router: Router) {
router.beforeEach(async (to, from) => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const authStore = useAuthStore();
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
preferences.app.defaultHomePath,
);
}
return true;
}
// accessToken 检查
if (!accessStore.accessToken) {
// 明确声明忽略权限访问权限,则可以访问
if (to.meta.ignoreAccess) {
return true;
}
// 没有访问权限,跳转登录页面
if (to.fullPath !== LOGIN_PATH) {
return {
path: LOGIN_PATH,
// 如不需要,直接删除 query
query:
to.fullPath === preferences.app.defaultHomePath
? {}
: { redirect: encodeURIComponent(to.fullPath) },
// 携带当前跳转的页面,登录后重新跳转该页面
replace: true,
};
}
return to;
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
}
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由
const { accessibleMenus, accessibleRoutes } = await generateAccess({
roles: userRoles,
router,
// 则会在菜单中显示但是访问会被重定向到403
routes: accessRoutes,
});
// 保存菜单信息和路由信息
accessStore.setAccessMenus(accessibleMenus);
accessStore.setAccessRoutes(accessibleRoutes);
accessStore.setIsAccessChecked(true);
const redirectPath = (from.query.redirect ??
(to.path === preferences.app.defaultHomePath
? userInfo.homePath || preferences.app.defaultHomePath
: to.fullPath)) as string;
return {
...router.resolve(decodeURIComponent(redirectPath)),
replace: true,
};
});
}
/**
* 项目守卫配置
* @param router
*/
function createRouterGuard(router: Router) {
/** 通用 */
setupCommonGuard(router);
/** 权限访问 */
setupAccessGuard(router);
}
export { createRouterGuard };

View File

@@ -0,0 +1,37 @@
import {
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import { resetStaticRoutes } from '@vben/utils';
import { createRouterGuard } from './guard';
import { routes } from './routes';
/**
* @zh_CN 创建vue-router实例
*/
const router = createRouter({
history:
import.meta.env.VITE_ROUTER_HISTORY === 'hash'
? createWebHashHistory(import.meta.env.VITE_BASE)
: createWebHistory(import.meta.env.VITE_BASE),
// 应该添加到路由的初始路由列表。
routes,
scrollBehavior: (to, _from, savedPosition) => {
if (savedPosition) {
return savedPosition;
}
return to.hash ? { behavior: 'smooth', el: to.hash } : { left: 0, top: 0 };
},
// 是否应该禁止尾部斜杠。
// strict: true,
});
const resetRoutes = () => resetStaticRoutes(router, routes);
// 创建路由守卫
createRouterGuard(router);
export { resetRoutes, router };

View File

@@ -0,0 +1,97 @@
import type { RouteRecordRaw } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { $t } from '#/locales';
const BasicLayout = () => import('#/layouts/basic.vue');
const AuthPageLayout = () => import('#/layouts/auth.vue');
/** 全局404页面 */
const fallbackNotFoundRoute: RouteRecordRaw = {
component: () => import('#/views/_core/fallback/not-found.vue'),
meta: {
hideInBreadcrumb: true,
hideInMenu: true,
hideInTab: true,
title: '404',
},
name: 'FallbackNotFound',
path: '/:path(.*)*',
};
/** 基本路由,这些路由是必须存在的 */
const coreRoutes: RouteRecordRaw[] = [
/**
* 根路由
* 使用基础布局作为所有页面的父级容器子级就不必配置BasicLayout。
* 此路由必须存在,且不应修改
*/
{
component: BasicLayout,
meta: {
hideInBreadcrumb: true,
title: 'Root',
},
name: 'Root',
path: '/',
redirect: preferences.app.defaultHomePath,
children: [],
},
{
component: AuthPageLayout,
meta: {
hideInTab: true,
title: 'Authentication',
},
name: 'Authentication',
path: '/auth',
redirect: LOGIN_PATH,
children: [
{
name: 'Login',
path: 'login',
component: () => import('#/views/_core/authentication/login.vue'),
meta: {
title: $t('page.auth.login'),
},
},
{
name: 'CodeLogin',
path: 'code-login',
component: () => import('#/views/_core/authentication/code-login.vue'),
meta: {
title: $t('page.auth.codeLogin'),
},
},
{
name: 'QrCodeLogin',
path: 'qrcode-login',
component: () =>
import('#/views/_core/authentication/qrcode-login.vue'),
meta: {
title: $t('page.auth.qrcodeLogin'),
},
},
{
name: 'ForgetPassword',
path: 'forget-password',
component: () =>
import('#/views/_core/authentication/forget-password.vue'),
meta: {
title: $t('page.auth.forgetPassword'),
},
},
{
name: 'Register',
path: 'register',
component: () => import('#/views/_core/authentication/register.vue'),
meta: {
title: $t('page.auth.register'),
},
},
],
},
];
export { coreRoutes, fallbackNotFoundRoute };

View File

@@ -0,0 +1,37 @@
import type { RouteRecordRaw } from 'vue-router';
import { mergeRouteModules, traverseTreeValues } from '@vben/utils';
import { coreRoutes, fallbackNotFoundRoute } from './core';
const dynamicRouteFiles = import.meta.glob('./modules/**/*.ts', {
eager: true,
});
// 有需要可以自行打开注释,并创建文件夹
// const externalRouteFiles = import.meta.glob('./external/**/*.ts', { eager: true });
// const staticRouteFiles = import.meta.glob('./static/**/*.ts', { eager: true });
/** 动态路由 */
const dynamicRoutes: RouteRecordRaw[] = mergeRouteModules(dynamicRouteFiles);
/** 外部路由列表访问这些页面可以不需要Layout可能用于内嵌在别的系统(不会显示在菜单中) */
// const externalRoutes: RouteRecordRaw[] = mergeRouteModules(externalRouteFiles);
// const staticRoutes: RouteRecordRaw[] = mergeRouteModules(staticRouteFiles);
const staticRoutes: RouteRecordRaw[] = [];
const externalRoutes: RouteRecordRaw[] = [];
/** 路由列表由基本路由、外部路由和404兜底路由组成
* 无需走权限验证(会一直显示在菜单中) */
const routes: RouteRecordRaw[] = [
...coreRoutes,
...externalRoutes,
fallbackNotFoundRoute,
];
/** 基本路由列表,这些路由不需要进入权限拦截 */
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
/** 有权限校验的路由列表,包含动态路由和静态路由 */
const accessRoutes = [...dynamicRoutes, ...staticRoutes];
export { accessRoutes, coreRouteNames, routes };

View File

@@ -0,0 +1,174 @@
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/common',
name: 'Common',
component: () => import('#/layouts').then((m) => m.BasicLayout),
meta: {
title: '系统管理',
icon: 'ep:setting',
order: 1000,
},
children: [
{
path: 'user',
name: 'CommonUser',
meta: {
title: '用户管理',
icon: 'ep:user',
},
children: [
{
path: 'admin',
name: 'CommonUserAdmin',
component: () => import('#/views/common/user/admin/index.vue'),
meta: {
title: '管理员',
icon: 'ep:user-filled',
},
},
{
path: 'member',
name: 'CommonUserMember',
component: () => import('#/views/common/user/member/index.vue'),
meta: {
title: '会员用户',
icon: 'ep:avatar',
},
},
],
},
{
path: 'rbac',
name: 'CommonRbac',
meta: {
title: '权限管理',
icon: 'ep:lock',
},
children: [
{
path: 'role',
name: 'CommonRbacRole',
component: () => import('#/views/common/rbac/role/index.vue'),
meta: {
title: '角色管理',
icon: 'ep:user-filled',
},
},
{
path: 'menu',
name: 'CommonRbacMenu',
component: () => import('#/views/common/rbac/menu/index.vue'),
meta: {
title: '菜单管理',
icon: 'ep:menu',
},
},
{
path: 'permission',
name: 'CommonRbacPermission',
component: () => import('#/views/common/rbac/permission/index.vue'),
meta: {
title: '权限管理',
icon: 'ep:key',
},
},
],
},
{
path: 'settings',
name: 'CommonSettings',
meta: {
title: '系统设置',
icon: 'ep:tools',
},
children: [
{
path: 'email',
name: 'CommonSettingsEmail',
component: () => import('#/views/common/settings/email/index.vue'),
meta: {
title: '邮件设置',
icon: 'ep:message',
},
},
{
path: 'sms',
name: 'CommonSettingsSms',
component: () => import('#/views/common/settings/sms/index.vue'),
meta: {
title: '短信设置',
icon: 'ep:chat-dot-round',
},
},
{
path: 'storage',
name: 'CommonSettingsStorage',
component: () => import('#/views/common/settings/storage/index.vue'),
meta: {
title: '存储设置',
icon: 'ep:folder',
},
},
{
path: 'payment',
name: 'CommonSettingsPayment',
component: () => import('#/views/common/settings/payment/index.vue'),
meta: {
title: '支付设置',
icon: 'ep:credit-card',
},
},
{
path: 'login',
name: 'CommonSettingsLogin',
component: () => import('#/views/common/settings/login/index.vue'),
meta: {
title: '登录设置',
icon: 'ep:unlock',
},
},
{
path: 'system',
name: 'CommonSettingsSystem',
component: () => import('#/views/common/settings/system/index.vue'),
meta: {
title: '系统设置',
icon: 'ep:setting',
},
},
{
path: 'security',
name: 'CommonSettingsSecurity',
component: () => import('#/views/common/settings/security/index.vue'),
meta: {
title: '安全设置',
icon: 'ep:lock',
},
},
{
path: 'notification',
name: 'CommonSettingsNotification',
component: () => import('#/views/common/settings/notification/index.vue'),
meta: {
title: '通知设置',
icon: 'ep:bell',
},
},
],
},
{
path: 'file',
name: 'CommonFile',
component: () => import('#/views/common/file/index.vue'),
meta: {
title: '文件管理',
icon: 'ep:folder',
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,38 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: $t('page.dashboard.title'),
},
name: 'Dashboard',
path: '/dashboard',
children: [
{
name: 'Analytics',
path: '/analytics',
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
affixTab: true,
icon: 'lucide:area-chart',
title: $t('page.dashboard.analytics'),
},
},
{
name: 'Workspace',
path: '/workspace',
component: () => import('#/views/dashboard/workspace/index.vue'),
meta: {
icon: 'carbon:workspace',
title: $t('page.dashboard.workspace'),
},
},
],
},
];
export default routes;

View File

@@ -0,0 +1,36 @@
import type { RouteRecordRaw } from 'vue-router';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
icon: 'ic:baseline-view-in-ar',
keepAlive: true,
order: 1000,
title: $t('demos.title'),
},
name: 'Demos',
path: '/demos',
children: [
{
meta: {
title: $t('demos.elementPlus'),
},
name: 'NaiveDemos',
path: '/demos/element',
component: () => import('#/views/demos/element/index.vue'),
},
{
meta: {
title: $t('demos.form'),
},
name: 'BasicForm',
path: '/demos/form',
component: () => import('#/views/demos/form/basic.vue'),
},
],
},
];
export default routes;

View File

@@ -0,0 +1,82 @@
import type { RouteRecordRaw } from 'vue-router';
import {
VBEN_ANT_PREVIEW_URL,
VBEN_DOC_URL,
VBEN_GITHUB_URL,
VBEN_LOGO_URL,
VBEN_NAIVE_PREVIEW_URL,
} from '@vben/constants';
import { SvgAntdvLogoIcon } from '@vben/icons';
import { IFrameView } from '#/layouts';
import { $t } from '#/locales';
const routes: RouteRecordRaw[] = [
{
meta: {
badgeType: 'dot',
icon: VBEN_LOGO_URL,
order: 9998,
title: $t('demos.vben.title'),
},
name: 'VbenProject',
path: '/vben-admin',
children: [
{
name: 'VbenDocument',
path: '/vben-admin/document',
component: IFrameView,
meta: {
icon: 'lucide:book-open-text',
link: VBEN_DOC_URL,
title: $t('demos.vben.document'),
},
},
{
name: 'VbenGithub',
path: '/vben-admin/github',
component: IFrameView,
meta: {
icon: 'mdi:github',
link: VBEN_GITHUB_URL,
title: 'Github',
},
},
{
name: 'VbenNaive',
path: '/vben-admin/naive',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: 'logos:naiveui',
link: VBEN_NAIVE_PREVIEW_URL,
title: $t('demos.vben.naive-ui'),
},
},
{
name: 'VbenAntd',
path: '/vben-admin/antd',
component: IFrameView,
meta: {
badgeType: 'dot',
icon: SvgAntdvLogoIcon,
link: VBEN_ANT_PREVIEW_URL,
title: $t('demos.vben.antdv'),
},
},
],
},
{
name: 'VbenAbout',
path: '/vben-admin/about',
component: () => import('#/views/_core/about/index.vue'),
meta: {
icon: 'lucide:copyright',
title: $t('demos.vben.about'),
order: 9999,
},
},
];
export default routes;

View File

@@ -0,0 +1,119 @@
import type { Recordable, UserInfo } from '@vben/types';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { LOGIN_PATH } from '@vben/constants';
import { preferences } from '@vben/preferences';
import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores';
import { ElNotification } from 'element-plus';
import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { $t } from '#/locales';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
const userStore = useUserStore();
const router = useRouter();
const loginLoading = ref(false);
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param params 登录表单数据
*/
async function authLogin(
params: Recordable<any>,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { accessToken } = await loginApi(params);
// 如果成功获取到 accessToken
if (accessToken) {
// 将 accessToken 存储到 accessStore 中
accessStore.setAccessToken(accessToken);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.realName) {
ElNotification({
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
title: $t('authentication.loginSuccess'),
type: 'success',
});
}
}
} finally {
loginLoading.value = false;
}
return {
userInfo,
};
}
async function logout(redirect: boolean = true) {
try {
await logoutApi();
} catch {
// 不做任何处理
}
resetAllStores();
accessStore.setLoginExpired(false);
// 回登录页带上当前路由地址
await router.replace({
path: LOGIN_PATH,
query: redirect
? {
redirect: encodeURIComponent(router.currentRoute.value.fullPath),
}
: {},
});
}
async function fetchUserInfo() {
let userInfo: null | UserInfo = null;
userInfo = await getUserInfoApi();
userStore.setUserInfo(userInfo);
return userInfo;
}
function $reset() {
loginLoading.value = false;
}
return {
$reset,
authLogin,
fetchUserInfo,
loginLoading,
logout,
};
});

View File

@@ -0,0 +1 @@
export * from './auth';

View File

@@ -0,0 +1,3 @@
# \_core
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
defineOptions({ name: 'About' });
</script>
<template>
<About />
</template>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import { AuthenticationCodeLogin, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'CodeLogin' });
const loading = ref(false);
const CODE_LENGTH = 6;
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.mobile'),
},
fieldName: 'phoneNumber',
label: $t('authentication.mobile'),
rules: z
.string()
.min(1, { message: $t('authentication.mobileTip') })
.refine((v) => /^\d{11}$/.test(v), {
message: $t('authentication.mobileErrortip'),
}),
},
{
component: 'VbenPinInput',
componentProps: {
codeLength: CODE_LENGTH,
createText: (countdown: number) => {
const text =
countdown > 0
? $t('authentication.sendText', [countdown])
: $t('authentication.sendCode');
return text;
},
placeholder: $t('authentication.code'),
},
fieldName: 'code',
label: $t('authentication.code'),
rules: z.string().length(CODE_LENGTH, {
message: $t('authentication.codeTip', [CODE_LENGTH]),
}),
},
];
});
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param values 登录表单数据
*/
async function handleLogin(values: Recordable<any>) {
// eslint-disable-next-line no-console
console.log(values);
}
</script>
<template>
<AuthenticationCodeLogin
:form-schema="formSchema"
:loading="loading"
@submit="handleLogin"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'ForgetPassword' });
const loading = ref(false);
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: 'example@example.com',
},
fieldName: 'email',
label: $t('authentication.email'),
rules: z
.string()
.min(1, { message: $t('authentication.emailTip') })
.email($t('authentication.emailValidErrorTip')),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('reset email:', value);
}
</script>
<template>
<AuthenticationForgetPassword
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,98 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types';
import { computed, markRaw } from 'vue';
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const MOCK_USER_OPTIONS: BasicOption[] = [
{
label: 'Super',
value: 'vben',
},
{
label: 'Admin',
value: 'admin',
},
{
label: 'User',
value: 'jack',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenSelect',
componentProps: {
options: MOCK_USER_OPTIONS,
placeholder: $t('authentication.selectAccount'),
},
fieldName: 'selectAccount',
label: $t('authentication.selectAccount'),
rules: z
.string()
.min(1, { message: $t('authentication.selectAccount') })
.optional()
.default('vben'),
},
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
dependencies: {
trigger(values, form) {
if (values.selectAccount) {
const findUser = MOCK_USER_OPTIONS.find(
(item) => item.value === values.selectAccount,
);
if (findUser) {
form.setValues({
password: '123456',
username: findUser.value,
});
}
}
},
triggerFields: ['selectAccount'],
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: markRaw(SliderCaptcha),
fieldName: 'captcha',
rules: z.boolean().refine((value) => value, {
message: $t('authentication.verifyRequiredTip'),
}),
},
];
});
</script>
<template>
<AuthenticationLogin
:form-schema="formSchema"
:loading="authStore.loginLoading"
@submit="authStore.authLogin"
/>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
import { AuthenticationQrCodeLogin } from '@vben/common-ui';
import { LOGIN_PATH } from '@vben/constants';
defineOptions({ name: 'QrCodeLogin' });
</script>
<template>
<AuthenticationQrCodeLogin :login-path="LOGIN_PATH" />
</template>

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, h, ref } from 'vue';
import { AuthenticationRegister, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'Register' });
const loading = ref(false);
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
renderComponentContent() {
return {
strengthText: () => $t('authentication.passwordStrength'),
};
},
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.confirmPassword'),
},
dependencies: {
rules(values) {
const { password } = values;
return z
.string({ required_error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),
});
},
triggerFields: ['password'],
},
fieldName: 'confirmPassword',
label: $t('authentication.confirmPassword'),
},
{
component: 'VbenCheckbox',
fieldName: 'agreePolicy',
renderComponentContent: () => ({
default: () =>
h('span', [
$t('authentication.agree'),
h(
'a',
{
class: 'vben-link ml-1 ',
href: '',
},
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
),
]),
}),
rules: z.boolean().refine((value) => !!value, {
message: $t('authentication.agreeTip'),
}),
},
];
});
function handleSubmit(value: Recordable<any>) {
// eslint-disable-next-line no-console
console.log('register submit:', value);
}
</script>
<template>
<AuthenticationRegister
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
</script>
<template>
<Fallback status="coming-soon" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback403Demo' });
</script>
<template>
<Fallback status="403" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback500Demo' });
</script>
<template>
<Fallback status="500" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback404Demo' });
</script>
<template>
<Fallback status="404" />
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'FallbackOfflineDemo' });
</script>
<template>
<Fallback status="offline" />
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
<template>
<Page>
<div class="p-4">
<!-- 搜索表单 -->
<el-card class="mb-4">
<el-form :model="searchForm" inline>
<el-form-item label="菜单名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入菜单名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="显示" :value="1" />
<el-option label="隐藏" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="菜单类型">
<el-select v-model="searchForm.type" placeholder="请选择类型" clearable>
<el-option label="目录" value="catalog" />
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="mb-4">
<div class="action-buttons">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增菜单
</el-button>
<el-button type="success" @click="handleExpandAll">
<Icon icon="ep:d-arrow-right" class="mr-1" />
展开全部
</el-button>
<el-button type="info" @click="handleCollapseAll">
<Icon icon="ep:d-arrow-left" class="mr-1" />
收起全部
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
row-key="menuId"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:default-expand-all="false"
ref="tableRef"
>
<el-table-column prop="title" label="菜单名称" min-width="200">
<template #default="{ row }">
<div class="menu-title">
<Icon :icon="row.icon || 'ep:folder'" class="mr-2" />
<span>{{ row.title }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.type)">
{{ getTypeText(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路由路径" min-width="150" show-overflow-tooltip />
<el-table-column prop="component" label="组件路径" min-width="150" show-overflow-tooltip />
<el-table-column prop="permission" label="权限标识" min-width="150" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '显示' : '隐藏' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="success" size="small" @click="handleAddChild(row)">
新增子菜单
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="800px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="上级菜单" prop="parentId">
<el-tree-select
v-model="formData.parentId"
:data="menuTreeData"
:props="treeSelectProps"
placeholder="请选择上级菜单"
check-strictly
:render-after-expand="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单类型" prop="type">
<el-radio-group v-model="formData.type" @change="handleTypeChange">
<el-radio label="catalog">目录</el-radio>
<el-radio label="menu">菜单</el-radio>
<el-radio label="button">按钮</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="菜单名称" prop="title">
<el-input v-model="formData.title" placeholder="请输入菜单名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单图标" prop="icon">
<el-input v-model="formData.icon" placeholder="请输入图标名称">
<template #prepend>
<Icon :icon="formData.icon || 'ep:folder'" />
</template>
</el-input>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" v-if="formData.type !== 'button'">
<el-col :span="12">
<el-form-item label="路由路径" prop="path">
<el-input v-model="formData.path" placeholder="请输入路由路径" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 'menu'">
<el-form-item label="组件路径" prop="component">
<el-input v-model="formData.component" placeholder="请输入组件路径" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限标识" prop="permission">
<el-input v-model="formData.permission" placeholder="请输入权限标识" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20" v-if="formData.type !== 'button'">
<el-col :span="12">
<el-form-item label="是否隐藏" prop="hidden">
<el-radio-group v-model="formData.hidden">
<el-radio :label="0">显示</el-radio>
<el-radio :label="1">隐藏</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="是否缓存" prop="keepAlive">
<el-radio-group v-model="formData.keepAlive">
<el-radio :label="1">缓存</el-radio>
<el-radio :label="0">不缓存</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted, computed, type FormInstance } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElRadio,
ElRadioGroup,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTreeSelect,
type ElTable,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getMenuListApi,
getMenuTreeApi,
createMenuApi,
updateMenuApi,
deleteMenuApi,
type Menu,
type CreateMenuParams,
type UpdateMenuParams,
type MenuTreeNode,
} from '#/api/common/rbac';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const tableData = ref<Menu[]>([]);
const menuTreeData = ref<MenuTreeNode[]>([]);
const currentParent = ref<Menu | null>(null);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
type: undefined as string | undefined,
});
// 对话框
const dialogVisible = ref(false);
const isEdit = ref(false);
const isAddChild = ref(false);
const formRef = ref<FormInstance>();
const tableRef = ref<InstanceType<typeof ElTable>>();
// 表单数据
const formData = reactive<CreateMenuParams & { menuId?: number }>({
parentId: 0,
title: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0,
hidden: 0,
keepAlive: 1,
status: 1,
remark: '',
});
// 树形选择器配置
const treeSelectProps = {
value: 'menuId',
label: 'title',
children: 'children',
};
// 表单验证规则
const formRules = {
title: [
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 50, message: '菜单名称长度在 2 到 50 个字符', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择菜单类型', trigger: 'change' },
],
path: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type !== 'button' && !value) {
callback(new Error('请输入路由路径'));
} else {
callback();
}
},
trigger: 'blur',
},
],
component: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type === 'menu' && !value) {
callback(new Error('请输入组件路径'));
} else {
callback();
}
},
trigger: 'blur',
},
],
permission: [
{ required: true, message: '请输入权限标识', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => {
if (isAddChild.value) {
return `新增子菜单 - ${currentParent.value?.title}`;
}
return isEdit.value ? '编辑菜单' : '新增菜单';
});
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getTypeText = (type: string) => {
const map = {
catalog: '目录',
menu: '菜单',
button: '按钮',
};
return map[type as keyof typeof map] || type;
};
const getTypeTagType = (type: string) => {
const map = {
catalog: 'warning',
menu: 'primary',
button: 'success',
};
return map[type as keyof typeof map] || 'info';
};
const loadData = async () => {
loading.value = true;
try {
const params = {
keyword: searchForm.keyword || undefined,
status: searchForm.status,
type: searchForm.type,
};
const result = await getMenuListApi(params);
tableData.value = result;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadMenuTree = async () => {
try {
const result = await getMenuTreeApi();
// 添加根节点
menuTreeData.value = [
{
menuId: 0,
title: '根目录',
children: result,
},
];
} catch (error) {
ElMessage.error('加载菜单树失败');
}
};
const handleSearch = () => {
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
searchForm.type = undefined;
handleSearch();
};
const handleExpandAll = () => {
const table = tableRef.value;
if (table) {
const expandAll = (data: Menu[]) => {
data.forEach(row => {
table.toggleRowExpansion(row, true);
if (row.children) {
expandAll(row.children);
}
});
};
expandAll(tableData.value);
}
};
const handleCollapseAll = () => {
const table = tableRef.value;
if (table) {
const collapseAll = (data: Menu[]) => {
data.forEach(row => {
table.toggleRowExpansion(row, false);
if (row.children) {
collapseAll(row.children);
}
});
};
collapseAll(tableData.value);
}
};
const handleAdd = async () => {
isEdit.value = false;
isAddChild.value = false;
currentParent.value = null;
await loadMenuTree();
resetForm();
dialogVisible.value = true;
};
const handleAddChild = async (row: Menu) => {
isEdit.value = false;
isAddChild.value = true;
currentParent.value = row;
await loadMenuTree();
resetForm();
formData.parentId = row.menuId;
dialogVisible.value = true;
};
const handleEdit = async (row: Menu) => {
isEdit.value = true;
isAddChild.value = false;
currentParent.value = null;
await loadMenuTree();
Object.assign(formData, {
menuId: row.menuId,
parentId: row.parentId,
title: row.title,
type: row.type,
path: row.path,
component: row.component,
icon: row.icon,
permission: row.permission,
sort: row.sort,
hidden: row.hidden,
keepAlive: row.keepAlive,
status: row.status,
remark: row.remark,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Menu) => {
try {
await ElMessageBox.confirm(
`确定要删除菜单 "${row.title}" 吗?删除后子菜单也会被删除!`,
'确认删除',
{
type: 'warning',
}
);
await deleteMenuApi(row.menuId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleTypeChange = (type: string) => {
// 根据类型清空相关字段
if (type === 'button') {
formData.path = '';
formData.component = '';
formData.hidden = 0;
formData.keepAlive = 0;
} else if (type === 'catalog') {
formData.component = '';
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateMenuParams = {
menuId: formData.menuId!,
parentId: formData.parentId,
title: formData.title,
type: formData.type,
path: formData.path,
component: formData.component,
icon: formData.icon,
permission: formData.permission,
sort: formData.sort,
hidden: formData.hidden,
keepAlive: formData.keepAlive,
status: formData.status,
remark: formData.remark,
};
await updateMenuApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreateMenuParams = {
parentId: formData.parentId,
title: formData.title,
type: formData.type,
path: formData.path,
component: formData.component,
icon: formData.icon,
permission: formData.permission,
sort: formData.sort,
hidden: formData.hidden,
keepAlive: formData.keepAlive,
status: formData.status,
remark: formData.remark,
};
await createMenuApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
menuId: undefined,
parentId: 0,
title: '',
type: 'menu',
path: '',
component: '',
icon: '',
permission: '',
sort: 0,
hidden: 0,
keepAlive: 1,
status: 1,
remark: '',
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.menu-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.menu-title {
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,660 @@
<template>
<Page>
<div class="p-4">
<!-- 搜索表单 -->
<el-card class="mb-4">
<el-form :model="searchForm" inline>
<el-form-item label="权限名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入权限名称或标识"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="权限类型">
<el-select v-model="searchForm.type" placeholder="请选择类型" clearable>
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
<el-option label="接口" value="api" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮 -->
<el-card class="mb-4">
<div class="action-buttons">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增权限
</el-button>
<el-button
type="danger"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
<el-button type="success" @click="handleSyncFromMenu">
<Icon icon="ep:refresh" class="mr-1" />
从菜单同步
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="permissionId" label="ID" width="80" />
<el-table-column prop="name" label="权限名称" min-width="150" />
<el-table-column prop="code" label="权限标识" min-width="200" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.type)">
{{ getTypeText(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource" label="资源路径" min-width="200" show-overflow-tooltip />
<el-table-column prop="method" label="请求方法" width="100">
<template #default="{ row }">
<el-tag v-if="row.method" :type="getMethodTagType(row.method)" size="small">
{{ row.method }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="menuId" label="关联菜单" width="100">
<template #default="{ row }">
<el-tag v-if="row.menuId" type="info" size="small">
{{ getMenuName(row.menuId) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入权限名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="权限标识" prop="code">
<el-input v-model="formData.code" placeholder="请输入权限标识" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="权限类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择类型" @change="handleTypeChange">
<el-option label="菜单" value="menu" />
<el-option label="按钮" value="button" />
<el-option label="接口" value="api" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="关联菜单" prop="menuId">
<el-select v-model="formData.menuId" placeholder="请选择菜单" clearable filterable>
<el-option
v-for="menu in menuOptions"
:key="menu.menuId"
:label="menu.title"
:value="menu.menuId"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="资源路径" prop="resource">
<el-input v-model="formData.resource" placeholder="请输入资源路径" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 'api'">
<el-form-item label="请求方法" prop="method">
<el-select v-model="formData.method" placeholder="请选择请求方法">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
<el-option label="PUT" value="PUT" />
<el-option label="DELETE" value="DELETE" />
<el-option label="PATCH" value="PATCH" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入权限描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script lang="ts" setup>
// 1. Vue 相关导入
import { ref, reactive, onMounted, computed, type FormInstance } from 'vue';
// 2. Element Plus 组件导入
import {
ElButton,
ElCard,
ElCol,
ElDialog,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElRadio,
ElRadioGroup,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
} from 'element-plus';
// 3. 图标组件导入
import { Icon } from '@iconify/vue';
// 4. Vben 组件导入
import { Page } from '@vben/common-ui';
// 5. 项目内部导入
import {
getPermissionListApi,
createPermissionApi,
updatePermissionApi,
deletePermissionApi,
batchDeletePermissionApi,
syncPermissionFromMenuApi,
type Permission,
type CreatePermissionParams,
type UpdatePermissionParams,
} from '#/api/rbac';
import { getMenuListApi, type Menu } from '#/api/rbac';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const tableData = ref<Permission[]>([]);
const selectedRows = ref<Permission[]>([]);
const menuOptions = ref<Menu[]>([]);
// 搜索表单
const searchForm = reactive({
keyword: '',
type: undefined as string | undefined,
status: undefined as number | undefined,
});
// 分页
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
});
// 对话框
const dialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
// 表单数据
const formData = reactive<CreatePermissionParams & { permissionId?: number }>({
name: '',
code: '',
type: 'button',
resource: '',
method: '',
menuId: undefined,
sort: 0,
status: 1,
description: '',
});
// 表单验证规则
const formRules = {
name: [
{ required: true, message: '请输入权限名称', trigger: 'blur' },
{ min: 2, max: 50, message: '权限名称长度在 2 到 50 个字符', trigger: 'blur' },
],
code: [
{ required: true, message: '请输入权限标识', trigger: 'blur' },
{ min: 2, max: 100, message: '权限标识长度在 2 到 100 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_:.-]*$/, message: '权限标识格式不正确', trigger: 'blur' },
],
type: [
{ required: true, message: '请选择权限类型', trigger: 'change' },
],
resource: [
{ max: 200, message: '资源路径不能超过 200 个字符', trigger: 'blur' },
],
method: [
{
validator: (rule: any, value: string, callback: any) => {
if (formData.type === 'api' && !value) {
callback(new Error('接口类型权限必须选择请求方法'));
} else {
callback();
}
},
trigger: 'change',
},
],
description: [
{ max: 500, message: '描述不能超过 500 个字符', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => (isEdit.value ? '编辑权限' : '新增权限'));
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getTypeText = (type: string) => {
const map = {
menu: '菜单',
button: '按钮',
api: '接口',
};
return map[type as keyof typeof map] || type;
};
const getTypeTagType = (type: string) => {
const map = {
menu: 'primary',
button: 'success',
api: 'warning',
};
return map[type as keyof typeof map] || 'info';
};
const getMethodTagType = (method: string) => {
const map = {
GET: 'primary',
POST: 'success',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info',
};
return map[method as keyof typeof map] || 'info';
};
const getMenuName = (menuId: number) => {
const menu = menuOptions.value.find(m => m.menuId === menuId);
return menu ? menu.title : `菜单${menuId}`;
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword || undefined,
type: searchForm.type,
status: searchForm.status,
};
const result = await getPermissionListApi(params);
tableData.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadMenuOptions = async () => {
try {
const result = await getMenuListApi({});
// 扁平化菜单树
const flattenMenu = (menus: Menu[]): Menu[] => {
let result: Menu[] = [];
menus.forEach(menu => {
result.push(menu);
if (menu.children) {
result = result.concat(flattenMenu(menu.children));
}
});
return result;
};
menuOptions.value = flattenMenu(result);
} catch (error) {
ElMessage.error('加载菜单选项失败');
}
};
const handleSearch = () => {
pagination.page = 1;
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.type = undefined;
searchForm.status = undefined;
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.limit = size;
loadData();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadData();
};
const handleSelectionChange = (selection: Permission[]) => {
selectedRows.value = selection;
};
const handleAdd = async () => {
isEdit.value = false;
await loadMenuOptions();
resetForm();
dialogVisible.value = true;
};
const handleEdit = async (row: Permission) => {
isEdit.value = true;
await loadMenuOptions();
Object.assign(formData, {
permissionId: row.permissionId,
name: row.name,
code: row.code,
type: row.type,
resource: row.resource,
method: row.method,
menuId: row.menuId,
sort: row.sort,
status: row.status,
description: row.description,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Permission) => {
try {
await ElMessageBox.confirm(
`确定要删除权限 "${row.name}" 吗?`,
'确认删除',
{
type: 'warning',
}
);
await deletePermissionApi(row.permissionId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 个权限吗?`,
'确认删除',
{
type: 'warning',
}
);
const permissionIds = selectedRows.value.map(row => row.permissionId);
await batchDeletePermissionApi(permissionIds);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleSyncFromMenu = async () => {
try {
await ElMessageBox.confirm(
'确定要从菜单同步权限吗?这将根据菜单自动创建对应的权限。',
'确认同步',
{
type: 'info',
}
);
const result = await syncPermissionFromMenuApi();
ElMessage.success(`同步成功,新增 ${result.created} 个权限,更新 ${result.updated} 个权限`);
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('同步失败');
}
}
};
const handleTypeChange = (type: string) => {
// 根据类型清空相关字段
if (type !== 'api') {
formData.method = '';
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdatePermissionParams = {
permissionId: formData.permissionId!,
name: formData.name,
code: formData.code,
type: formData.type,
resource: formData.resource,
method: formData.method,
menuId: formData.menuId,
sort: formData.sort,
status: formData.status,
description: formData.description,
};
await updatePermissionApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreatePermissionParams = {
name: formData.name,
code: formData.code,
type: formData.type,
resource: formData.resource,
method: formData.method,
menuId: formData.menuId,
sort: formData.sort,
status: formData.status,
description: formData.description,
};
await createPermissionApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const resetForm = () => {
Object.assign(formData, {
permissionId: undefined,
name: '',
code: '',
type: 'button',
resource: '',
method: '',
menuId: undefined,
sort: 0,
status: 1,
description: '',
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.permission-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,826 @@
<template>
<Page
description="管理系统角色权限信息"
title="角色管理"
>
<div class="flex flex-col gap-4">
<!-- 搜索表单 -->
<el-card>
<el-form :model="searchForm" inline>
<el-form-item label="角色名称">
<el-input
v-model="searchForm.keyword"
placeholder="请输入角色名称"
clearable
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleReset">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作按钮和数据表格 -->
<el-card>
<div class="mb-4">
<el-button type="primary" @click="handleAdd">
<Icon icon="ep:plus" class="mr-1" />
新增角色
</el-button>
<el-button
type="danger"
:disabled="!selectedRows.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
</div>
<!-- 数据表格 -->
<el-table
v-loading="loading"
:data="tableData"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="roleId" label="ID" width="80" />
<el-table-column prop="roleName" label="角色名称" min-width="150" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="rules" label="权限规则" min-width="200">
<template #default="{ row }">
<el-tag v-if="row.rules === '*'" type="danger">超级管理员</el-tag>
<el-tag v-else-if="!row.rules" type="info">无权限</el-tag>
<el-tooltip v-else :content="row.rules" placement="top">
<el-tag type="primary">{{ getPermissionCount(row.rules) }}个权限</el-tag>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">
编辑
</el-button>
<el-button type="warning" size="small" @click="handleSetPermission(row)">
设置权限
</el-button>
<el-button type="info" size="small" @click="handleViewUsers(row)">
查看用户
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
:disabled="row.roleId === 1"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="formData.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 权限设置对话框 -->
<el-dialog
v-model="permissionDialogVisible"
title="设置权限"
width="800px"
@close="handlePermissionDialogClose"
>
<div class="permission-content">
<div class="permission-header">
<span>为角色 "{{ currentRole?.roleName }}" 设置权限</span>
<div class="permission-actions">
<el-button size="small" @click="handleExpandAll">展开全部</el-button>
<el-button size="small" @click="handleCollapseAll">收起全部</el-button>
<el-button size="small" @click="handleCheckAll">全选</el-button>
<el-button size="small" @click="handleUncheckAll">取消全选</el-button>
</div>
</div>
<el-tree
ref="permissionTreeRef"
:data="permissionTreeData"
:props="treeProps"
show-checkbox
node-key="id"
:default-checked-keys="checkedPermissions"
:default-expand-all="false"
class="permission-tree"
>
<template #default="{ node, data }">
<div class="tree-node">
<Icon :icon="data.icon || 'ep:folder'" class="mr-2" />
<span>{{ data.title }}</span>
<el-tag v-if="data.type" size="small" class="ml-2">
{{ getNodeTypeText(data.type) }}
</el-tag>
</div>
</template>
</el-tree>
</div>
<template #footer>
<el-button @click="permissionDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSavePermission" :loading="permissionLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 用户列表对话框 -->
<el-dialog
v-model="userDialogVisible"
title="角色用户列表"
width="800px"
>
<div class="user-content">
<div class="user-header">
<span>角色 "{{ currentRole?.roleName }}" 的用户列表</span>
<el-button type="primary" size="small" @click="handleAddUser">
<Icon icon="ep:plus" class="mr-1" />
添加用户
</el-button>
</div>
<el-table v-loading="userLoading" :data="roleUsers">
<el-table-column prop="userId" label="用户ID" width="80" />
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="realName" label="真实姓名" min-width="120" />
<el-table-column prop="mobile" label="手机号" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="150" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button
type="danger"
size="small"
@click="handleRemoveUser(row)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<!-- 添加用户对话框 -->
<el-dialog
v-model="addUserDialogVisible"
title="添加用户到角色"
width="600px"
>
<el-form :model="addUserForm" label-width="100px">
<el-form-item label="选择用户">
<el-select
v-model="addUserForm.userIds"
multiple
placeholder="请选择用户"
style="width: 100%"
filterable
remote
:remote-method="searchUsers"
:loading="searchUserLoading"
>
<el-option
v-for="user in availableUsers"
:key="user.userId"
:label="`${user.username} (${user.realName})`"
:value="user.userId"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addUserDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveAddUser" :loading="addUserLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</Page>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type ElTree } from 'element-plus';
import { Icon } from '@iconify/vue';
import { Page } from '@vben/common-ui';
import {
getRoleListApi,
createRoleApi,
updateRoleApi,
deleteRoleApi,
batchDeleteRolesApi,
getRoleUsersApi,
addRoleUserApi,
removeRoleUserApi,
getPermissionTreeApi,
type Role,
type CreateRoleParams,
type UpdateRoleParams,
type PermissionTreeNode,
} from '#/api/rbac';
import { getAdminListApi, type AdminUser } from '#/api/user';
// 响应式数据
const loading = ref(false);
const submitLoading = ref(false);
const permissionLoading = ref(false);
const userLoading = ref(false);
const addUserLoading = ref(false);
const searchUserLoading = ref(false);
const tableData = ref<Role[]>([]);
const selectedRows = ref<Role[]>([]);
const currentRole = ref<Role | null>(null);
const roleUsers = ref<AdminUser[]>([]);
const availableUsers = ref<AdminUser[]>([]);
const permissionTreeData = ref<PermissionTreeNode[]>([]);
const checkedPermissions = ref<string[]>([]);
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined as number | undefined,
});
// 分页
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
});
// 对话框
const dialogVisible = ref(false);
const permissionDialogVisible = ref(false);
const userDialogVisible = ref(false);
const addUserDialogVisible = ref(false);
const isEdit = ref(false);
const formRef = ref<FormInstance>();
const permissionTreeRef = ref<InstanceType<typeof ElTree>>();
// 表单数据
const formData = reactive<CreateRoleParams & { roleId?: number }>({
roleName: '',
description: '',
status: 1,
});
// 添加用户表单
const addUserForm = reactive({
userIds: [] as number[],
});
// 树形组件配置
const treeProps = {
children: 'children',
label: 'title',
};
// 表单验证规则
const formRules = {
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' },
{ min: 2, max: 50, message: '角色名称长度在 2 到 50 个字符', trigger: 'blur' },
],
description: [
{ max: 200, message: '描述不能超过 200 个字符', trigger: 'blur' },
],
};
// 计算属性
const dialogTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
// 方法
const formatTime = (timestamp: number) => {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
};
const getPermissionCount = (rules: string) => {
if (!rules || rules === '*') return 0;
try {
const ruleArray = rules.split(',').filter(Boolean);
return ruleArray.length;
} catch {
return 0;
}
};
const getNodeTypeText = (type: string) => {
const map = {
menu: '菜单',
button: '按钮',
api: '接口',
};
return map[type as keyof typeof map] || type;
};
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword || undefined,
status: searchForm.status,
};
const result = await getRoleListApi(params);
tableData.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载数据失败');
} finally {
loading.value = false;
}
};
const loadPermissionTree = async () => {
try {
const result = await getPermissionTreeApi();
permissionTreeData.value = result;
} catch (error) {
ElMessage.error('加载权限树失败');
}
};
const loadRoleUsers = async (roleId: number) => {
userLoading.value = true;
try {
const result = await getRoleUsersApi(roleId);
roleUsers.value = result;
} catch (error) {
ElMessage.error('加载角色用户失败');
} finally {
userLoading.value = false;
}
};
const searchUsers = async (keyword: string) => {
if (!keyword) {
availableUsers.value = [];
return;
}
searchUserLoading.value = true;
try {
const result = await getAdminListApi({
page: 1,
limit: 20,
keyword,
});
availableUsers.value = result.list;
} catch (error) {
ElMessage.error('搜索用户失败');
} finally {
searchUserLoading.value = false;
}
};
const handleSearch = () => {
pagination.page = 1;
loadData();
};
const handleReset = () => {
searchForm.keyword = '';
searchForm.status = undefined;
handleSearch();
};
const handleSizeChange = (size: number) => {
pagination.limit = size;
loadData();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadData();
};
const handleSelectionChange = (selection: Role[]) => {
selectedRows.value = selection;
};
const handleAdd = () => {
isEdit.value = false;
resetForm();
dialogVisible.value = true;
};
const handleEdit = (row: Role) => {
isEdit.value = true;
Object.assign(formData, {
roleId: row.roleId,
roleName: row.roleName,
description: row.description,
status: row.status,
});
dialogVisible.value = true;
};
const handleDelete = async (row: Role) => {
if (row.roleId === 1) {
ElMessage.warning('超级管理员角色不能删除');
return;
}
try {
await ElMessageBox.confirm(
`确定要删除角色 "${row.roleName}" 吗?`,
'确认删除',
{
type: 'warning',
}
);
await deleteRoleApi(row.roleId);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleBatchDelete = async () => {
const canDeleteRoles = selectedRows.value.filter(row => row.roleId !== 1);
if (canDeleteRoles.length === 0) {
ElMessage.warning('选中的角色中没有可删除的角色');
return;
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${canDeleteRoles.length} 个角色吗?`,
'确认删除',
{
type: 'warning',
}
);
const roleIds = canDeleteRoles.map(row => row.roleId);
await batchDeleteRoleApi(roleIds);
ElMessage.success('删除成功');
loadData();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleSetPermission = async (row: Role) => {
currentRole.value = row;
await loadPermissionTree();
// 解析当前角色的权限
if (row.rules && row.rules !== '*') {
checkedPermissions.value = row.rules.split(',').filter(Boolean);
} else {
checkedPermissions.value = [];
}
permissionDialogVisible.value = true;
};
const handleViewUsers = async (row: Role) => {
currentRole.value = row;
await loadRoleUsers(row.roleId);
userDialogVisible.value = true;
};
const handleAddUser = () => {
addUserForm.userIds = [];
availableUsers.value = [];
addUserDialogVisible.value = true;
};
const handleRemoveUser = async (user: AdminUser) => {
try {
await ElMessageBox.confirm(
`确定要将用户 "${user.username}" 从角色中移除吗?`,
'确认移除',
{
type: 'warning',
}
);
await removeRoleUserApi(currentRole.value!.roleId, user.userId);
ElMessage.success('移除成功');
loadRoleUsers(currentRole.value!.roleId);
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('移除失败');
}
}
};
const handleSaveAddUser = async () => {
if (!addUserForm.userIds.length) {
ElMessage.warning('请选择要添加的用户');
return;
}
addUserLoading.value = true;
try {
await addRoleUsersApi(currentRole.value!.roleId, addUserForm.userIds);
ElMessage.success('添加成功');
addUserDialogVisible.value = false;
loadRoleUsers(currentRole.value!.roleId);
} catch (error) {
ElMessage.error('添加失败');
} finally {
addUserLoading.value = false;
}
};
const handleExpandAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
const expandAll = (nodes: PermissionTreeNode[]) => {
nodes.forEach(node => {
tree.setExpanded(node.id, true);
if (node.children) {
expandAll(node.children);
}
});
};
expandAll(permissionTreeData.value);
}
};
const handleCollapseAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
const collapseAll = (nodes: PermissionTreeNode[]) => {
nodes.forEach(node => {
tree.setExpanded(node.id, false);
if (node.children) {
collapseAll(node.children);
}
});
};
collapseAll(permissionTreeData.value);
}
};
const handleCheckAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
const getAllNodeIds = (nodes: PermissionTreeNode[]): string[] => {
let ids: string[] = [];
nodes.forEach(node => {
ids.push(node.id);
if (node.children) {
ids = ids.concat(getAllNodeIds(node.children));
}
});
return ids;
};
const allIds = getAllNodeIds(permissionTreeData.value);
tree.setCheckedKeys(allIds);
}
};
const handleUncheckAll = () => {
const tree = permissionTreeRef.value;
if (tree) {
tree.setCheckedKeys([]);
}
};
const handleSavePermission = async () => {
const tree = permissionTreeRef.value;
if (!tree || !currentRole.value) return;
permissionLoading.value = true;
try {
const checkedKeys = tree.getCheckedKeys() as string[];
const halfCheckedKeys = tree.getHalfCheckedKeys() as string[];
const allCheckedKeys = [...checkedKeys, ...halfCheckedKeys];
await setRolePermissionApi(currentRole.value.roleId, allCheckedKeys);
ElMessage.success('权限设置成功');
permissionDialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error('权限设置失败');
} finally {
permissionLoading.value = false;
}
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
submitLoading.value = true;
if (isEdit.value) {
const updateData: UpdateRoleParams = {
roleId: formData.roleId!,
roleName: formData.roleName,
description: formData.description,
status: formData.status,
};
await updateRoleApi(updateData);
ElMessage.success('更新成功');
} else {
const createData: CreateRoleParams = {
roleName: formData.roleName,
description: formData.description,
status: formData.status,
};
await createRoleApi(createData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
loadData();
} catch (error) {
ElMessage.error(isEdit.value ? '更新失败' : '创建失败');
} finally {
submitLoading.value = false;
}
};
const handleDialogClose = () => {
formRef.value?.resetFields();
resetForm();
};
const handlePermissionDialogClose = () => {
currentRole.value = null;
checkedPermissions.value = [];
permissionTreeData.value = [];
};
const resetForm = () => {
Object.assign(formData, {
roleId: undefined,
roleName: '',
description: '',
status: 1,
});
};
// 生命周期
onMounted(() => {
loadData();
});
</script>
<style scoped>
.role-page {
padding: 20px;
}
.search-form {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.action-buttons {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
.permission-content {
max-height: 500px;
overflow-y: auto;
}
.permission-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
.permission-actions {
display: flex;
gap: 8px;
}
.permission-tree {
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
}
.tree-node {
display: flex;
align-items: center;
flex: 1;
}
.user-content {
max-height: 500px;
overflow-y: auto;
}
.user-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
}
</style>

View File

@@ -0,0 +1,559 @@
<template>
<Page>
<VbenCard title="邮件设置">
<template #extra>
<Icon icon="lucide:mail" class="text-lg" />
</template>
<VbenTabs v-model:active-key="activeTab" type="card">
<!-- SMTP配置 -->
<VbenTabPane key="smtp" tab="SMTP配置">
<VbenForm @submit="handleSubmitSmtp">
<template #default="{ form }">
<VbenFormItem name="smtp_host" label="SMTP服务器">
<Input v-model:value="form.smtp_host" placeholder="请输入SMTP服务器地址" />
</VbenFormItem>
<VbenFormItem name="smtp_port" label="端口">
<InputNumber
v-model:value="form.smtp_port"
:min="1"
:max="65535"
placeholder="请输入端口号"
/>
</VbenFormItem>
<VbenFormItem name="smtp_username" label="用户名">
<Input v-model:value="form.smtp_username" placeholder="请输入邮箱用户名" />
</VbenFormItem>
<VbenFormItem name="smtp_password" label="密码">
<Input
v-model:value="form.smtp_password"
type="password"
placeholder="请输入邮箱密码或授权码"
show-password
/>
</VbenFormItem>
<VbenFormItem name="smtp_from_email" label="发件人邮箱">
<Input v-model:value="form.smtp_from_email" placeholder="请输入发件人邮箱" />
</VbenFormItem>
<VbenFormItem name="smtp_from_name" label="发件人名称">
<Input v-model:value="form.smtp_from_name" placeholder="请输入发件人名称" />
</VbenFormItem>
<VbenFormItem name="smtp_encryption" label="加密方式">
<Select v-model:value="form.smtp_encryption" placeholder="请选择加密方式">
<template #default>
<SelectOption value=""></SelectOption>
<SelectOption value="ssl">SSL</SelectOption>
<SelectOption value="tls">TLS</SelectOption>
</template>
</Select>
</VbenFormItem>
<VbenFormItem name="smtp_enabled" label="启用状态">
<Switch v-model:checked="form.smtp_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存设置
</PrimaryButton>
<DefaultButton @click="handleTestEmail" :loading="testLoading">
<Icon icon="lucide:send" class="mr-1" />
发送测试邮件
</DefaultButton>
<DefaultButton @click="handleResetSmtp">
<Icon icon="lucide:rotate-ccw" class="mr-1" />
重置
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 邮件模板 -->
<VbenTabPane key="template" tab="邮件模板">
<VbenTabs v-model:active-key="templateTab" type="line">
<!-- 注册验证模板 -->
<VbenTabPane key="register" tab="注册验证">
<VbenForm @submit="handleSubmitTemplate">
<template #default="{ form }">
<VbenFormItem name="register_subject" label="邮件标题">
<Input
v-model:value="form.register_subject"
placeholder="请输入注册验证邮件标题"
/>
</VbenFormItem>
<VbenFormItem name="register_content" label="邮件内容">
<Input
v-model:value="form.register_content"
type="textarea"
:rows="6"
placeholder="请输入注册验证邮件内容,可使用变量:{username}、{code}、{expire}"
/>
</VbenFormItem>
<VbenFormItem name="register_enabled" label="启用状态">
<Switch v-model:checked="form.register_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存模板
</PrimaryButton>
<DefaultButton @click="handlePreviewTemplate('register')">
<Icon icon="lucide:eye" class="mr-1" />
预览模板
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 找回密码模板 -->
<VbenTabPane key="reset" tab="找回密码">
<VbenForm @submit="handleSubmitTemplate">
<template #default="{ form }">
<VbenFormItem name="reset_subject" label="邮件标题">
<Input
v-model:value="form.reset_subject"
placeholder="请输入找回密码邮件标题"
/>
</VbenFormItem>
<VbenFormItem name="reset_content" label="邮件内容">
<Input
v-model:value="form.reset_content"
type="textarea"
:rows="6"
placeholder="请输入找回密码邮件内容,可使用变量:{username}、{code}、{expire}"
/>
</VbenFormItem>
<VbenFormItem name="reset_enabled" label="启用状态">
<Switch v-model:checked="form.reset_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存模板
</PrimaryButton>
<DefaultButton @click="handlePreviewTemplate('reset')">
<Icon icon="lucide:eye" class="mr-1" />
预览模板
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
<!-- 通知邮件模板 -->
<VbenTabPane key="notify" tab="通知邮件">
<VbenForm @submit="handleSubmitTemplate">
<template #default="{ form }">
<VbenFormItem name="notify_subject" label="邮件标题">
<Input
v-model:value="form.notify_subject"
placeholder="请输入通知邮件标题"
/>
</VbenFormItem>
<VbenFormItem name="notify_content" label="邮件内容">
<Input
v-model:value="form.notify_content"
type="textarea"
:rows="6"
placeholder="请输入通知邮件内容,可使用变量:{username}、{title}、{content}"
/>
</VbenFormItem>
<VbenFormItem name="notify_enabled" label="启用状态">
<Switch v-model:checked="form.notify_enabled" />
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="submitLoading">
<Icon icon="lucide:check" class="mr-1" />
保存模板
</PrimaryButton>
<DefaultButton @click="handlePreviewTemplate('notify')">
<Icon icon="lucide:eye" class="mr-1" />
预览模板
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenTabPane>
</VbenTabs>
</VbenTabPane>
</VbenTabs>
</VbenCard>
<!-- 测试邮件对话框 -->
<VbenModal v-model:open="testDialogVisible" title="发送测试邮件" width="500px">
<VbenForm @submit="handleSendTest">
<template #default="{ form }">
<VbenFormItem name="test_email" label="收件人邮箱">
<Input
v-model:value="form.test_email"
placeholder="请输入测试邮箱地址"
/>
</VbenFormItem>
<VbenFormItem name="test_type" label="邮件类型">
<Select v-model:value="form.test_type" placeholder="请选择邮件类型">
<template #default>
<SelectOption value="register">注册验证</SelectOption>
<SelectOption value="reset">找回密码</SelectOption>
<SelectOption value="notify">通知邮件</SelectOption>
</template>
</Select>
</VbenFormItem>
</template>
<template #submit>
<Space>
<PrimaryButton html-type="submit" :loading="sendTestLoading">
<Icon icon="lucide:send" class="mr-1" />
发送
</PrimaryButton>
<DefaultButton @click="testDialogVisible = false">
取消
</DefaultButton>
</Space>
</template>
</VbenForm>
</VbenModal>
<!-- 模板预览对话框 -->
<VbenModal v-model:open="previewDialogVisible" title="模板预览" width="600px">
<div class="template-preview">
<div class="preview-item">
<label>邮件标题</label>
<div class="preview-content">{{ previewData.subject }}</div>
</div>
<div class="preview-item">
<label>邮件内容</label>
<div class="preview-content" v-html="previewData.content"></div>
</div>
</div>
<template #footer>
<DefaultButton @click="previewDialogVisible = false">
关闭
</DefaultButton>
</template>
</VbenModal>
</Page>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import {
Page,
VbenCard,
VbenTabs,
VbenTabPane,
VbenModal
} from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
import type { VbenFormSchema } from '#/adapter/form';
import {
getEmailConfigApi,
updateEmailConfigApi,
testEmailApi,
previewEmailTemplateApi
} from '#/api/common/email';
// 响应式数据
const activeTab = ref('smtp');
const templateTab = ref('register');
const submitLoading = ref(false);
const testLoading = ref(false);
const sendTestLoading = ref(false);
const testDialogVisible = ref(false);
const previewDialogVisible = ref(false);
// 预览数据
const previewData = reactive({
subject: '',
content: '',
});
// SMTP配置表单配置
const smtpFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'smtp_host',
label: 'SMTP服务器',
rules: 'required',
componentProps: {
placeholder: '请输入SMTP服务器地址',
},
},
{
component: 'InputNumber',
fieldName: 'smtp_port',
label: '端口',
rules: 'required',
componentProps: {
min: 1,
max: 65535,
placeholder: '请输入端口号',
},
},
{
component: 'Input',
fieldName: 'smtp_username',
label: '用户名',
rules: 'required',
componentProps: {
placeholder: '请输入邮箱用户名',
},
},
{
component: 'Input',
fieldName: 'smtp_password',
label: '密码',
rules: 'required',
componentProps: {
type: 'password',
placeholder: '请输入邮箱密码或授权码',
showPassword: true,
},
},
{
component: 'Input',
fieldName: 'smtp_from_email',
label: '发件人邮箱',
rules: 'required',
componentProps: {
placeholder: '请输入发件人邮箱',
},
},
{
component: 'Input',
fieldName: 'smtp_from_name',
label: '发件人名称',
rules: 'required',
componentProps: {
placeholder: '请输入发件人名称',
},
},
{
component: 'Select',
fieldName: 'smtp_encryption',
label: '加密方式',
componentProps: {
placeholder: '请选择加密方式',
options: [
{ label: '无', value: '' },
{ label: 'SSL', value: 'ssl' },
{ label: 'TLS', value: 'tls' },
],
},
},
{
component: 'Switch',
fieldName: 'smtp_enabled',
label: '启用状态',
},
];
// 邮件模板表单配置
const templateFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'register_subject',
label: '邮件标题',
rules: 'required',
componentProps: {
placeholder: '请输入注册验证邮件标题',
},
},
{
component: 'Input',
fieldName: 'register_content',
label: '邮件内容',
rules: 'required',
componentProps: {
type: 'textarea',
rows: 6,
placeholder: '请输入注册验证邮件内容,可使用变量:{username}、{code}、{expire}',
},
},
{
component: 'Switch',
fieldName: 'register_enabled',
label: '启用状态',
},
];
// 测试邮件表单配置
const testFormSchema: VbenFormSchema[] = [
{
component: 'Input',
fieldName: 'test_email',
label: '收件人邮箱',
rules: 'required',
componentProps: {
placeholder: '请输入测试邮箱地址',
},
},
{
component: 'Select',
fieldName: 'test_type',
label: '邮件类型',
rules: 'required',
componentProps: {
placeholder: '请选择邮件类型',
options: [
{ label: '注册验证', value: 'register' },
{ label: '找回密码', value: 'reset' },
{ label: '通知邮件', value: 'notify' },
],
},
},
];
// 创建表单实例
const [SmtpForm, smtpFormApi] = useVbenForm({
schema: smtpFormSchema,
showDefaultActions: false,
});
const [TemplateForm, templateFormApi] = useVbenForm({
schema: templateFormSchema,
showDefaultActions: false,
});
const [TestForm, testFormApi] = useVbenForm({
schema: testFormSchema,
showDefaultActions: false,
});
// 处理SMTP配置提交
const handleSubmitSmtp = async (values: Record<string, any>) => {
try {
submitLoading.value = true;
await updateEmailConfigApi('smtp', values);
// 显示成功消息
} catch (error) {
console.error('保存SMTP配置失败:', error);
} finally {
submitLoading.value = false;
}
};
// 处理模板提交
const handleSubmitTemplate = async (values: Record<string, any>) => {
try {
submitLoading.value = true;
await updateEmailConfigApi('template', values);
// 显示成功消息
} catch (error) {
console.error('保存邮件模板失败:', error);
} finally {
submitLoading.value = false;
}
};
// 处理测试邮件
const handleTestEmail = () => {
testDialogVisible.value = true;
};
// 发送测试邮件
const handleSendTest = async (values: Record<string, any>) => {
try {
sendTestLoading.value = true;
await testEmailApi(values.test_email, values.test_type);
testDialogVisible.value = false;
// 显示成功消息
} catch (error) {
console.error('发送测试邮件失败:', error);
} finally {
sendTestLoading.value = false;
}
};
// 预览模板
const handlePreviewTemplate = async (type: string) => {
try {
const data = await previewEmailTemplateApi(type);
previewData.subject = data.subject;
previewData.content = data.content;
previewDialogVisible.value = true;
} catch (error) {
console.error('预览模板失败:', error);
}
};
// 重置SMTP配置
const handleResetSmtp = () => {
smtpFormApi.resetForm();
};
// 初始化数据
const initData = async () => {
try {
const [smtpData, templateData] = await Promise.all([
getEmailConfigApi('smtp'),
getEmailConfigApi('template'),
]);
smtpFormApi.setValues(smtpData);
templateFormApi.setValues(templateData);
} catch (error) {
console.error('初始化数据失败:', error);
}
};
// 组件挂载时初始化数据
onMounted(() => {
initData();
});
</script>
<style scoped>
.template-preview {
padding: 16px 0;
}
.preview-item {
margin-bottom: 16px;
}
.preview-item label {
font-weight: 500;
color: #333;
margin-bottom: 8px;
display: block;
}
.preview-content {
padding: 12px;
background-color: #f5f5f5;
border-radius: 4px;
border: 1px solid #e0e0e0;
white-space: pre-wrap;
word-break: break-word;
}
</style>

Some files were not shown because too many files have changed in this diff Show More