gengx
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
PORT=5320
|
||||
ACCESS_TOKEN_SECRET=access_token_secret
|
||||
REFRESH_TOKEN_SECRET=refresh_token_secret
|
||||
@@ -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
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { eventHandler, getQuery } from 'h3';
|
||||
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||
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',
|
||||
});
|
||||
|
||||
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' }),
|
||||
),
|
||||
deptId: faker.string.uuid(),
|
||||
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,
|
||||
deptId,
|
||||
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));
|
||||
}
|
||||
if (deptId) {
|
||||
listData = listData.filter((item) => item.deptId === deptId);
|
||||
}
|
||||
return usePageResponseSuccess(page as string, pageSize as string, listData);
|
||||
});
|
||||
@@ -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: number;
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineEventHandler } from 'h3';
|
||||
|
||||
export default defineEventHandler(() => 'Test get handler');
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineEventHandler } from 'h3';
|
||||
|
||||
export default defineEventHandler(() => 'Test post handler');
|
||||
@@ -0,0 +1,12 @@
|
||||
import { eventHandler } from 'h3';
|
||||
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||
import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
|
||||
import { getTimezone } from '~/utils/timezone-utils';
|
||||
|
||||
export default eventHandler((event) => {
|
||||
const userinfo = verifyAccessToken(event);
|
||||
if (!userinfo) {
|
||||
return unAuthorizedResponse(event);
|
||||
}
|
||||
return useResponseSuccess(getTimezone());
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { eventHandler } from 'h3';
|
||||
import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default eventHandler(() => {
|
||||
const data = TIME_ZONE_OPTIONS.map((o) => ({
|
||||
label: `${o.timezone} (GMT${o.offset >= 0 ? `+${o.offset}` : o.offset})`,
|
||||
value: o.timezone,
|
||||
}));
|
||||
return useResponseSuccess(data);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { eventHandler, readBody, setResponseStatus } from 'h3';
|
||||
import { verifyAccessToken } from '~/utils/jwt-utils';
|
||||
import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
|
||||
import {
|
||||
unAuthorizedResponse,
|
||||
useResponseError,
|
||||
useResponseSuccess,
|
||||
} from '~/utils/response';
|
||||
import { setTimezone } from '~/utils/timezone-utils';
|
||||
|
||||
export default eventHandler(async (event) => {
|
||||
const userinfo = verifyAccessToken(event);
|
||||
if (!userinfo) {
|
||||
return unAuthorizedResponse(event);
|
||||
}
|
||||
const body = await readBody<{ timezone?: unknown }>(event);
|
||||
const timezone =
|
||||
typeof body?.timezone === 'string' ? body.timezone : undefined;
|
||||
const allowed = TIME_ZONE_OPTIONS.some((o) => o.timezone === timezone);
|
||||
if (!timezone || !allowed) {
|
||||
setResponseStatus(event, 400);
|
||||
return useResponseError('Bad Request', 'Invalid timezone');
|
||||
}
|
||||
setTimezone(timezone);
|
||||
return useResponseSuccess({});
|
||||
});
|
||||
@@ -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")
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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, '演示环境,禁止修改');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineNitroConfig } from 'nitropack/config';
|
||||
|
||||
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': '*',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@vben/backend-mock",
|
||||
"version": "5.7.0",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineEventHandler } from 'h3';
|
||||
|
||||
export default defineEventHandler(() => {
|
||||
return `
|
||||
<h1>Hello Vben Admin</h1>
|
||||
<h2>Mock service is running</h2>
|
||||
<ul>
|
||||
<li><a href="/api/auth/login">/api/auth/login</a></li>
|
||||
<li><a href="/api/auth/codes">/api/auth/codes</a></li>
|
||||
<li><a href="/api/user/info">/api/user/info</a></li>
|
||||
<li><a href="/api/menu/all">/api/menu/all</a></li>
|
||||
<li><a href="/api/auth/logout">/api/auth/logout</a></li>
|
||||
</ul>
|
||||
`;
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/.*.ts"],
|
||||
"exclude": ["node_modules", "dist", ".nitro"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
password: string;
|
||||
realName: string;
|
||||
roles: string[];
|
||||
username: string;
|
||||
homePath?: string;
|
||||
}
|
||||
|
||||
export interface TimezoneOption {
|
||||
offset: number;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export const MOCK_USERS: UserInfo[] = [
|
||||
{
|
||||
id: 0,
|
||||
password: '123456',
|
||||
realName: 'Vben',
|
||||
roles: ['super'],
|
||||
username: 'vben',
|
||||
homePath: '/dashboard/workspace',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
password: '123456',
|
||||
realName: 'Admin',
|
||||
roles: ['admin'],
|
||||
username: 'admin',
|
||||
homePath: '/dashboard/workspace',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
password: '123456',
|
||||
realName: 'Jack',
|
||||
roles: ['user'],
|
||||
username: 'jack',
|
||||
homePath: '/dashboard/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: '/dashboard/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: 'admin-visible',
|
||||
},
|
||||
super: {
|
||||
component: '/demos/access/super-visible',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.superVisible',
|
||||
},
|
||||
name: 'AccessSuperVisibleDemo',
|
||||
path: 'super-visible',
|
||||
},
|
||||
user: {
|
||||
component: '/demos/access/user-visible',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.userVisible',
|
||||
},
|
||||
name: 'AccessUserVisibleDemo',
|
||||
path: '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: 'access',
|
||||
meta: {
|
||||
icon: 'mdi:cloud-key-outline',
|
||||
title: 'demos.access.backendPermissions',
|
||||
},
|
||||
redirect: '/demos/access/page-control',
|
||||
children: [
|
||||
{
|
||||
name: 'AccessPageControlDemo',
|
||||
path: 'page-control',
|
||||
component: '/demos/access/index',
|
||||
meta: {
|
||||
icon: 'mdi:page-previous-outline',
|
||||
title: 'demos.access.pageAccess',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AccessButtonControlDemo',
|
||||
path: 'button-control',
|
||||
component: '/demos/access/button-control',
|
||||
meta: {
|
||||
icon: 'mdi:button-cursor',
|
||||
title: 'demos.access.buttonControl',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AccessMenuVisible403Demo',
|
||||
path: '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: 'Dashboard',
|
||||
status: 1,
|
||||
type: 'catalog',
|
||||
icon: 'lucide:layout-dashboard',
|
||||
path: '/dashboard',
|
||||
meta: {
|
||||
icon: 'lucide:layout-dashboard',
|
||||
order: -1,
|
||||
title: 'page.dashboard.title',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
id: 101,
|
||||
pid: 1,
|
||||
status: 1,
|
||||
type: 'menu',
|
||||
name: 'Analytics',
|
||||
path: 'analytics',
|
||||
component: '/dashboard/analytics/index',
|
||||
meta: {
|
||||
affixTab: true,
|
||||
icon: 'lucide:area-chart',
|
||||
title: 'page.dashboard.analytics',
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
pid: 1,
|
||||
status: 1,
|
||||
type: 'menu',
|
||||
name: 'Workspace',
|
||||
path: 'workspace',
|
||||
component: '/views/dashboard/workspace/index',
|
||||
meta: {
|
||||
icon: 'carbon:workspace',
|
||||
title: 'page.dashboard.workspace',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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: 202,
|
||||
name: 'SystemDeptCreate',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Dept:Create',
|
||||
meta: { title: 'common.create' },
|
||||
},
|
||||
{
|
||||
id: 20_402,
|
||||
pid: 202,
|
||||
name: 'SystemDeptEdit',
|
||||
status: 1,
|
||||
type: 'button',
|
||||
authCode: 'System:Dept:Edit',
|
||||
meta: { title: 'common.edit' },
|
||||
},
|
||||
{
|
||||
id: 20_403,
|
||||
pid: 202,
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 时区选项
|
||||
*/
|
||||
export const TIME_ZONE_OPTIONS: TimezoneOption[] = [
|
||||
{
|
||||
offset: -5,
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
{
|
||||
offset: 0,
|
||||
timezone: 'Europe/London',
|
||||
},
|
||||
{
|
||||
offset: 8,
|
||||
timezone: 'Asia/Shanghai',
|
||||
},
|
||||
{
|
||||
offset: 9,
|
||||
timezone: 'Asia/Tokyo',
|
||||
},
|
||||
{
|
||||
offset: 9,
|
||||
timezone: 'Asia/Seoul',
|
||||
},
|
||||
];
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
let mockTimeZone: null | string = null;
|
||||
|
||||
export const setTimezone = (timeZone: string) => {
|
||||
mockTimeZone = timeZone;
|
||||
};
|
||||
|
||||
export const getTimezone = () => {
|
||||
return mockTimeZone;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
# 应用标题
|
||||
VITE_APP_TITLE=真羊 AI 客服 · 管理后台
|
||||
|
||||
# 应用命名空间,用于缓存、store等功能的前缀,确保隔离
|
||||
VITE_APP_NAMESPACE=zhenyang-admin
|
||||
|
||||
# 对 store 持久化到 localStorage 时加密用的密钥。
|
||||
# 模板里默认是 please-replace-me-with-your-own-key——那是公开值,等于没加密。
|
||||
VITE_APP_STORE_SECURE_KEY=DHIpryct4GsHrszDncvvxvzb8cp69mhEoXZvIU-ubik
|
||||
@@ -0,0 +1,7 @@
|
||||
# public path
|
||||
VITE_BASE=/
|
||||
|
||||
# Basic interface address SPA
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
VITE_VISUALIZER=true
|
||||
@@ -0,0 +1,16 @@
|
||||
# 端口号
|
||||
VITE_PORT=5666
|
||||
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
VITE_GLOB_API_URL=/api
|
||||
|
||||
# 是否开启 Nitro Mock服务,true 为开启,false 为关闭
|
||||
VITE_NITRO_MOCK=false
|
||||
|
||||
# 是否打开 devtools,true 为打开,false 为关闭
|
||||
VITE_DEVTOOLS=false
|
||||
|
||||
# 是否注入全局loading
|
||||
VITE_INJECT_APP_LOADING=true
|
||||
@@ -0,0 +1,22 @@
|
||||
VITE_BASE=/
|
||||
|
||||
# 接口地址
|
||||
# 生产走同源相对路径:前端是静态文件,由同一个 Nginx 把 /api 反代到
|
||||
# admin_api.py。模板默认值是 vben 的公共 mock 服务器 mock-napi.vben.pro——
|
||||
# 那意味着打包后所有管理请求(含登录凭据)都会发给第三方主机。
|
||||
VITE_GLOB_API_URL=/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
|
||||
@@ -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?b38e689f40558f20a9a686d7f6f33edf';
|
||||
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>
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@vben/web-antd",
|
||||
"version": "5.7.0",
|
||||
"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-antd"
|
||||
},
|
||||
"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:",
|
||||
"ant-design-vue": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* 通用组件共同的使用的基础组件,原先放在 adapter/form 内部,限制了使用范围,这里提取出来,方便其他地方使用
|
||||
* 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
|
||||
*/
|
||||
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import type {
|
||||
AutoCompleteProps,
|
||||
ButtonProps,
|
||||
CascaderProps,
|
||||
CheckboxGroupProps,
|
||||
CheckboxProps,
|
||||
DatePickerProps,
|
||||
DividerProps,
|
||||
InputNumberProps,
|
||||
InputProps,
|
||||
MentionsProps,
|
||||
RadioGroupProps,
|
||||
RadioProps,
|
||||
RateProps,
|
||||
SelectProps,
|
||||
SpaceProps,
|
||||
SwitchProps,
|
||||
TextAreaProps,
|
||||
TimePickerProps,
|
||||
TreeSelectProps,
|
||||
UploadChangeParam,
|
||||
UploadFile,
|
||||
UploadProps,
|
||||
} from 'ant-design-vue';
|
||||
import type { RangePickerProps } from 'ant-design-vue/es/date-picker';
|
||||
|
||||
import type { Component, Ref } from 'vue';
|
||||
|
||||
import type {
|
||||
ApiComponentSharedProps,
|
||||
BaseFormComponentType,
|
||||
IconPickerProps,
|
||||
} from '@vben/common-ui';
|
||||
import type { Sortable } from '@vben/hooks';
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
defineComponent,
|
||||
h,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
render,
|
||||
unref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
ApiComponent,
|
||||
globalShareState,
|
||||
IconPicker,
|
||||
VCropper,
|
||||
} from '@vben/common-ui';
|
||||
import { useSortable } from '@vben/hooks';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { message, Modal, notification } from 'ant-design-vue';
|
||||
|
||||
type AdapterUploadProps = UploadProps & {
|
||||
aspectRatio?: string;
|
||||
crop?: boolean;
|
||||
draggable?: boolean;
|
||||
handleChange?: (event: UploadChangeParam) => void;
|
||||
maxSize?: number;
|
||||
onDragSort?: (oldIndex: number, newIndex: number) => void;
|
||||
onHandleChange?: (event: UploadChangeParam) => void;
|
||||
};
|
||||
|
||||
const AutoComplete = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/auto-complete'),
|
||||
);
|
||||
const Button = defineAsyncComponent(() => import('ant-design-vue/es/button'));
|
||||
const Checkbox = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/checkbox'),
|
||||
);
|
||||
const CheckboxGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/checkbox').then((res) => res.CheckboxGroup),
|
||||
);
|
||||
const DatePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/date-picker'),
|
||||
);
|
||||
const Divider = defineAsyncComponent(() => import('ant-design-vue/es/divider'));
|
||||
const Input = defineAsyncComponent(() => import('ant-design-vue/es/input'));
|
||||
const InputNumber = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/input-number'),
|
||||
);
|
||||
const InputPassword = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.InputPassword),
|
||||
);
|
||||
const Mentions = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/mentions'),
|
||||
);
|
||||
const Radio = defineAsyncComponent(() => import('ant-design-vue/es/radio'));
|
||||
const RadioGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/radio').then((res) => res.RadioGroup),
|
||||
);
|
||||
const RangePicker = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/date-picker').then((res) => res.RangePicker),
|
||||
);
|
||||
const Rate = defineAsyncComponent(() => import('ant-design-vue/es/rate'));
|
||||
const Select = defineAsyncComponent(() => import('ant-design-vue/es/select'));
|
||||
const Space = defineAsyncComponent(() => import('ant-design-vue/es/space'));
|
||||
const Switch = defineAsyncComponent(() => import('ant-design-vue/es/switch'));
|
||||
const Textarea = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/input').then((res) => res.Textarea),
|
||||
);
|
||||
const TimePicker = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/time-picker'),
|
||||
);
|
||||
const TreeSelect = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/tree-select'),
|
||||
);
|
||||
const Cascader = defineAsyncComponent(
|
||||
() => import('ant-design-vue/es/cascader'),
|
||||
);
|
||||
const Upload = defineAsyncComponent(() => import('ant-design-vue/es/upload'));
|
||||
const Image = defineAsyncComponent(() => import('ant-design-vue/es/image'));
|
||||
const PreviewGroup = defineAsyncComponent(() =>
|
||||
import('ant-design-vue/es/image').then((res) => res.ImagePreviewGroup),
|
||||
);
|
||||
|
||||
const withDefaultPlaceholder = (
|
||||
component: Component,
|
||||
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,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
'bmp',
|
||||
'gif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'webp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* 检查是否为图片文件
|
||||
*/
|
||||
function isImageFile(file: UploadFile): boolean {
|
||||
if (file.url) {
|
||||
try {
|
||||
const pathname = new URL(file.url, 'http://localhost').pathname;
|
||||
const ext = pathname.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
} catch {
|
||||
const ext = file.url?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
}
|
||||
if (!file.type) {
|
||||
const ext = file.name?.split('.').pop()?.toLowerCase();
|
||||
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
|
||||
}
|
||||
return file.type.startsWith('image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的上传按钮插槽
|
||||
*/
|
||||
function createDefaultUploadSlots(listType: string, placeholder: string) {
|
||||
if (listType === 'picture-card') {
|
||||
return { default: () => placeholder };
|
||||
}
|
||||
return {
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
icon: h(IconifyIcon, {
|
||||
icon: 'ant-design:upload-outlined',
|
||||
class: 'mb-1 size-4',
|
||||
}),
|
||||
},
|
||||
() => placeholder,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件的 Base64
|
||||
*/
|
||||
function getBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.addEventListener('load', () => resolve(reader.result as string));
|
||||
reader.addEventListener('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
*/
|
||||
async function previewImage(
|
||||
file: UploadFile,
|
||||
visible: Ref<boolean>,
|
||||
fileList: Ref<UploadProps['fileList']>,
|
||||
) {
|
||||
// 非图片文件直接打开链接
|
||||
if (!isImageFile(file)) {
|
||||
const url = file.url || file.preview;
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
message.error($t('ui.formRules.previewWarning'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [ImageComponent, PreviewGroupComponent] = await Promise.all([
|
||||
Image,
|
||||
PreviewGroup,
|
||||
]);
|
||||
|
||||
// 过滤图片文件并生成预览
|
||||
const imageFiles = (unref(fileList) || []).filter((f) => isImageFile(f));
|
||||
|
||||
for (const imgFile of imageFiles) {
|
||||
if (!imgFile.url && !imgFile.preview && imgFile.originFileObj) {
|
||||
imgFile.preview = await getBase64(imgFile.originFileObj);
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
let isUnmounted = false;
|
||||
|
||||
const currentIndex = imageFiles.findIndex((f) => f.uid === file.uid);
|
||||
|
||||
const PreviewWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
return h(
|
||||
PreviewGroupComponent,
|
||||
{
|
||||
class: 'hidden',
|
||||
preview: {
|
||||
visible: visible.value,
|
||||
current: currentIndex,
|
||||
onVisibleChange: (value: boolean) => {
|
||||
visible.value = value;
|
||||
if (!value) {
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
() =>
|
||||
imageFiles.map((imgFile) =>
|
||||
h(ImageComponent, {
|
||||
key: imgFile.uid,
|
||||
src: imgFile.url || imgFile.preview,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(PreviewWrapper), container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪操作
|
||||
*/
|
||||
function cropImage(file: File, aspectRatio: string | undefined) {
|
||||
return new Promise<Blob | string | undefined>((resolve, reject) => {
|
||||
const container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
|
||||
let isUnmounted = false;
|
||||
let objectUrl: null | string = null;
|
||||
|
||||
const open = ref<boolean>(true);
|
||||
const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
|
||||
|
||||
const closeModal = () => {
|
||||
open.value = false;
|
||||
setTimeout(() => {
|
||||
if (!isUnmounted && container) {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
isUnmounted = true;
|
||||
render(null, container);
|
||||
container.remove();
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const CropperWrapper = {
|
||||
setup() {
|
||||
return () => {
|
||||
if (isUnmounted) return null;
|
||||
if (!objectUrl) {
|
||||
objectUrl = URL.createObjectURL(file);
|
||||
}
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: open.value,
|
||||
title: h('div', {}, [
|
||||
$t('ui.crop.title'),
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class: `${aspectRatio ? '' : 'hidden'} ml-2 text-sm text-gray-400 font-normal`,
|
||||
},
|
||||
$t('ui.crop.titleTip', [aspectRatio]),
|
||||
),
|
||||
]),
|
||||
centered: true,
|
||||
width: 548,
|
||||
zIndex: 9999,
|
||||
keyboard: false,
|
||||
maskClosable: false,
|
||||
closable: false,
|
||||
cancelText: $t('common.cancel'),
|
||||
okText: $t('ui.crop.confirm'),
|
||||
destroyOnClose: true,
|
||||
onOk: async () => {
|
||||
const cropper = cropperRef.value;
|
||||
if (!cropper) {
|
||||
reject(new Error('Cropper not found'));
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataUrl = await cropper.getCropImage();
|
||||
if (dataUrl) {
|
||||
resolve(dataUrl);
|
||||
} else {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
}
|
||||
} catch {
|
||||
reject(new Error($t('ui.crop.errorTip')));
|
||||
} finally {
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
onCancel() {
|
||||
resolve('');
|
||||
closeModal();
|
||||
},
|
||||
},
|
||||
() =>
|
||||
h(VCropper, {
|
||||
ref: (ref: any) => (cropperRef.value = ref),
|
||||
img: objectUrl as string,
|
||||
aspectRatio,
|
||||
}),
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
render(h(CropperWrapper), container);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 带预览功能的上传组件
|
||||
*/
|
||||
const withPreviewUpload = () => {
|
||||
return defineComponent({
|
||||
name: Upload.name,
|
||||
emits: ['update:modelValue'],
|
||||
setup(
|
||||
props: any,
|
||||
{ attrs, slots, emit }: { attrs: any; emit: any; slots: any },
|
||||
) {
|
||||
const previewVisible = ref<boolean>(false);
|
||||
const placeholder = attrs?.placeholder || $t('ui.placeholder.upload');
|
||||
const listType = attrs?.listType || attrs?.['list-type'] || 'text';
|
||||
const fileList = ref<UploadProps['fileList']>(
|
||||
attrs?.fileList || attrs?.['file-list'] || [],
|
||||
);
|
||||
|
||||
const maxSize = computed(() => attrs?.maxSize ?? attrs?.['max-size']);
|
||||
const aspectRatio = computed(
|
||||
() => attrs?.aspectRatio ?? attrs?.['aspect-ratio'],
|
||||
);
|
||||
|
||||
const handleBeforeUpload = async (
|
||||
file: UploadFile,
|
||||
originFileList: Array<File>,
|
||||
) => {
|
||||
// 文件大小限制
|
||||
if (maxSize.value && (file.size || 0) / 1024 / 1024 > maxSize.value) {
|
||||
message.error($t('ui.formRules.sizeLimit', [maxSize.value]));
|
||||
file.status = 'removed';
|
||||
return false;
|
||||
}
|
||||
|
||||
// 图片裁剪处理
|
||||
if (
|
||||
attrs.crop &&
|
||||
!attrs.multiple &&
|
||||
originFileList[0] &&
|
||||
isImageFile(file)
|
||||
) {
|
||||
file.status = 'removed';
|
||||
const blob = await cropImage(originFileList[0], aspectRatio.value);
|
||||
if (!blob) {
|
||||
throw new Error($t('ui.crop.errorTip'));
|
||||
}
|
||||
return blob;
|
||||
}
|
||||
|
||||
return attrs.beforeUpload?.(file) ?? true;
|
||||
};
|
||||
|
||||
const handleChange = (event: UploadChangeParam) => {
|
||||
try {
|
||||
attrs.handleChange?.(event);
|
||||
attrs.onHandleChange?.(event);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
fileList.value = event.fileList.filter(
|
||||
(file) => file.status !== 'removed',
|
||||
);
|
||||
emit(
|
||||
'update:modelValue',
|
||||
event.fileList?.length ? fileList.value : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handlePreview = async (file: UploadFile) => {
|
||||
previewVisible.value = true;
|
||||
await previewImage(file, previewVisible, fileList);
|
||||
};
|
||||
|
||||
const renderUploadButton = () => {
|
||||
if (attrs.disabled) return null;
|
||||
return isEmpty(slots)
|
||||
? createDefaultUploadSlots(listType, placeholder)
|
||||
: slots;
|
||||
};
|
||||
|
||||
// 拖拽排序
|
||||
const draggable = computed(
|
||||
() => (attrs.draggable ?? false) && !attrs.disabled,
|
||||
);
|
||||
const uploadId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const sortableInstance = ref<null | Sortable>(null);
|
||||
|
||||
const styleId = `upload-drag-style-${uploadId}`;
|
||||
|
||||
function injectDragStyle() {
|
||||
if (!document.querySelector(`[id="${styleId}"]`)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item { cursor: move; }
|
||||
[data-upload-id="${uploadId}"] .ant-upload-list-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDragStyle() {
|
||||
document.querySelector(`[id="${styleId}"]`)?.remove();
|
||||
}
|
||||
|
||||
async function initSortable(retryCount = 0) {
|
||||
if (!draggable.value) return;
|
||||
|
||||
injectDragStyle();
|
||||
await nextTick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const container = document.querySelector(
|
||||
`[data-upload-id="${uploadId}"] .ant-upload-list`,
|
||||
) as HTMLElement;
|
||||
|
||||
if (!container) {
|
||||
if (retryCount < 5) {
|
||||
setTimeout(() => initSortable(retryCount + 1), 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { initializeSortable } = useSortable(container, {
|
||||
animation: 300,
|
||||
delay: 400,
|
||||
delayOnTouchOnly: true,
|
||||
filter:
|
||||
'.ant-upload-select, .ant-upload-list-item-error, .ant-upload-list-item-uploading',
|
||||
onEnd: (evt) => {
|
||||
const { oldIndex, newIndex } = evt;
|
||||
if (
|
||||
oldIndex === undefined ||
|
||||
newIndex === undefined ||
|
||||
oldIndex === newIndex
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = [...(fileList.value || [])];
|
||||
const [movedItem] = list.splice(oldIndex, 1);
|
||||
if (movedItem) {
|
||||
list.splice(newIndex, 0, movedItem);
|
||||
fileList.value = list;
|
||||
}
|
||||
|
||||
attrs.onDragSort?.(oldIndex, newIndex);
|
||||
emit('update:modelValue', fileList.value);
|
||||
},
|
||||
});
|
||||
|
||||
sortableInstance.value = await initializeSortable();
|
||||
}
|
||||
|
||||
// 监听表单值变化
|
||||
watch(
|
||||
() => attrs.modelValue,
|
||||
(res) => {
|
||||
fileList.value = res;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(initSortable);
|
||||
onUnmounted(() => {
|
||||
sortableInstance.value?.destroy();
|
||||
removeDragStyle();
|
||||
});
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{ 'data-upload-id': uploadId, class: 'w-full' },
|
||||
h(
|
||||
Upload,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
fileList: fileList.value,
|
||||
beforeUpload: handleBeforeUpload,
|
||||
onChange: handleChange,
|
||||
onPreview: handlePreview,
|
||||
},
|
||||
renderUploadButton() as any,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 这里需要自行根据业务组件库进行适配,需要用到的组件都需要在这里类型说明
|
||||
export type ComponentType =
|
||||
| 'ApiCascader'
|
||||
| 'ApiSelect'
|
||||
| 'ApiTreeSelect'
|
||||
| 'AutoComplete'
|
||||
| 'Cascader'
|
||||
| 'Checkbox'
|
||||
| 'CheckboxGroup'
|
||||
| 'DatePicker'
|
||||
| 'DefaultButton'
|
||||
| 'Divider'
|
||||
| 'IconPicker'
|
||||
| 'Input'
|
||||
| 'InputNumber'
|
||||
| 'InputPassword'
|
||||
| 'Mentions'
|
||||
| 'PrimaryButton'
|
||||
| 'Radio'
|
||||
| 'RadioGroup'
|
||||
| 'RangePicker'
|
||||
| 'Rate'
|
||||
| 'Select'
|
||||
| 'Space'
|
||||
| 'Switch'
|
||||
| 'Textarea'
|
||||
| 'TimePicker'
|
||||
| 'TreeSelect'
|
||||
| 'Upload'
|
||||
| BaseFormComponentType;
|
||||
|
||||
/**
|
||||
* 与 {@link ComponentType} 中注册的组件名一一对应,便于 Schema 上 `component` + `componentProps` 联动提示
|
||||
*/
|
||||
export interface ComponentPropsMap {
|
||||
ApiCascader: ApiComponentSharedProps & CascaderProps;
|
||||
ApiSelect: ApiComponentSharedProps & SelectProps;
|
||||
ApiTreeSelect: ApiComponentSharedProps & TreeSelectProps;
|
||||
AutoComplete: AutoCompleteProps;
|
||||
Cascader: CascaderProps;
|
||||
Checkbox: CheckboxProps;
|
||||
CheckboxGroup: CheckboxGroupProps;
|
||||
DatePicker: DatePickerProps;
|
||||
DefaultButton: ButtonProps;
|
||||
Divider: DividerProps;
|
||||
IconPicker: IconPickerProps;
|
||||
Input: InputProps;
|
||||
InputNumber: InputNumberProps;
|
||||
InputPassword: InputProps;
|
||||
Mentions: MentionsProps;
|
||||
PrimaryButton: ButtonProps;
|
||||
Radio: RadioProps;
|
||||
RadioGroup: RadioGroupProps;
|
||||
RangePicker: RangePickerProps;
|
||||
Rate: RateProps;
|
||||
Select: SelectProps;
|
||||
Space: SpaceProps;
|
||||
Switch: SwitchProps;
|
||||
Textarea: TextAreaProps;
|
||||
TimePicker: TimePickerProps;
|
||||
TreeSelect: TreeSelectProps;
|
||||
Upload: AdapterUploadProps;
|
||||
}
|
||||
|
||||
async function initComponentAdapter() {
|
||||
const components: Partial<Record<ComponentType, Component>> = {
|
||||
// 如果你的组件体积比较大,可以使用异步加载
|
||||
// Button: () =>
|
||||
// import('xxx').then((res) => res.Button),
|
||||
|
||||
ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Cascader,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: Select,
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
|
||||
component: TreeSelect,
|
||||
fieldNames: { label: 'label', value: 'value', children: 'children' },
|
||||
loadingSlot: 'suffixIcon',
|
||||
modelPropName: 'value',
|
||||
optionsPropName: 'treeData',
|
||||
visibleEvent: 'onVisibleChange',
|
||||
}),
|
||||
AutoComplete,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
DatePicker,
|
||||
// 自定义默认按钮
|
||||
DefaultButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'default' }, slots);
|
||||
},
|
||||
Divider,
|
||||
IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
|
||||
iconSlot: 'addonAfter',
|
||||
inputComponent: Input,
|
||||
modelValueProp: 'value',
|
||||
}),
|
||||
Input: withDefaultPlaceholder(Input, 'input'),
|
||||
InputNumber: withDefaultPlaceholder(InputNumber, 'input', {
|
||||
style: { width: '100%' },
|
||||
}),
|
||||
InputPassword: withDefaultPlaceholder(InputPassword, 'input'),
|
||||
Mentions: withDefaultPlaceholder(Mentions, 'input'),
|
||||
// 自定义主要按钮
|
||||
PrimaryButton: (props, { attrs, slots }) => {
|
||||
return h(Button, { ...props, attrs, type: 'primary' }, slots);
|
||||
},
|
||||
Radio,
|
||||
RadioGroup,
|
||||
RangePicker,
|
||||
Rate,
|
||||
Select: withDefaultPlaceholder(Select, 'select'),
|
||||
Space,
|
||||
Switch,
|
||||
Textarea: withDefaultPlaceholder(Textarea, 'input'),
|
||||
TimePicker,
|
||||
TreeSelect: withDefaultPlaceholder(TreeSelect, 'select'),
|
||||
Upload: withPreviewUpload(),
|
||||
};
|
||||
|
||||
// 将组件注册到全局共享状态中
|
||||
globalShareState.setComponents(components);
|
||||
|
||||
// 定义全局共享状态中的消息提示
|
||||
globalShareState.defineMessage({
|
||||
// 复制成功消息提示
|
||||
copyPreferencesSuccess: (title, content) => {
|
||||
notification.success({
|
||||
description: content,
|
||||
message: title,
|
||||
placement: 'bottomRight',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export { initComponentAdapter };
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
VbenFormProps as FormProps,
|
||||
VbenFormSchema as FormSchema,
|
||||
FormValues,
|
||||
} from '@vben/common-ui';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
async function initSetupVbenForm() {
|
||||
setupVbenForm<ComponentType>({
|
||||
config: {
|
||||
// ant design vue组件库默认都是 v-model:value
|
||||
baseModelPropName: 'value',
|
||||
|
||||
// 一些组件是 v-model:checked 或者 v-model:fileList
|
||||
modelPropNameMap: {
|
||||
Checkbox: 'checked',
|
||||
Radio: 'checked',
|
||||
Switch: 'checked',
|
||||
Upload: 'fileList',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 输入项目必填国际化适配
|
||||
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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function useVbenForm<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
options: FormProps<
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>,
|
||||
) {
|
||||
return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export { initSetupVbenForm, useVbenForm, z };
|
||||
|
||||
export type VbenFormSchema<TValues extends FormValues = FormValues> =
|
||||
FormSchema<ComponentType, ComponentPropsMap, TValues>;
|
||||
export type VbenFormProps<
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
> = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { FormValues } from '@vben/common-ui';
|
||||
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
|
||||
|
||||
import type { ComponentPropsMap, ComponentType } from './component';
|
||||
|
||||
import { h } from 'vue';
|
||||
|
||||
import {
|
||||
setupVbenVxeTable,
|
||||
useVbenVxeGrid as useGrid,
|
||||
} from '@vben/plugins/vxe-table';
|
||||
|
||||
import { Button, Image } from 'ant-design-vue';
|
||||
|
||||
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 { props } = renderOpts;
|
||||
const { column, row } = params;
|
||||
return h(Image, { src: row[column.field], ...props });
|
||||
},
|
||||
});
|
||||
|
||||
// 表格配置项可以用 cellRender: { name: 'CellLink' },
|
||||
vxeUI.renderer.add('CellLink', {
|
||||
renderTableDefault(renderOpts) {
|
||||
const { props } = renderOpts;
|
||||
return h(
|
||||
Button,
|
||||
{ size: 'small', type: 'link' },
|
||||
{ default: () => props?.text },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// 这里可以自行扩展 vxe-table 的全局配置,比如自定义格式化
|
||||
// vxeUI.formats.add
|
||||
},
|
||||
useVbenForm,
|
||||
});
|
||||
|
||||
export const useVbenVxeGrid = <
|
||||
T extends Record<string, any>,
|
||||
TFormValues extends FormValues = FormValues,
|
||||
TSubmitValues extends FormValues = TFormValues,
|
||||
>(
|
||||
...rest: Parameters<
|
||||
typeof useGrid<
|
||||
T,
|
||||
ComponentType,
|
||||
ComponentPropsMap,
|
||||
TFormValues,
|
||||
TSubmitValues
|
||||
>
|
||||
>
|
||||
) =>
|
||||
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
|
||||
...rest,
|
||||
);
|
||||
|
||||
export type * from '@vben/plugins/vxe-table';
|
||||
@@ -0,0 +1,261 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface ArchiveStats {
|
||||
conversations: number;
|
||||
exports: number;
|
||||
imports: number;
|
||||
last_message_at: string;
|
||||
media: number;
|
||||
media_failed: number;
|
||||
media_ready: number;
|
||||
messages: number;
|
||||
people: number;
|
||||
}
|
||||
|
||||
export interface ArchiveConversation {
|
||||
conversation_type: string;
|
||||
external_id: string;
|
||||
id: string;
|
||||
last_content: string;
|
||||
last_message_at: string;
|
||||
message_count: number;
|
||||
name: string;
|
||||
source_account: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMessage {
|
||||
attachment_count: number;
|
||||
attachments: ArchiveAttachment[];
|
||||
content: string;
|
||||
direction: string;
|
||||
id: string;
|
||||
message_type: string;
|
||||
sender_name: string;
|
||||
sender_person_id: null | string;
|
||||
sent_at: string;
|
||||
sequence_no: null | number;
|
||||
source_message_id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveAttachment {
|
||||
attachment_index: number;
|
||||
attachment_role: string;
|
||||
checksum?: string;
|
||||
id: string;
|
||||
match_confidence: number;
|
||||
match_method: string;
|
||||
media_type: 'audio' | 'file' | 'image' | 'video';
|
||||
mime_type: string;
|
||||
original_filename: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMediaAccess {
|
||||
expires_in: number;
|
||||
id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ArchiveStorage {
|
||||
bucket: string;
|
||||
custom_domain: string;
|
||||
enabled: boolean;
|
||||
encryption_mode: string;
|
||||
export_prefix: string;
|
||||
media_prefix: string;
|
||||
region: string;
|
||||
secret_id_masked: string;
|
||||
secret_id_present: boolean;
|
||||
secret_key_masked: string;
|
||||
secret_key_present: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArchiveMedia {
|
||||
bucket: string;
|
||||
created_at: string;
|
||||
encryption_mode: string;
|
||||
id: string;
|
||||
last_error: string;
|
||||
media_type: string;
|
||||
mime_type: string;
|
||||
object_key: string;
|
||||
original_filename: string;
|
||||
region: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
status: string;
|
||||
verified_at: string;
|
||||
version_id: string;
|
||||
}
|
||||
|
||||
export interface ArchivePerson {
|
||||
conversation_count: number;
|
||||
created_at: string;
|
||||
display_name: string;
|
||||
id: string;
|
||||
identity_count: number;
|
||||
message_count: number;
|
||||
real_name: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ArchiveIdentity {
|
||||
created_at: string;
|
||||
external_id: string;
|
||||
id: string;
|
||||
identity_type: string;
|
||||
scope_id: string;
|
||||
source: string;
|
||||
updated_at: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface ArchivePersonDetail {
|
||||
display_name: string;
|
||||
id: string;
|
||||
identities: ArchiveIdentity[];
|
||||
real_name: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportFile {
|
||||
file_name: string;
|
||||
format: string;
|
||||
id: string;
|
||||
sha256: string;
|
||||
size_bytes: number;
|
||||
storage_status: string;
|
||||
}
|
||||
|
||||
export interface ArchiveExportJob {
|
||||
completed_at: string;
|
||||
created_at: string;
|
||||
cutoff_at: string;
|
||||
error_message: string;
|
||||
files: ArchiveExportFile[];
|
||||
filters: Record<string, unknown>;
|
||||
formats: string[];
|
||||
id: string;
|
||||
progress: number;
|
||||
started_at: string;
|
||||
status: 'completed' | 'failed' | 'queued' | 'running';
|
||||
total_rows: number;
|
||||
}
|
||||
|
||||
function query(values: Record<string, number | string>) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
if (value !== '') params.set(key, String(value));
|
||||
});
|
||||
const text = params.toString();
|
||||
return text ? `?${text}` : '';
|
||||
}
|
||||
|
||||
export function fetchArchiveStats() {
|
||||
return requestClient.get<ArchiveStats>('/archive/stats');
|
||||
}
|
||||
|
||||
export function fetchArchiveConversations(limit = 50, cursor = '') {
|
||||
return requestClient.get<{
|
||||
has_more: boolean;
|
||||
items: ArchiveConversation[];
|
||||
next_cursor: string;
|
||||
}>(`/archive/conversations${query({ cursor, limit })}`);
|
||||
}
|
||||
|
||||
export function fetchArchiveMessages(
|
||||
conversationId: string,
|
||||
limit = 100,
|
||||
cursor = '',
|
||||
) {
|
||||
return requestClient.get<{
|
||||
has_more: boolean;
|
||||
items: ArchiveMessage[];
|
||||
next_cursor: string;
|
||||
}>(
|
||||
`/archive/conversations/${encodeURIComponent(conversationId)}/messages${query({ cursor, limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveMediaAccessUrls(mediaIds: string[], expires = 900) {
|
||||
return requestClient.post<{ items: ArchiveMediaAccess[] }>(
|
||||
'/archive/media/access-urls',
|
||||
{ expires, media_ids: mediaIds },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchiveStorage() {
|
||||
return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage');
|
||||
}
|
||||
|
||||
export function saveArchiveStorage(data: Partial<ArchiveStorage> & {
|
||||
secret_id?: string;
|
||||
secret_key?: string;
|
||||
}) {
|
||||
return requestClient.put<{ storage: ArchiveStorage }>(
|
||||
'/archive/storage',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function testArchiveStorage() {
|
||||
return requestClient.post<{
|
||||
result: { bucket: string; ok: boolean; region: string; request_id: string };
|
||||
}>('/archive/storage/test');
|
||||
}
|
||||
|
||||
export function fetchArchiveMedia(limit = 100) {
|
||||
return requestClient.get<{ items: ArchiveMedia[] }>(
|
||||
`/archive/media${query({ limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchivePeople(limit = 100, keyword = '') {
|
||||
return requestClient.get<{ items: ArchivePerson[] }>(
|
||||
`/archive/people${query({ keyword, limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchArchivePerson(personId: string) {
|
||||
return requestClient.get<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function bindArchiveIdentity(
|
||||
personId: string,
|
||||
data: {
|
||||
external_id: string;
|
||||
identity_type: string;
|
||||
scope_id: string;
|
||||
verified: boolean;
|
||||
},
|
||||
) {
|
||||
return requestClient.post<{ person: ArchivePersonDetail }>(
|
||||
`/archive/people/${encodeURIComponent(personId)}/identities`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function createArchiveExport(data: {
|
||||
filters: Record<string, unknown>;
|
||||
formats: string[];
|
||||
}) {
|
||||
return requestClient.post<{ job: ArchiveExportJob }>('/archive/exports', data);
|
||||
}
|
||||
|
||||
export function fetchArchiveExports(limit = 100) {
|
||||
return requestClient.get<{ jobs: ArchiveExportJob[] }>(
|
||||
`/archive/exports${query({ limit })}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadArchiveExport(fileId: string) {
|
||||
return requestClient.download<Blob>(
|
||||
`/archive/export-files/${encodeURIComponent(fileId)}/download`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 业务接口:模型清单、角色权限、用户、调用统计。
|
||||
*
|
||||
* 全部对 `admin_api.py` 的 /api/v2/*。这一层只负责搬数据,权限判定在后端——
|
||||
* 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export interface ModelProvider {
|
||||
api_key?: string;
|
||||
api_key_masked?: string;
|
||||
base_url: string;
|
||||
capabilities: string;
|
||||
enabled: boolean;
|
||||
/** 后端算好的实际请求地址(只读) */
|
||||
endpoint?: string;
|
||||
/** auto = 按接口类型补全路径;exact = 地址原样使用 */
|
||||
endpoint_mode: string;
|
||||
health: string;
|
||||
id: string;
|
||||
kind: string;
|
||||
max_inflight: number;
|
||||
max_tokens: number;
|
||||
model: string;
|
||||
name: string;
|
||||
rpm_limit: number;
|
||||
temperature: number;
|
||||
timeout_ms: number;
|
||||
}
|
||||
|
||||
export interface ModelPlan {
|
||||
answer_ids: string;
|
||||
fallback_ids: string;
|
||||
judge_id: string;
|
||||
judge_mode: string;
|
||||
version: number;
|
||||
vision_id: string;
|
||||
}
|
||||
|
||||
export interface RoleItem {
|
||||
builtin: boolean;
|
||||
code: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
export interface PermissionItem {
|
||||
code: string;
|
||||
group_name: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
active: boolean;
|
||||
created_at: string;
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface CallStats {
|
||||
avg_ms: number;
|
||||
avg_score: number;
|
||||
chosen: { count: number; provider: string }[];
|
||||
judged: number;
|
||||
max_ms: number;
|
||||
risk: Record<string, number>;
|
||||
score_buckets: { count: number; range: string }[];
|
||||
since: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 单次调用的候选出口——`model_calls.candidates_json` 解出来的一项。 */
|
||||
export interface ModelCallCandidate {
|
||||
error?: string;
|
||||
latency_ms?: number;
|
||||
provider: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 单条调用留痕:客户说了什么、模型答了什么、有没有被拦下来。 */
|
||||
export interface ModelCallLogItem {
|
||||
candidates: ModelCallCandidate[];
|
||||
chosen: string;
|
||||
created_at: string;
|
||||
customer_text: string;
|
||||
device_id: string;
|
||||
id: number;
|
||||
judge_mode: string;
|
||||
judge_risk: string;
|
||||
judge_score: number;
|
||||
judge_winner: string;
|
||||
/** chat = 回客户的话;guard = 界面识别之类的内部判断 */
|
||||
purpose: string;
|
||||
reply_text: string;
|
||||
review_reason: string;
|
||||
roles_version: number;
|
||||
task_id: string;
|
||||
total_ms: number;
|
||||
}
|
||||
|
||||
// ── 模型 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchModels() {
|
||||
return requestClient.get<{
|
||||
judge_modes: string[];
|
||||
kinds: string[];
|
||||
models: ModelProvider[];
|
||||
roles: ModelPlan;
|
||||
}>('/models');
|
||||
}
|
||||
|
||||
export function saveModel(data: Partial<ModelProvider>) {
|
||||
return requestClient.post<{ models: ModelProvider[] }>('/models', data);
|
||||
}
|
||||
|
||||
export function deleteModel(id: string) {
|
||||
return requestClient.delete<{ models: ModelProvider[] }>(
|
||||
`/models/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveModelPlan(data: Partial<ModelPlan>) {
|
||||
return requestClient.post<{ roles: ModelPlan; version: number }>(
|
||||
'/models/plan',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 角色与权限 ───────────────────────────────────────────────────────────────
|
||||
export function fetchRoles() {
|
||||
return requestClient.get<{ roles: RoleItem[] }>('/roles');
|
||||
}
|
||||
|
||||
export function fetchPermissions() {
|
||||
return requestClient.get<{ permissions: PermissionItem[] }>('/permissions');
|
||||
}
|
||||
|
||||
export function saveRole(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
}) {
|
||||
return requestClient.post<{ roles: RoleItem[] }>('/roles', data);
|
||||
}
|
||||
|
||||
export function deleteRole(code: string) {
|
||||
return requestClient.delete<{ roles: RoleItem[] }>(
|
||||
`/roles/${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 用户 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchUsers() {
|
||||
return requestClient.get<{ users: UserItem[] }>('/users');
|
||||
}
|
||||
|
||||
export function createUser(data: {
|
||||
password: string;
|
||||
role: string;
|
||||
username: string;
|
||||
}) {
|
||||
return requestClient.post<{ users: UserItem[] }>('/users', data);
|
||||
}
|
||||
|
||||
export function updateUser(
|
||||
id: number,
|
||||
data: { active: boolean; role: string },
|
||||
) {
|
||||
return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data);
|
||||
}
|
||||
|
||||
// ── 运营 ─────────────────────────────────────────────────────────────────────
|
||||
export function fetchCallStats(days = 7) {
|
||||
return requestClient.get<CallStats>(`/stats/model-calls?days=${days}`);
|
||||
}
|
||||
|
||||
export function fetchCallLog(params: {
|
||||
days?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/** chat = 回客户的话(默认);guard = 界面识别;'' = 全都要 */
|
||||
purpose?: string;
|
||||
q?: string;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
query.set('days', String(params.days ?? 7));
|
||||
query.set('limit', String(params.limit ?? 50));
|
||||
query.set('offset', String(params.offset ?? 0));
|
||||
query.set('purpose', params.purpose ?? 'chat');
|
||||
if (params.q) query.set('q', params.q);
|
||||
return requestClient.get<{ items: ModelCallLogItem[]; total: number }>(
|
||||
`/stats/model-calls/log?${query.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAudit(limit = 200) {
|
||||
return requestClient.get<{
|
||||
entries: {
|
||||
action: string;
|
||||
created_at: string;
|
||||
detail: string;
|
||||
id: number;
|
||||
ip_address: string;
|
||||
username: null | string;
|
||||
}[];
|
||||
}>(`/audit?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function changeMyPassword(data: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
}) {
|
||||
return requestClient.post('/me/password', data);
|
||||
}
|
||||
|
||||
// ── 桌面端配置 ───────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 下发给桌面客户端的配置。
|
||||
*
|
||||
* 字段名是大写下划线,和老网页后台的表单完全一致——两边共用后端同一个校验函数,
|
||||
* 不会出现"这边填得进、那边填不进"。
|
||||
*
|
||||
* 这里**没有模型连接参数**:服务类型、API 地址、密钥、模型名、温度、tokens、
|
||||
* 超时全部搬到了「AI 模型 → 模型清单 / 角色编排」。桌面端的模型调用走网关,
|
||||
* 密钥不出后端。
|
||||
*/
|
||||
export interface DesktopConfig {
|
||||
AI_AGENT_NAME: string;
|
||||
AI_CONTEXT_ENABLED: boolean;
|
||||
AI_CONTEXT_MAX_ROUNDS: number;
|
||||
AI_COUNTER_INSULT_ENABLED: boolean;
|
||||
AI_DEVELOPMENT_MODE: boolean;
|
||||
AI_ENABLED: boolean;
|
||||
AI_HOSPITAL_NAME: string;
|
||||
AI_MCP_ENABLED: boolean;
|
||||
AI_MCP_MAX_ROUNDS: number;
|
||||
AI_MCP_SERVERS: unknown[];
|
||||
AI_UI_GUARD_ENABLED: boolean;
|
||||
AI_USE_VISION: boolean;
|
||||
/** 留空 = 按后台自己的地址自动推算 */
|
||||
AI_GATEWAY_URL: string;
|
||||
AI_REVIEW_RULES: ReviewRule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择性审核规则:命中任意一条,这一条回复才会停下来等人工确认,其余照常
|
||||
* 自动发送。`id` 可以留空——留空由后端根据 label 自动生成并去重。
|
||||
*/
|
||||
export interface ReviewRule {
|
||||
id?: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigEnvelope {
|
||||
bool_keys: string[];
|
||||
config: DesktopConfig;
|
||||
/** 留空时实际会下发给客户端的网关地址 */
|
||||
gateway_url_effective: string;
|
||||
/** 模型设置搬去哪儿了,用来在页面上给人指路 */
|
||||
model_settings_moved_to: string;
|
||||
retired_keys: string[];
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function fetchConfig() {
|
||||
return requestClient.get<ConfigEnvelope>('/config');
|
||||
}
|
||||
|
||||
export function saveConfig(data: DesktopConfig) {
|
||||
return requestClient.post<{ version: number }>('/config', data);
|
||||
}
|
||||
|
||||
// ── 桌面端版本升级 ───────────────────────────────────────────────────────────
|
||||
export interface ReleasePolicy {
|
||||
download_url: string;
|
||||
force_upgrade: boolean;
|
||||
latest_version: string;
|
||||
release_notes: string;
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
}
|
||||
|
||||
export function fetchRelease() {
|
||||
return requestClient.get<ReleasePolicy>('/release');
|
||||
}
|
||||
|
||||
export function saveRelease(data: {
|
||||
download_url: string;
|
||||
force_upgrade: boolean;
|
||||
latest_version: string;
|
||||
release_notes: string;
|
||||
}) {
|
||||
return requestClient.post<ReleasePolicy>('/release', data);
|
||||
}
|
||||
|
||||
// ── 模型连通性测试 ───────────────────────────────────────────────────────────
|
||||
export interface ModelTestResult {
|
||||
endpoint: string;
|
||||
http_status: null | number;
|
||||
latency_ms: number;
|
||||
message: string;
|
||||
model: string;
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给了 provider_id 就测清单里那一条,密钥由后端从库里解密取用——
|
||||
* 前端从头到尾拿不到明文,也就不可能在网络上多走一趟。
|
||||
*/
|
||||
export function testModel(data: {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
endpoint_mode?: string;
|
||||
kind?: string;
|
||||
model?: string;
|
||||
provider_id?: string;
|
||||
timeout_seconds?: number;
|
||||
}) {
|
||||
return requestClient.post<{ label: string; result: ModelTestResult }>(
|
||||
'/models/test',
|
||||
data,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* 认证接口。
|
||||
*
|
||||
* vben 默认对接的是它自带的 mock 后端(`/auth/login` 返回 `{accessToken}`、
|
||||
* `/auth/codes` 单独取权限码)。我们的后端是 `admin_api.py`,形状不一样,而且
|
||||
* **登录时就把权限码一起返回了**——少一次往返,也避免"已登录但权限还没到"
|
||||
* 这个中间态导致路由守卫误判。
|
||||
*
|
||||
* 适配放在这一层,不去改后端:接口契约不该被某一个客户端的习惯带偏。
|
||||
*/
|
||||
export namespace AuthApi {
|
||||
export interface LoginParams {
|
||||
password?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/** 后端 /api/v2/auth/login 的真实返回 */
|
||||
export interface BackendLoginResult {
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
user: {
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
permissions: string[];
|
||||
role: string;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录后暂存的权限码。getAccessCodesApi 直接用它,省掉一次请求。 */
|
||||
let cachedCodes: string[] = [];
|
||||
|
||||
export function takeCachedAccessCodes(): string[] {
|
||||
return cachedCodes;
|
||||
}
|
||||
|
||||
export async function loginApi(data: AuthApi.LoginParams) {
|
||||
const resp = await requestClient.post<AuthApi.BackendLoginResult>(
|
||||
'/auth/login',
|
||||
{ username: data.username, password: data.password, device_name: 'web' },
|
||||
);
|
||||
cachedCodes = resp.user?.permissions ?? [];
|
||||
return { accessToken: resp.access_token } as AuthApi.LoginResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 accessToken。
|
||||
*
|
||||
* 后端目前发的是 30 天有效的长令牌,没有 refresh token 机制,所以这里必须明确
|
||||
* 失败——返回一个假 token 会让请求拦截器以为续期成功,接着用一个无效令牌无限
|
||||
* 重试,表现是页面卡死而不是跳登录页。
|
||||
*/
|
||||
export async function refreshTokenApi(): Promise<never> {
|
||||
throw new Error('后端未启用令牌续期,请重新登录');
|
||||
}
|
||||
|
||||
export async function logoutApi() {
|
||||
cachedCodes = [];
|
||||
return requestClient.post('/auth/logout', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前用户的权限码。
|
||||
*
|
||||
* 优先用登录时带回来的那份;刷新页面后缓存是空的,再去 /me 拿一次。
|
||||
*/
|
||||
export async function getAccessCodesApi(): Promise<string[]> {
|
||||
if (cachedCodes.length > 0) {
|
||||
return cachedCodes;
|
||||
}
|
||||
const me = await requestClient.get<{ permissions: string[] }>('/me');
|
||||
cachedCodes = me.permissions ?? [];
|
||||
return cachedCodes;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './auth';
|
||||
export * from './menu';
|
||||
export * from './user';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { UserInfo } from '@vben/types';
|
||||
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
interface BackendMe {
|
||||
id: number;
|
||||
must_change_password: boolean;
|
||||
permissions: string[];
|
||||
role: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前登录用户。
|
||||
*
|
||||
* 后端的 `/api/v2/me` 是权限的唯一数据源——路由守卫和按钮显隐都以它为准。
|
||||
* 这里把它映射成 vben 的 UserInfo 形状。
|
||||
*
|
||||
* `roles` 塞的是**权限码**而不是角色名,这是有意的:vben 的 `v-access:role`
|
||||
* 指令按这个数组匹配,而我们全线只判权限码,不判角色名。判角色名等于把运营
|
||||
* 策略焊死在前端,加个角色就要重新发版。
|
||||
*/
|
||||
export async function getUserInfoApi(): Promise<UserInfo> {
|
||||
const me = await requestClient.get<BackendMe>('/me');
|
||||
return {
|
||||
avatar: '',
|
||||
realName: me.username,
|
||||
roles: me.permissions ?? [],
|
||||
userId: String(me.id),
|
||||
username: me.username,
|
||||
// 首登必须改密:把落地页锁死在改密页,用户改完才放行。后端不会拦改密接口
|
||||
// 本身(拦了就只能去老网页后台改),锁在前端这一层。
|
||||
homePath: me.must_change_password ? '/profile/password' : undefined,
|
||||
desc: me.role,
|
||||
token: '',
|
||||
} as UserInfo;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core';
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 该文件可自行根据业务逻辑进行调整
|
||||
*/
|
||||
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 { message } from 'ant-design-vue';
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
/**
|
||||
* 重新认证逻辑。
|
||||
*
|
||||
* 这里必须防重入:登出本身也是一个带鉴权头的请求,令牌已经无效时它同样会
|
||||
* 拿到 401,于是又走进这个函数、又去登出……递归下去,表现是浏览器疯狂
|
||||
* 发 POST /auth/logout 直到页面卡死。authStore.logout() 里的 try/catch 挡不住,
|
||||
* 它catch 的是自己那一层,下一层照样新起一轮。
|
||||
*/
|
||||
let reAuthenticating = false;
|
||||
async function doReAuthenticate() {
|
||||
if (reAuthenticating) {
|
||||
return;
|
||||
}
|
||||
reAuthenticating = true;
|
||||
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 {
|
||||
try {
|
||||
await authStore.logout();
|
||||
} finally {
|
||||
reAuthenticating = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
reAuthenticating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 token 逻辑。
|
||||
*
|
||||
* 后端发的是 30 天有效的长令牌,没有 refresh token。配套地
|
||||
* `enableRefreshToken` 关掉了,拦截器不会走到这里;万一哪天有人打开开关,
|
||||
* 这里立刻抛错,比默默续期一个假令牌然后无限重试要好得多。
|
||||
*/
|
||||
async function doRefreshToken(): Promise<string> {
|
||||
await refreshTokenApi();
|
||||
throw new Error('后端未启用令牌续期');
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
});
|
||||
|
||||
// 处理返回的响应数据格式。
|
||||
//
|
||||
// 模板默认配的是 `{codeField:'code', dataField:'data', successCode:0}`,也就是
|
||||
// 假定后端返回 `{code:0, data:{...}}` 这种信封。admin_api.py 返回的是扁平
|
||||
// JSON(`{"access_token":...}`),没有 code 字段,按默认配置每一个 200 都会被
|
||||
// 判成失败并抛出——症状是点登录毫无反应,控制台还一句错都不报。
|
||||
//
|
||||
// 所以改成透传:成败由 HTTP 状态码表达(这本来就是它该干的事),
|
||||
// 整个响应体就是数据。这个拦截器不能直接删掉——删了之后没人拆包,
|
||||
// 拿到的是整个 axios response,`resp.access_token` 一样是 undefined。
|
||||
client.addResponseInterceptor(
|
||||
defaultResponseInterceptor({
|
||||
codeField: '',
|
||||
dataField: (response) => response,
|
||||
successCode: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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 ?? {};
|
||||
// FastAPI 的错误信息在 detail 里(HTTPException(detail=...)),
|
||||
// 模板只认 error / message,不加这个后端说的话一句都显示不出来。
|
||||
const errorMessage =
|
||||
responseData?.detail ??
|
||||
responseData?.error ??
|
||||
responseData?.message ??
|
||||
'';
|
||||
// 如果没有错误信息,则会根据状态码进行提示
|
||||
message.error(errorMessage || msg);
|
||||
}),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export const requestClient = createRequestClient(apiURL, {
|
||||
responseReturn: 'data',
|
||||
});
|
||||
|
||||
export const baseRequestClient = new RequestClient({ baseURL: apiURL });
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'App' });
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
const { tokens } = useAntdDesignTokens();
|
||||
|
||||
const tokenTheme = computed(() => {
|
||||
const algorithm = isDark.value
|
||||
? [theme.darkAlgorithm]
|
||||
: [theme.defaultAlgorithm];
|
||||
|
||||
// antd 紧凑模式算法
|
||||
if (preferences.app.compact) {
|
||||
algorithm.push(theme.compactAlgorithm);
|
||||
}
|
||||
|
||||
return {
|
||||
algorithm,
|
||||
token: tokens,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfigProvider :locale="antdLocale" :theme="tokenTheme">
|
||||
<App>
|
||||
<RouterView />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createApp, watchEffect } from 'vue';
|
||||
|
||||
import { registerAccessDirective } from '@vben/access';
|
||||
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { initStores } from '@vben/stores';
|
||||
import '@vben/styles';
|
||||
import '@vben/styles/antd';
|
||||
|
||||
import { useTitle } from '@vueuse/core';
|
||||
|
||||
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: 1020,
|
||||
// });
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
// 注册v-loading指令
|
||||
registerLoadingDirective(app, {
|
||||
loading: 'loading', // 在这里可以自定义指令名称,也可以明确提供false表示不注册这个指令
|
||||
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 };
|
||||
@@ -0,0 +1,25 @@
|
||||
<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);
|
||||
const logoDark = computed(() => preferences.logo.sourceDark);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPageLayout
|
||||
:app-name="appName"
|
||||
:logo="logo"
|
||||
:logo-dark="logoDark"
|
||||
:page-description="$t('authentication.pageDesc')"
|
||||
:page-title="$t('authentication.pageTitle')"
|
||||
>
|
||||
<!-- 自定义工具栏 -->
|
||||
<!-- <template #toolbar></template> -->
|
||||
</AuthPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,261 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences, usePreferences } 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[]>([
|
||||
{
|
||||
id: 1,
|
||||
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||
date: '3小时前',
|
||||
isRead: true,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '收到了 14 份新周报',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '刚刚',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '朱偏右 回复了你',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '2024-01-01',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '曲丽丽 评论了你',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '代办提醒',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转Workspace示例',
|
||||
link: '/workspace',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转外部链接示例',
|
||||
link: 'https://doc.vben.pro',
|
||||
},
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { isDark } = usePreferences();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
handler: () => {
|
||||
router.push({ name: 'Profile' });
|
||||
},
|
||||
icon: 'lucide:user',
|
||||
text: $t('page.auth.profile'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_DOC_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: BookOpenText,
|
||||
text: $t('ui.widgets.document'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_GITHUB_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: SvgGithubIcon,
|
||||
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 markRead(id: number | string) {
|
||||
const item = notifications.value.find((item) => item.id === id);
|
||||
if (item) {
|
||||
item.isRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: number | string) {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
}
|
||||
|
||||
const viewAll = () => {};
|
||||
|
||||
const handleClick = (item: NotificationItem) => {
|
||||
// 如果通知项有链接,点击时跳转
|
||||
if (item.link) {
|
||||
navigateTo(item.link, item.query, item.state);
|
||||
}
|
||||
};
|
||||
|
||||
function navigateTo(
|
||||
link: string,
|
||||
query?: Record<string, any>,
|
||||
state?: Record<string, any>,
|
||||
) {
|
||||
if (link.startsWith('http://') || link.startsWith('https://')) {
|
||||
// 外部链接,在新标签页打开
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
// 内部路由链接,支持 query 参数和 state
|
||||
router.push({
|
||||
path: link,
|
||||
query: query || {},
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
content: preferences.app.watermarkContent,
|
||||
isDark: isDark.value,
|
||||
}),
|
||||
async ({ enable, content, isDark: isDarkValue }) => {
|
||||
if (enable) {
|
||||
const watermarkColor = isDarkValue
|
||||
? 'rgba(255, 255, 255, 0.12)'
|
||||
: 'rgba(0, 0, 0, 0.12)';
|
||||
|
||||
await updateWatermark({
|
||||
advancedStyle: {
|
||||
colorStops: [
|
||||
{
|
||||
color: watermarkColor,
|
||||
offset: 0,
|
||||
},
|
||||
{
|
||||
color: watermarkColor,
|
||||
offset: 1,
|
||||
},
|
||||
],
|
||||
type: 'linear',
|
||||
},
|
||||
content:
|
||||
content ||
|
||||
`${userStore.userInfo?.username} - ${userStore.userInfo?.realName}`,
|
||||
});
|
||||
} else {
|
||||
destroyWatermark();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasicLayout
|
||||
:avatar
|
||||
:text="userStore.userInfo?.realName"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
>
|
||||
<template #user-dropdown>
|
||||
<UserDropdown
|
||||
:avatar
|
||||
:menus
|
||||
:text="userStore.userInfo?.realName"
|
||||
description="ann.vben@gmail.com"
|
||||
tag-text="Pro"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@read="(item) => item.id && markRead(item.id)"
|
||||
@remove="(item) => item.id && remove(item.id)"
|
||||
@make-all="handleMakeAll"
|
||||
@on-click="handleClick"
|
||||
@view-all="viewAll"
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
:avatar
|
||||
>
|
||||
<LoginForm />
|
||||
</AuthenticationLoginExpiredModal>
|
||||
</template>
|
||||
<template #lock-screen>
|
||||
<LockScreen :avatar @to-login="handleLogout" />
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
# locale
|
||||
|
||||
每个app使用的国际化可能不同,这里用于扩展国际化的功能,例如扩展 dayjs、antd组件库的多语言切换,以及app本身的国际化文件。
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Locale } from 'ant-design-vue/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 antdEnLocale from 'ant-design-vue/es/locale/en_US';
|
||||
import antdDefaultLocale from 'ant-design-vue/es/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const antdLocale = ref<Locale>(antdDefaultLocale);
|
||||
|
||||
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([loadAntdLocale(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}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载antd的语言包
|
||||
* @param lang
|
||||
*/
|
||||
async function loadAntdLocale(lang: SupportedLanguagesType) {
|
||||
switch (lang) {
|
||||
case 'en-US': {
|
||||
antdLocale.value = antdEnLocale;
|
||||
break;
|
||||
}
|
||||
case 'zh-CN': {
|
||||
antdLocale.value = antdDefaultLocale;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
|
||||
await coreSetup(app, {
|
||||
defaultLocale: preferences.app.locale,
|
||||
loadMessages,
|
||||
missingWarn: !import.meta.env.PROD,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export { $t, antdLocale, setupI18n };
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "Demos",
|
||||
"antd": "Ant Design Vue",
|
||||
"vben": {
|
||||
"title": "Project",
|
||||
"about": "About",
|
||||
"document": "Document",
|
||||
"antdv": "Ant Design Vue Version",
|
||||
"antdv-next": "Antdv Next Version",
|
||||
"naive-ui": "Naive UI Version",
|
||||
"element-plus": "Element Plus Version",
|
||||
"tdesign": "TDesign Vue Version"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"codeLogin": "Code Login",
|
||||
"qrcodeLogin": "Qr Code Login",
|
||||
"forgetPassword": "Forget Password",
|
||||
"profile": "Profile"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"workspace": "Workspace"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"title": "演示",
|
||||
"antd": "Ant Design Vue",
|
||||
"vben": {
|
||||
"title": "项目",
|
||||
"about": "关于",
|
||||
"document": "文档",
|
||||
"antdv": "Ant Design Vue 版本",
|
||||
"antdv-next": "Antdv Next 版本",
|
||||
"naive-ui": "Naive UI 版本",
|
||||
"element-plus": "Element Plus 版本",
|
||||
"tdesign": "TDesign Vue 版本"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"register": "注册",
|
||||
"codeLogin": "验证码登录",
|
||||
"qrcodeLogin": "二维码登录",
|
||||
"forgetPassword": "忘记密码",
|
||||
"profile": "个人中心"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { initPreferences } from '@vben/preferences';
|
||||
import { unmountGlobalLoading } from '@vben/utils';
|
||||
|
||||
import { overridesPreferences, preferencesExtension } 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({
|
||||
extension: preferencesExtension,
|
||||
namespace,
|
||||
overrides: overridesPreferences,
|
||||
});
|
||||
|
||||
// 启动应用并挂载
|
||||
// vue应用主要逻辑及视图
|
||||
const { bootstrap } = await import('./bootstrap');
|
||||
await bootstrap(namespace);
|
||||
|
||||
// 移除并销毁loading
|
||||
unmountGlobalLoading();
|
||||
}
|
||||
|
||||
initApplication();
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
defineOverridesPreferences,
|
||||
definePreferencesExtension,
|
||||
} from '@vben/preferences';
|
||||
|
||||
interface WebAntdPreferencesExtension {
|
||||
defaultTableSize: number;
|
||||
enableFormFullscreen: boolean;
|
||||
reportTitle: string;
|
||||
tenantMode: 'multi' | 'single';
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 项目配置文件
|
||||
* 只需要覆盖项目中的一部分配置,不需要的配置不用覆盖,会自动使用默认配置
|
||||
* !!! 更改配置后请清空缓存,否则可能不生效
|
||||
*/
|
||||
export const overridesPreferences = defineOverridesPreferences({
|
||||
// overrides
|
||||
app: {
|
||||
name: import.meta.env.VITE_APP_TITLE,
|
||||
// 内部管理后台,不需要主题色/布局那一堆面向 C 端的偏好设置开关
|
||||
enablePreferences: false,
|
||||
// 模板默认首页是 /dashboard/analytics——那是一整页 Math.random() 生成的
|
||||
// 假图表。已经删掉了,首页改成调用统计:这套系统真正的总览就是它。
|
||||
defaultHomePath: '/model/stats',
|
||||
// 后端发的是长期令牌,没有 refresh token 机制。开着这个开关,401 之后
|
||||
// 拦截器会先去尝试续期、失败再登出,白白多一轮请求和一次报错。
|
||||
enableRefreshToken: false,
|
||||
},
|
||||
copyright: {
|
||||
companyName: '真羊',
|
||||
companySiteLink: '',
|
||||
date: '2026',
|
||||
enable: true,
|
||||
// 模板默认挂的是 vben 作者名下的真实备案号(闽ICP备19024351号)。
|
||||
// 那是别人的备案,挂在这里既不对也没意义。等这套后台真的对公网提供服务、
|
||||
// 拿到自己的备案号,再填进来;内网部署本来就不需要备案。
|
||||
icp: '',
|
||||
icpLink: '',
|
||||
},
|
||||
});
|
||||
|
||||
export const preferencesExtension =
|
||||
definePreferencesExtension<WebAntdPreferencesExtension>({
|
||||
tabLabel: 'preferences.antd.tabLabel',
|
||||
title: 'preferences.antd.title',
|
||||
fields: [
|
||||
{
|
||||
component: 'switch',
|
||||
defaultValue: true,
|
||||
key: 'enableFormFullscreen',
|
||||
label: 'preferences.antd.fields.enableFormFullscreen.label',
|
||||
tip: 'preferences.antd.fields.enableFormFullscreen.tip',
|
||||
},
|
||||
{
|
||||
component: 'select',
|
||||
defaultValue: 'single',
|
||||
key: 'tenantMode',
|
||||
label: 'preferences.antd.fields.tenantMode.label',
|
||||
options: [
|
||||
{
|
||||
label: 'preferences.antd.fields.tenantMode.options.single.label',
|
||||
value: 'single',
|
||||
},
|
||||
{
|
||||
label: 'preferences.antd.fields.tenantMode.options.multi.label',
|
||||
value: 'multi',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
component: 'number',
|
||||
componentProps: {
|
||||
max: 200,
|
||||
min: 10,
|
||||
step: 10,
|
||||
},
|
||||
defaultValue: 20,
|
||||
key: 'defaultTableSize',
|
||||
label: 'preferences.antd.fields.defaultTableSize.label',
|
||||
},
|
||||
{
|
||||
component: 'input',
|
||||
defaultValue: '',
|
||||
key: 'reportTitle',
|
||||
label: 'preferences.antd.fields.reportTitle.label',
|
||||
placeholder: 'preferences.antd.fields.reportTitle.placeholder',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
ComponentRecordType,
|
||||
GenerateMenuAndRoutesOptions,
|
||||
} from '@vben/types';
|
||||
|
||||
import { generateAccessible } from '@vben/access';
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
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 () => {
|
||||
message.loading({
|
||||
content: `${$t('common.loadingMenu')}...`,
|
||||
duration: 1.5,
|
||||
});
|
||||
return await getAllMenusApi();
|
||||
},
|
||||
// 可以指定没有权限跳转403页面
|
||||
forbiddenComponent,
|
||||
// 如果 route.meta.menuVisibleWithForbidden = true
|
||||
layoutMap,
|
||||
pageMap,
|
||||
});
|
||||
}
|
||||
|
||||
export { generateAccess };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,65 @@
|
||||
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'),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export { coreRoutes, fallbackNotFoundRoute };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
/**
|
||||
* 业务路由。
|
||||
*
|
||||
* `meta.authority` 里写的是**权限码**,不是角色名——和后端 `require("model:write")`
|
||||
* 判的是同一个东西。加一个角色只是在后台勾几个框,前端一行代码都不用改。
|
||||
*
|
||||
* 前端只负责"看不见",后端负责"进不去"。两者缺一不可:只做前端等于没做,
|
||||
* 任何人拿 curl 都能绕过去;只做后端则是用户点进去才报错,体验很差。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:bot',
|
||||
order: 0,
|
||||
title: 'AI 模型',
|
||||
},
|
||||
name: 'ModelCenter',
|
||||
path: '/model',
|
||||
children: [
|
||||
{
|
||||
name: 'ModelCatalog',
|
||||
path: 'catalog',
|
||||
component: () => import('#/views/console/model-catalog.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:layers',
|
||||
title: '模型清单',
|
||||
authority: ['model:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ModelPlan',
|
||||
path: 'plan',
|
||||
component: () => import('#/views/console/model-plan.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:git-branch',
|
||||
title: '角色编排',
|
||||
authority: ['model:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'CallStats',
|
||||
path: 'stats',
|
||||
component: () => import('#/views/console/call-stats.vue'),
|
||||
meta: {
|
||||
// 首页,标签栏里固定住不让关
|
||||
affixTab: true,
|
||||
icon: 'lucide:bar-chart-3',
|
||||
title: '调用统计',
|
||||
authority: ['stats:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:monitor-smartphone',
|
||||
order: 1,
|
||||
title: '桌面端',
|
||||
},
|
||||
name: 'DesktopCenter',
|
||||
path: '/desktop',
|
||||
children: [
|
||||
{
|
||||
name: 'DesktopConfig',
|
||||
path: 'config',
|
||||
component: () => import('#/views/console/desktop-config.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:sliders-horizontal',
|
||||
title: '客户端配置',
|
||||
// 只读用户也该能看这份配置——排查问题时"线上到底配的什么"是第一个
|
||||
// 要回答的问题。改动才需要 config:write。
|
||||
authority: ['config:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AppRelease',
|
||||
path: 'release',
|
||||
component: () => import('#/views/console/release.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:package-check',
|
||||
title: '版本升级',
|
||||
authority: ['config:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:messages-square',
|
||||
order: 2,
|
||||
title: '聊天归档',
|
||||
},
|
||||
name: 'ArchiveCenter',
|
||||
path: '/archive',
|
||||
children: [
|
||||
{
|
||||
name: 'ArchiveOverview',
|
||||
path: 'overview',
|
||||
component: () => import('#/views/archive/overview.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:gauge',
|
||||
title: '归档概览',
|
||||
authority: ['im:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveConversations',
|
||||
path: 'conversations',
|
||||
component: () => import('#/views/archive/conversations.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:message-circle-more',
|
||||
title: '会话与消息',
|
||||
authority: ['im:content:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveExports',
|
||||
path: 'exports',
|
||||
component: () => import('#/views/archive/exports.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:file-down',
|
||||
title: '导出中心',
|
||||
authority: ['im:export'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchivePeople',
|
||||
path: 'people',
|
||||
component: () => import('#/views/archive/people.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:contact-round',
|
||||
title: '人员唯一标识',
|
||||
authority: ['im:identity:write'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ArchiveStorage',
|
||||
path: 'storage',
|
||||
component: () => import('#/views/archive/storage.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:cloud-cog',
|
||||
title: 'COS 存储',
|
||||
authority: ['im:storage:write'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
icon: 'lucide:shield',
|
||||
order: 3,
|
||||
title: '系统管理',
|
||||
},
|
||||
name: 'SystemCenter',
|
||||
path: '/system',
|
||||
children: [
|
||||
{
|
||||
name: 'RoleManage',
|
||||
path: 'roles',
|
||||
component: () => import('#/views/console/roles.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:key-round',
|
||||
title: '角色权限',
|
||||
authority: ['user:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'UserManage',
|
||||
path: 'users',
|
||||
component: () => import('#/views/console/users.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:users',
|
||||
title: '用户管理',
|
||||
authority: ['user:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'AuditLog',
|
||||
path: 'audit',
|
||||
component: () => import('#/views/console/audit.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:scroll-text',
|
||||
title: '审计日志',
|
||||
authority: ['audit:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
meta: { hideInMenu: true, title: '个人设置' },
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
children: [
|
||||
{
|
||||
name: 'ChangePassword',
|
||||
path: 'password',
|
||||
component: () => import('#/views/console/change-password.vue'),
|
||||
meta: { hideInMenu: true, title: '修改密码' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,117 @@
|
||||
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 { notification } from 'ant-design-vue';
|
||||
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) {
|
||||
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) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} 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() {
|
||||
const userInfo = await getUserInfoApi();
|
||||
userStore.setUserInfo(userInfo);
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
function $reset() {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
$reset,
|
||||
authLogin,
|
||||
fetchUserInfo,
|
||||
loginLoading,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './auth';
|
||||
@@ -0,0 +1,3 @@
|
||||
# \_core
|
||||
|
||||
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { About } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'About' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<About />
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationForgetPassword
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 模板自带的演示账号下拉、滑块验证码、手机号/扫码/第三方登录、注册和找回密码
|
||||
// 都删掉了——后端没有对应的接口,留着只会让人点进死路。滑块验证码尤其要注意:
|
||||
// 它是纯前端的,挡不住脚本,只挡真人。真要防爆破得在服务端做失败计数。
|
||||
const formSchema = computed((): VbenFormSchema[] => [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '用户名' },
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
rules: z.string().min(1, { message: '请输入用户名' }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: { placeholder: '密码' },
|
||||
fieldName: 'password',
|
||||
label: '密码',
|
||||
rules: z.string().min(1, { message: '请输入密码' }),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationLogin
|
||||
:form-schema="formSchema"
|
||||
:loading="authStore.loginLoading"
|
||||
:show-code-login="false"
|
||||
:show-forget-password="false"
|
||||
:show-qrcode-login="false"
|
||||
:show-register="false"
|
||||
:show-third-party-login="false"
|
||||
sub-title="请使用管理员分配的账号登录"
|
||||
title="真羊 AI 客服 · 管理后台"
|
||||
@submit="authStore.authLogin"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,95 @@
|
||||
<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({ 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>) {
|
||||
void value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthenticationRegister
|
||||
:form-schema="formSchema"
|
||||
:loading="loading"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="coming-soon" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback403Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="403" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback500Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="500" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'Fallback404Demo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="404" />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import { Fallback } from '@vben/common-ui';
|
||||
|
||||
defineOptions({ name: 'FallbackOfflineDemo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fallback status="offline" />
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Profile } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
import ProfileSecuritySetting from './security-setting.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '基本设置',
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
v-model:model-value="tabsValue"
|
||||
title="个人中心"
|
||||
:user-info="userStore.userInfo"
|
||||
:tabs="tabs"
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileNotificationSetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '其他用户的消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'systemMessage',
|
||||
label: '系统消息',
|
||||
description: '系统消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'todoTask',
|
||||
label: '待办任务',
|
||||
description: '待办任务将以站内信的形式通知',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileNotificationSetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请再次输入新密码',
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
});
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
message.success('密码修改成功');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ProfilePasswordSetting
|
||||
class="w-1/3"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileSecuritySetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '当前密码强度:强',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityPhone',
|
||||
label: '密保手机',
|
||||
description: '已绑定手机:138****8293',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityQuestion',
|
||||
label: '密保问题',
|
||||
description: '未设置密保问题,密保问题可有效保护账户安全',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityEmail',
|
||||
label: '备用邮箱',
|
||||
description: '已绑定邮箱:ant***sign.com',
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
fieldName: 'securityMfa',
|
||||
label: 'MFA 设备',
|
||||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileSecuritySetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveConversation, ArchiveMessage } from '#/api/archive';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Empty,
|
||||
Image as AImage,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
fetchArchiveConversations,
|
||||
fetchArchiveMediaAccessUrls,
|
||||
fetchArchiveMessages,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const messageLoading = ref(false);
|
||||
const conversations = ref<ArchiveConversation[]>([]);
|
||||
const nextCursor = ref('');
|
||||
const hasMore = ref(false);
|
||||
const selected = ref<ArchiveConversation | null>(null);
|
||||
const messages = ref<ArchiveMessage[]>([]);
|
||||
const messageCursor = ref('');
|
||||
const messageHasMore = ref(false);
|
||||
const mediaUrls = ref<Record<string, string>>({});
|
||||
|
||||
function formatSize(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '未知大小';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value || '-';
|
||||
const parts = new Intl.DateTimeFormat('zh-CN', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
minute: '2-digit',
|
||||
month: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
}).formatToParts(date);
|
||||
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||||
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`;
|
||||
}
|
||||
|
||||
function isBrowserAudio(mimeType: string) {
|
||||
return !['audio/amr', 'audio/silk'].includes(String(mimeType).toLowerCase());
|
||||
}
|
||||
|
||||
function attachmentStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
source_not_cached: '源文件未缓存,请先在企业微信中下载',
|
||||
upload_failed: '上传失败,下次启动将自动重试',
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
|
||||
async function loadMediaUrls(items: ArchiveMessage[]) {
|
||||
const ids = [
|
||||
...new Set(
|
||||
items.flatMap((item) =>
|
||||
(item.attachments || [])
|
||||
.filter((attachment) => attachment.status === 'ready')
|
||||
.map((attachment) => attachment.id),
|
||||
),
|
||||
),
|
||||
].filter((id) => !mediaUrls.value[id]);
|
||||
for (let index = 0; index < ids.length; index += 200) {
|
||||
try {
|
||||
const data = await fetchArchiveMediaAccessUrls(ids.slice(index, index + 200));
|
||||
const additions = Object.fromEntries(
|
||||
data.items.map((item) => [item.id, item.url]),
|
||||
);
|
||||
mediaUrls.value = { ...mediaUrls.value, ...additions };
|
||||
} catch {
|
||||
// 消息正文仍可正常查看;素材签名失败时保留状态提示,刷新后可重试。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load(reset = true) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await fetchArchiveConversations(
|
||||
50,
|
||||
reset ? '' : nextCursor.value,
|
||||
);
|
||||
conversations.value = reset
|
||||
? data.items
|
||||
: [...conversations.value, ...data.items];
|
||||
nextCursor.value = data.next_cursor;
|
||||
hasMore.value = data.has_more;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(record: Record<string, any>) {
|
||||
selected.value = record as ArchiveConversation;
|
||||
messages.value = [];
|
||||
messageCursor.value = '';
|
||||
mediaUrls.value = {};
|
||||
await loadMessages(true);
|
||||
}
|
||||
|
||||
async function loadMessages(reset = false) {
|
||||
if (!selected.value) return;
|
||||
messageLoading.value = true;
|
||||
try {
|
||||
const data = await fetchArchiveMessages(
|
||||
selected.value.id,
|
||||
100,
|
||||
reset ? '' : messageCursor.value,
|
||||
);
|
||||
messages.value = reset ? data.items : [...messages.value, ...data.items];
|
||||
messageCursor.value = data.next_cursor;
|
||||
messageHasMore.value = data.has_more;
|
||||
await loadMediaUrls(data.items);
|
||||
} finally {
|
||||
messageLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
application: '应用会话',
|
||||
direct_wechat: '微信单聊',
|
||||
direct_wecom: '企微单聊',
|
||||
group: '群聊',
|
||||
service: '客服会话',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
onMounted(() => load(true));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="会话与消息">
|
||||
<template #extra><Button :loading="loading" @click="load(true)">刷新</Button></template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
使用时间 + 唯一 ID 游标翻页,数据增长到百万级时不会因深分页越来越慢。
|
||||
</p>
|
||||
<Table
|
||||
:data-source="conversations"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
:columns="[
|
||||
{ title: '会话', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'conversation_type', key: 'conversation_type', width: 120 },
|
||||
{ title: '归档账号', dataIndex: 'source_account', key: 'source_account', width: 150 },
|
||||
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: '最后消息时间', dataIndex: 'last_message_at', key: 'last_message_at', width: 210 },
|
||||
{ title: '操作', key: 'action', width: 90 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="font-medium">{{ record.name }}</div>
|
||||
<div class="max-w-[420px] truncate text-xs text-gray-400">{{ record.last_content }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'conversation_type'">
|
||||
<Tag>{{ TYPE_LABEL[record.conversation_type] || record.conversation_type }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'last_message_at'">
|
||||
{{ formatDateTime(record.last_message_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="openConversation(record)">查看</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<div v-if="hasMore" class="mt-4 text-center">
|
||||
<Button :loading="loading" @click="load(false)">加载更多会话</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="!!selected"
|
||||
:title="selected?.name"
|
||||
width="860"
|
||||
@close="selected = null"
|
||||
>
|
||||
<Empty v-if="!messageLoading && !messages.length" description="暂无消息" />
|
||||
<div v-for="item in messages" :key="item.id" class="mb-3 border-b border-gray-100 pb-3">
|
||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-400">
|
||||
<span>
|
||||
<strong class="mr-2 text-gray-600">{{ item.sender_name || '未知发送者' }}</strong>
|
||||
{{ item.message_type }}
|
||||
<Tag v-if="item.attachment_count" class="ml-2">{{ item.attachment_count }} 个素材</Tag>
|
||||
</span>
|
||||
<span>{{ formatDateTime(item.sent_at) }}</span>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap break-words text-sm">{{ item.content || '(非文本消息)' }}</div>
|
||||
<div v-if="item.attachments?.length" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="attachment in item.attachments"
|
||||
:key="attachment.id"
|
||||
class="rounded-md border border-gray-200 bg-gray-50 p-3"
|
||||
>
|
||||
<AImage
|
||||
v-if="attachment.media_type === 'image' && mediaUrls[attachment.id]"
|
||||
:src="mediaUrls[attachment.id]"
|
||||
:alt="attachment.original_filename"
|
||||
:preview="true"
|
||||
:width="260"
|
||||
/>
|
||||
<video
|
||||
v-else-if="attachment.media_type === 'video' && mediaUrls[attachment.id]"
|
||||
class="max-h-[420px] max-w-full rounded bg-black"
|
||||
controls
|
||||
preload="metadata"
|
||||
:src="mediaUrls[attachment.id]"
|
||||
></video>
|
||||
<audio
|
||||
v-else-if="attachment.media_type === 'audio' && isBrowserAudio(attachment.mime_type) && mediaUrls[attachment.id]"
|
||||
class="w-full"
|
||||
controls
|
||||
preload="metadata"
|
||||
:src="mediaUrls[attachment.id]"
|
||||
></audio>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
||||
<Tag>{{ attachment.media_type }}</Tag>
|
||||
<span class="max-w-[460px] truncate" :title="attachment.original_filename">
|
||||
{{ attachment.original_filename || '未命名素材' }}
|
||||
</span>
|
||||
<span>{{ formatSize(attachment.size_bytes) }}</span>
|
||||
<Tag v-if="attachment.status !== 'ready'" color="warning">
|
||||
{{ attachmentStatusLabel(attachment.status) }}
|
||||
</Tag>
|
||||
<a
|
||||
v-if="mediaUrls[attachment.id]"
|
||||
:href="mediaUrls[attachment.id]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ attachment.media_type === 'file' || !isBrowserAudio(attachment.mime_type) ? '下载附件' : '查看原文件' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="messageHasMore" class="mt-3 text-center">
|
||||
<Button :loading="messageLoading" @click="loadMessages(false)">加载更早消息</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveExportJob } from '#/api/archive';
|
||||
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
|
||||
import { downloadFileFromBlob } from '@vben/utils';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
Progress,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
createArchiveExport,
|
||||
downloadArchiveExport,
|
||||
fetchArchiveExports,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const downloading = ref('');
|
||||
const jobs = ref<ArchiveExportJob[]>([]);
|
||||
const form = reactive({ date_from: '', date_to: '', formats: ['sql', 'xlsx'] });
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function setDate(field: 'date_from' | 'date_to', value: unknown) {
|
||||
form[field] = typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
jobs.value = (await fetchArchiveExports()).jobs;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
if (!form.formats.length) {
|
||||
message.warning('至少选择一种导出格式');
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
try {
|
||||
await createArchiveExport({
|
||||
formats: form.formats,
|
||||
filters: {
|
||||
date_from: form.date_from || undefined,
|
||||
date_to: form.date_to || undefined,
|
||||
},
|
||||
});
|
||||
message.success('导出任务已创建');
|
||||
await load();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function download(file: { file_name: string; id: string }) {
|
||||
downloading.value = file.id;
|
||||
try {
|
||||
const blob = await downloadArchiveExport(file.id);
|
||||
downloadFileFromBlob({ fileName: file.file_name, source: blob });
|
||||
} finally {
|
||||
downloading.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
completed: 'green', failed: 'red', queued: 'default', running: 'blue',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
completed: '已完成', failed: '失败', queued: '等待中', running: '处理中',
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
timer = setInterval(() => {
|
||||
if (jobs.value.some((item) => ['queued', 'running'].includes(item.status))) load();
|
||||
}, 3000);
|
||||
});
|
||||
onUnmounted(() => timer && clearInterval(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="新建导出" class="mb-4">
|
||||
<Form layout="inline">
|
||||
<Form.Item label="格式">
|
||||
<Checkbox.Group v-model:value="form.formats" :options="[
|
||||
{ label: 'SQL', value: 'sql' },
|
||||
{ label: 'Excel', value: 'xlsx' },
|
||||
{ label: 'CSV', value: 'csv' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="开始日期">
|
||||
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_from', $event)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="结束日期">
|
||||
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_to', $event)" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" :loading="creating" @click="createJob">开始导出</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p class="mb-0 mt-3 text-xs text-gray-500">
|
||||
导出按创建时的截止水位生成;SQL/CSV 流式写出,Excel 超过 90 万行自动拆分工作表。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="导出任务">
|
||||
<template #extra><Button :loading="loading" @click="load">刷新</Button></template>
|
||||
<Table
|
||||
:data-source="jobs" row-key="id" size="small" :loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 205 },
|
||||
{ title: '格式', dataIndex: 'formats', key: 'formats', width: 160 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 110 },
|
||||
{ title: '进度/行数', key: 'progress', width: 180 },
|
||||
{ title: '文件', key: 'files' },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'formats'">
|
||||
<Tag v-for="format in record.formats" :key="format">{{ format.toUpperCase() }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="STATUS_COLOR[record.status]">{{ STATUS_LABEL[record.status] }}</Tag>
|
||||
<div v-if="record.error_message" class="mt-1 text-xs text-red-500">{{ record.error_message }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<Progress v-if="record.status === 'running'" :percent="record.progress" size="small" />
|
||||
<span v-else>{{ record.total_rows || 0 }} 行</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'files'">
|
||||
<Space wrap>
|
||||
<Button
|
||||
v-for="file in record.files" :key="file.id" size="small"
|
||||
:loading="downloading === file.id" @click="download(file)"
|
||||
>{{ file.file_name }}</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveStats } from '#/api/archive';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Alert, Button, Card, Col, Row, Statistic } from 'ant-design-vue';
|
||||
|
||||
import { fetchArchiveStats } from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const stats = ref<ArchiveStats | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await fetchArchiveStats();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold">聊天归档</h2>
|
||||
<p class="mb-0 mt-1 text-sm text-gray-500">
|
||||
企业微信消息、人员、会话与 COS 素材的统一数据视图
|
||||
</p>
|
||||
</div>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="素材本体不进入数据库"
|
||||
description="图片、语音、视频和文件保存在腾讯云 COS;数据库只保存对象地址、版本、SHA-256、CRC64 与校验状态。"
|
||||
/>
|
||||
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="归档消息" :value="stats?.messages ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="会话" :value="stats?.conversations ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="唯一人员" :value="stats?.people ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="COS 素材" :value="stats?.media ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Card title="素材完整性" size="small" :loading="loading">
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">已校验</span>
|
||||
<strong class="text-green-600">{{ stats?.media_ready ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">校验失败</span>
|
||||
<strong :class="(stats?.media_failed ?? 0) ? 'text-red-600' : ''">
|
||||
{{ stats?.media_failed ?? 0 }}
|
||||
</strong>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card title="处理任务" size="small" :loading="loading">
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">导入批次</span><strong>{{ stats?.imports ?? 0 }}</strong>
|
||||
</div>
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">导出任务</span><strong>{{ stats?.exports ?? 0 }}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card title="最新水位" size="small" :loading="loading">
|
||||
<p class="mb-1 text-gray-500">最后一条消息时间(UTC)</p>
|
||||
<strong>{{ stats?.last_message_at || '尚未导入' }}</strong>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchivePerson, ArchivePersonDetail } from '#/api/archive';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
bindArchiveIdentity,
|
||||
fetchArchivePeople,
|
||||
fetchArchivePerson,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const binding = ref(false);
|
||||
const keyword = ref('');
|
||||
const people = ref<ArchivePerson[]>([]);
|
||||
const selected = ref<ArchivePersonDetail | null>(null);
|
||||
const identity = reactive({
|
||||
external_id: '',
|
||||
identity_type: 'wecom_userid',
|
||||
scope_id: '',
|
||||
verified: true,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
people.value = (await fetchArchivePeople(200, keyword.value)).items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openPerson(record: Record<string, any>) {
|
||||
selected.value = (await fetchArchivePerson(record.id)).person;
|
||||
identity.external_id = '';
|
||||
}
|
||||
|
||||
async function bind() {
|
||||
if (!selected.value || !identity.external_id.trim()) {
|
||||
message.warning('请输入企业微信人员 ID');
|
||||
return;
|
||||
}
|
||||
binding.value = true;
|
||||
try {
|
||||
selected.value = (
|
||||
await bindArchiveIdentity(selected.value.id, identity)
|
||||
).person;
|
||||
identity.external_id = '';
|
||||
message.success('人员标识已绑定');
|
||||
await load();
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="人员唯一标识">
|
||||
<template #extra>
|
||||
<Space>
|
||||
<Input.Search
|
||||
v-model:value="keyword" allow-clear placeholder="姓名"
|
||||
:loading="loading" @search="load"
|
||||
/>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
同一个人可绑定本地 UID、企业微信 userid、微信 external_userid 等多个身份;唯一键由“身份类型 + 企业范围 + 外部 ID”组成。
|
||||
</p>
|
||||
<Table
|
||||
:data-source="people" row-key="id" size="small" :loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '显示名称', dataIndex: 'display_name', key: 'display_name' },
|
||||
{ title: '真实姓名', dataIndex: 'real_name', key: 'real_name' },
|
||||
{ title: '已绑定身份', dataIndex: 'identity_count', key: 'identity_count', width: 110 },
|
||||
{ title: '会话数', dataIndex: 'conversation_count', key: 'conversation_count', width: 90 },
|
||||
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="openPerson(record)">管理标识</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="!!selected" :title="`人员标识 · ${selected?.display_name || ''}`"
|
||||
width="680" @close="selected = null"
|
||||
>
|
||||
<Table
|
||||
class="mb-5" :data-source="selected?.identities || []" row-key="id"
|
||||
size="small" :pagination="false"
|
||||
:columns="[
|
||||
{ title: '类型', dataIndex: 'identity_type', key: 'identity_type', width: 140 },
|
||||
{ title: '企业/范围', dataIndex: 'scope_id', key: 'scope_id', width: 140 },
|
||||
{ title: '外部 ID', dataIndex: 'external_id', key: 'external_id' },
|
||||
{ title: '状态', dataIndex: 'verified', key: 'verified', width: 80 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'verified'">
|
||||
<Tag :color="record.verified ? 'green' : 'default'">
|
||||
{{ record.verified ? '已确认' : '未确认' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Card title="绑定新标识" size="small">
|
||||
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 17 }">
|
||||
<Form.Item label="身份类型">
|
||||
<Select v-model:value="identity.identity_type" :options="[
|
||||
{ label: '企业微信 userid', value: 'wecom_userid' },
|
||||
{ label: '微信 external_userid', value: 'wecom_external_userid' },
|
||||
{ label: '本地数据库 UID', value: 'wecom_local_uid' },
|
||||
{ label: '企微 open_userid', value: 'wecom_open_userid' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="企业/范围 ID">
|
||||
<Input v-model:value="identity.scope_id" placeholder="建议填写 corp_id;本地 UID 可填账号 ID" />
|
||||
</Form.Item>
|
||||
<Form.Item label="外部人员 ID" required>
|
||||
<Input v-model:value="identity.external_id" />
|
||||
</Form.Item>
|
||||
<Form.Item label="确认身份">
|
||||
<Switch v-model:checked="identity.verified" />
|
||||
</Form.Item>
|
||||
<Form.Item :wrapper-col="{ offset: 6, span: 17 }">
|
||||
<Button type="primary" :loading="binding" @click="bind">确认绑定</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveMedia } from '#/api/archive';
|
||||
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
fetchArchiveMedia,
|
||||
fetchArchiveStorage,
|
||||
saveArchiveStorage,
|
||||
testArchiveStorage,
|
||||
} from '#/api/archive';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const testing = ref(false);
|
||||
const mediaItems = ref<ArchiveMedia[]>([]);
|
||||
const form = reactive({
|
||||
bucket: '',
|
||||
custom_domain: '',
|
||||
enabled: false,
|
||||
encryption_mode: 'AES256',
|
||||
export_prefix: 'archive/exports',
|
||||
media_prefix: 'archive/media',
|
||||
region: '',
|
||||
secret_id: '',
|
||||
secret_id_masked: '',
|
||||
secret_key: '',
|
||||
secret_key_masked: '',
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [storage, media] = await Promise.all([
|
||||
fetchArchiveStorage(),
|
||||
fetchArchiveMedia(100),
|
||||
]);
|
||||
Object.assign(form, storage.storage, { secret_id: '', secret_key: '' });
|
||||
mediaItems.value = media.items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await saveArchiveStorage({
|
||||
bucket: form.bucket,
|
||||
custom_domain: form.custom_domain,
|
||||
enabled: form.enabled,
|
||||
encryption_mode: form.encryption_mode,
|
||||
export_prefix: form.export_prefix,
|
||||
media_prefix: form.media_prefix,
|
||||
region: form.region,
|
||||
secret_id: form.secret_id,
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
Object.assign(form, data.storage, { secret_id: '', secret_key: '' });
|
||||
message.success('COS 配置已保存');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = (await testArchiveStorage()).result;
|
||||
message.success(`连接成功:${result.bucket} / ${result.region}`);
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sizeText(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4" type="warning" show-icon
|
||||
message="请使用最小权限的独立 COS 子账号密钥"
|
||||
description="SecretId / SecretKey 只写入后台并加密保存,页面不会回显明文。留空表示保持原密钥;更换截图中曾暴露过的密钥后再启用。"
|
||||
/>
|
||||
|
||||
<Card title="腾讯云 COS" class="mb-4" :loading="loading">
|
||||
<Form :label-col="{ span: 5 }" :wrapper-col="{ span: 15 }">
|
||||
<Form.Item label="启用存储"><Switch v-model:checked="form.enabled" /></Form.Item>
|
||||
<Form.Item label="Bucket">
|
||||
<Input v-model:value="form.bucket" placeholder="bucket-appid" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Region">
|
||||
<Input v-model:value="form.region" placeholder="ap-guangzhou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretId">
|
||||
<Input.Password v-model:value="form.secret_id" :placeholder="form.secret_id_masked || '请输入 SecretId'" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretKey">
|
||||
<Input.Password v-model:value="form.secret_key" :placeholder="form.secret_key_masked || '请输入 SecretKey'" />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材路径前缀">
|
||||
<Input v-model:value="form.media_prefix" />
|
||||
</Form.Item>
|
||||
<Form.Item label="导出路径前缀">
|
||||
<Input v-model:value="form.export_prefix" />
|
||||
</Form.Item>
|
||||
<Form.Item label="服务端加密">
|
||||
<Select v-model:value="form.encryption_mode" :options="[
|
||||
{ label: 'SSE-COS(AES256)', value: 'AES256' },
|
||||
{ label: 'SSE-KMS', value: 'cos/kms' },
|
||||
{ label: '不指定', value: '' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义域名">
|
||||
<Input v-model:value="form.custom_domain" placeholder="可选,仅允许完整 HTTPS 域名" />
|
||||
</Form.Item>
|
||||
<Form.Item :wrapper-col="{ offset: 5, span: 15 }">
|
||||
<Space>
|
||||
<Button type="primary" :loading="saving" @click="save">保存配置</Button>
|
||||
<Button :loading="testing" :disabled="!form.enabled" @click="testConnection">测试连接</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="最近素材对象">
|
||||
<Table
|
||||
:data-source="mediaItems" row-key="id" size="small" :loading="loading"
|
||||
:pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '文件', dataIndex: 'original_filename', key: 'original_filename' },
|
||||
{ title: '类型', dataIndex: 'media_type', key: 'media_type', width: 90 },
|
||||
{ title: '大小', dataIndex: 'size_bytes', key: 'size_bytes', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: 'COS ObjectKey', dataIndex: 'object_key', key: 'object_key' },
|
||||
{ title: '校验时间', dataIndex: 'verified_at', key: 'verified_at', width: 200 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'size_bytes'">{{ sizeText(record.size_bytes) }}</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="record.status === 'ready' ? 'green' : record.status === 'failed' ? 'red' : 'orange'">
|
||||
{{ record.status }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'object_key'">
|
||||
<span class="break-all font-mono text-xs">{{ record.object_key }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Card, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { fetchAudit } from '#/api/console';
|
||||
|
||||
const loading = ref(false);
|
||||
const entries = ref<any[]>([]);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
entries.value = (await fetchAudit(200)).entries;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
'login.failed': 'red',
|
||||
'role.delete': 'red',
|
||||
'model.provider.delete': 'red',
|
||||
'role.save': 'orange',
|
||||
'model.roles.save': 'orange',
|
||||
'model.provider.save': 'blue',
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="审计日志">
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
谁在什么时候改了什么——出事之后唯一能回溯的东西。只显示最近 200 条。
|
||||
</p>
|
||||
<Table
|
||||
:data-source="entries" :loading="loading" row-key="id"
|
||||
size="small" :pagination="{ pageSize: 20 }"
|
||||
:columns="[
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '操作人', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', key: 'action', width: 200 },
|
||||
{ title: '详情', dataIndex: 'detail', key: 'detail' },
|
||||
{ title: '来源 IP', dataIndex: 'ip_address', key: 'ip_address', width: 140 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Tag :color="TONE[record.action] || 'default'">{{ record.action }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.username || '(未登录)' }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import type { CallStats, ModelCallLogItem } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Input,
|
||||
Modal,
|
||||
Progress,
|
||||
Row,
|
||||
Segmented,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { fetchCallLog, fetchCallStats } from '#/api/console';
|
||||
|
||||
const days = ref(7);
|
||||
const loading = ref(false);
|
||||
const stats = ref<CallStats | null>(null);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
stats.value = await fetchCallStats(days.value);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 调用记录:定位某一句具体是怎么被回复的 ──────────────────────────────
|
||||
const logKeyword = ref('');
|
||||
const logLoading = ref(false);
|
||||
const logItems = ref<ModelCallLogItem[]>([]);
|
||||
const logTotal = ref(0);
|
||||
const logPage = ref(1);
|
||||
const logPageSize = ref(20);
|
||||
const detailItem = ref<ModelCallLogItem | null>(null);
|
||||
/**
|
||||
* chat = 回客户的话;guard = 界面识别之类的内部判断;'' = 全都要。
|
||||
*
|
||||
* 默认只看 chat:界面守卫每轮轮询都要问一次模型,条数是真实对话的几十倍,
|
||||
* 混在一起这张表根本没法用。但内部调用同样在花钱,所以留了入口能翻出来看。
|
||||
*/
|
||||
const logPurpose = ref('chat');
|
||||
|
||||
async function loadLog() {
|
||||
logLoading.value = true;
|
||||
try {
|
||||
const resp = await fetchCallLog({
|
||||
days: days.value,
|
||||
limit: logPageSize.value,
|
||||
offset: (logPage.value - 1) * logPageSize.value,
|
||||
q: logKeyword.value.trim(),
|
||||
purpose: logPurpose.value,
|
||||
});
|
||||
logItems.value = resp.items;
|
||||
logTotal.value = resp.total;
|
||||
} finally {
|
||||
logLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function searchLog() {
|
||||
logPage.value = 1;
|
||||
loadLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间统一显示成「MM-DD HH:mm:ss」。
|
||||
*
|
||||
* 库里存过两种格式:网关早期写的是 `2026-08-24 14:40:44`,后台写的是带时区的
|
||||
* ISO。同一次调用在表格里长得不一样,看着像两件事。新数据已经统一,这里负责
|
||||
* 让历史数据也能正常显示,解析不了就原样输出,不能显示成 Invalid Date。
|
||||
*/
|
||||
function formatTime(raw: string): string {
|
||||
const value = String(raw || '').trim();
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
|
||||
}
|
||||
|
||||
watch(logPurpose, () => {
|
||||
logPage.value = 1;
|
||||
loadLog();
|
||||
});
|
||||
|
||||
function logTableChange(pagination: { current?: number; pageSize?: number }) {
|
||||
logPage.value = pagination.current ?? 1;
|
||||
logPageSize.value = pagination.pageSize ?? 20;
|
||||
loadLog();
|
||||
}
|
||||
|
||||
watch(days, () => {
|
||||
load();
|
||||
logPage.value = 1;
|
||||
loadLog();
|
||||
});
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadLog();
|
||||
});
|
||||
|
||||
/** 各出口被选中的次数占比——回答"第二个模型值不值那一倍成本"。 */
|
||||
const chosenRows = computed(() => {
|
||||
const total = stats.value?.chosen.reduce((sum, row) => sum + row.count, 0) ?? 0;
|
||||
return (stats.value?.chosen ?? []).map((row) => ({
|
||||
...row,
|
||||
percent: total ? Math.round((row.count / total) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const riskRows = computed(() => {
|
||||
const risk = stats.value?.risk ?? {};
|
||||
const total = Object.values(risk).reduce((sum, n) => sum + n, 0);
|
||||
return Object.entries(risk).map(([level, count]) => ({
|
||||
level,
|
||||
count,
|
||||
percent: total ? Math.round((count / total) * 100) : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const maxBucket = computed(() =>
|
||||
Math.max(1, ...(stats.value?.score_buckets ?? []).map((b) => b.count)),
|
||||
);
|
||||
|
||||
const RISK_COLOR: Record<string, string> = {
|
||||
low: 'green', medium: 'orange', high: 'red', unknown: 'default',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="这张表是决定要不要上双模型的唯一依据"
|
||||
description="分数分布告诉你现有回复的真实水平——如果大部分本来就是高分,并发问两个模型就是纯浪费;如果低分集中在某一类消息上,针对性换个模型比全量双跑划算得多。"
|
||||
/>
|
||||
|
||||
<div class="mb-4">
|
||||
<Segmented
|
||||
v-model:value="days"
|
||||
:options="[{ value: 1, label: '今天' }, { value: 7, label: '近 7 天' }, { value: 30, label: '近 30 天' }]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<Card size="small"><Statistic title="总调用" :value="stats?.total ?? 0" /></Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small"><Statistic title="已评审" :value="stats?.judged ?? 0" /></Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small">
|
||||
<Statistic title="平均分" :value="stats?.avg_score ?? 0" :precision="3" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small">
|
||||
<Statistic title="平均耗时" :value="stats?.avg_ms ?? 0" suffix="ms" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Card title="裁判分数分布" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!stats?.score_buckets?.length" description="还没有评审数据" />
|
||||
<div v-for="bucket in stats?.score_buckets ?? []" :key="bucket.range" class="mb-2">
|
||||
<div class="mb-1 flex justify-between text-xs">
|
||||
<span>{{ bucket.range }}</span>
|
||||
<span>{{ bucket.count }} 条</span>
|
||||
</div>
|
||||
<Progress
|
||||
:percent="Math.round((bucket.count / maxBucket) * 100)"
|
||||
:show-info="false" size="small"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col :span="12">
|
||||
<Card title="风险等级" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!riskRows.length" description="还没有评审数据" />
|
||||
<div v-for="row in riskRows" :key="row.level" class="mb-2 flex items-center gap-3">
|
||||
<Tag :color="RISK_COLOR[row.level]" class="w-16 text-center">{{ row.level }}</Tag>
|
||||
<Progress :percent="row.percent" size="small" class="flex-1" />
|
||||
<span class="w-16 text-right text-xs">{{ row.count }} 条</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="各模型被选中的比例" size="small" :loading="loading">
|
||||
<Table
|
||||
:data-source="chosenRows" row-key="provider" size="small" :pagination="false"
|
||||
:columns="[
|
||||
{ title: '模型', dataIndex: 'provider', key: 'provider' },
|
||||
{ title: '被选中', dataIndex: 'count', key: 'count', width: 100 },
|
||||
{ title: '占比', key: 'percent', width: 240 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'percent'">
|
||||
<Progress :percent="record.percent" size="small" />
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
统计区间自 {{ stats?.since || '—' }} 起。最慢一次 {{ stats?.max_ms ?? 0 }}ms。
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="调用记录" size="small" class="mt-4">
|
||||
<template #extra>
|
||||
<Input.Search
|
||||
v-model:value="logKeyword"
|
||||
placeholder="搜客户消息或模型回复里的关键词"
|
||||
style="width: 280px"
|
||||
allow-clear
|
||||
@search="searchLog"
|
||||
/>
|
||||
</template>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
按时间倒序,一条一次调用:客户说了什么、模型候选都答了什么、最终选中的是哪个、有没有被审核规则拦下——出问题时按这张表往回查。
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<Segmented
|
||||
v-model:value="logPurpose"
|
||||
:options="[
|
||||
{ value: 'chat', label: '客服对话' },
|
||||
{ value: 'guard', label: '界面识别' },
|
||||
{ value: '', label: '全部' },
|
||||
]"
|
||||
/>
|
||||
<span class="ml-3 text-xs text-gray-500">
|
||||
「界面识别」是机器人自己看企业微信窗口用的,不是发给客户的话;它每轮轮询都要问一次模型,条数远多于真实对话,所以默认不混在一起。
|
||||
</span>
|
||||
</div>
|
||||
<Table
|
||||
:data-source="logItems"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:loading="logLoading"
|
||||
:pagination="{
|
||||
current: logPage,
|
||||
pageSize: logPageSize,
|
||||
total: logTotal,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
}"
|
||||
@change="logTableChange"
|
||||
:columns="[
|
||||
{ title: '时间', key: 'created_at', width: 130 },
|
||||
{ title: '客户消息', key: 'customer_text', width: 200, ellipsis: true },
|
||||
{ title: '模型回复', key: 'reply_text', width: 200, ellipsis: true },
|
||||
{ title: '选中', dataIndex: 'chosen', key: 'chosen', width: 110, ellipsis: true },
|
||||
{ title: '裁判分', dataIndex: 'judge_score', key: 'judge_score', width: 80 },
|
||||
{ title: '风险', dataIndex: 'judge_risk', key: 'judge_risk', width: 80 },
|
||||
{ title: '审核', key: 'review_reason', width: 90 },
|
||||
{ title: '耗时', dataIndex: 'total_ms', key: 'total_ms', width: 80 },
|
||||
{ title: '', key: 'action', width: 70 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">
|
||||
<span :title="record.created_at">{{ formatTime(record.created_at) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'customer_text'">
|
||||
<span :title="record.customer_text">{{ record.customer_text || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reply_text'">
|
||||
<span :title="record.reply_text">{{ record.reply_text || '—' }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'judge_risk'">
|
||||
<Tag v-if="record.judge_risk" :color="RISK_COLOR[record.judge_risk]">{{ record.judge_risk }}</Tag>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'review_reason'">
|
||||
<Tag v-if="record.review_reason" color="orange">已拦</Tag>
|
||||
<span v-else class="text-gray-400">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total_ms'">
|
||||
{{ record.total_ms }}ms
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a @click="detailItem = record as ModelCallLogItem">详情</a>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
:open="!!detailItem"
|
||||
title="调用详情"
|
||||
:footer="null"
|
||||
width="640px"
|
||||
@cancel="detailItem = null"
|
||||
>
|
||||
<template v-if="detailItem">
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">客户消息</div>
|
||||
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.customer_text || '(空)' }}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">模型回复(最终选中)</div>
|
||||
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.reply_text || '(空)' }}</div>
|
||||
</div>
|
||||
<div v-if="detailItem.review_reason" class="mb-3">
|
||||
<Alert type="warning" show-icon :message="`已停发送审核:${detailItem.review_reason}`" />
|
||||
</div>
|
||||
<div v-if="detailItem.candidates?.length" class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">全部候选</div>
|
||||
<div
|
||||
v-for="(candidate, index) in detailItem.candidates"
|
||||
:key="index"
|
||||
class="mb-2 rounded border border-gray-200 p-2 text-sm dark:border-gray-700"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<Tag :color="candidate.provider === detailItem.chosen ? 'green' : 'default'">
|
||||
{{ candidate.provider }}{{ candidate.provider === detailItem.chosen ? '(选中)' : '' }}
|
||||
</Tag>
|
||||
<span class="text-xs text-gray-400">{{ candidate.latency_ms ?? 0 }}ms</span>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap">{{ candidate.text || candidate.error || '(空)' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
裁判 {{ detailItem.judge_winner || '—' }}/{{ detailItem.judge_score }}分,共 {{ detailItem.total_ms }}ms,{{ formatTime(detailItem.created_at) }}
|
||||
</p>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Alert, Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { changeMyPassword } from '#/api/console';
|
||||
|
||||
const router = useRouter();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({ current_password: '', new_password: '', confirm: '' });
|
||||
|
||||
async function submit() {
|
||||
if (form.new_password !== form.confirm) {
|
||||
message.warning('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await changeMyPassword({
|
||||
current_password: form.current_password,
|
||||
new_password: form.new_password,
|
||||
});
|
||||
message.success('密码已修改');
|
||||
await router.push('/');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-xl p-5">
|
||||
<Alert
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="首次登录必须修改初始密码"
|
||||
description="初始密码是公开的默认值,不改等于没有密码。改完才能进入其他页面。"
|
||||
/>
|
||||
<Card title="修改密码">
|
||||
<Form :model="form" layout="vertical">
|
||||
<FormItem label="当前密码">
|
||||
<Input.Password v-model:value="form.current_password" />
|
||||
</FormItem>
|
||||
<FormItem label="新密码(至少 10 位,需同时含字母和数字)">
|
||||
<Input.Password v-model:value="form.new_password" />
|
||||
</FormItem>
|
||||
<FormItem label="确认新密码">
|
||||
<Input.Password v-model:value="form.confirm" />
|
||||
</FormItem>
|
||||
<Button type="primary" :loading="submitting" @click="submit">提交</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user