Compare commits

...

48 Commits

Author SHA1 Message Date
chenzhen e67731a7cd feat: ai
报表
2026-10-22 11:03:01 +08:00
chenzhen 9e0cb3f27a feat: 运营平台 ai 口子调整 2026-10-21 17:27:10 +08:00
chenzhen ae960df897 feat: oyouhua build 2026-09-20 15:24:36 +08:00
chenzhen 955914995c Merge pull request 'fix: 优化Banner配置页面体验' (#19) from release/eventPlatform_1.05 into master
Reviewed-on: #19
2026-09-20 13:37:15 +08:00
ZhuRui 1e9b013065 fix: 优化Banner配置页面体验 2026-09-20 12:03:40 +08:00
chenzhen e21c1458ae Merge pull request 'Release/event platform 1.05' (#18) from release/eventPlatform_1.05 into master
Reviewed-on: #18
2026-09-03 18:14:49 +08:00
chenzhen 5f6325689f Merge pull request 'Feat/0828/zr' (#17) from feat/0828/ZR into release/eventPlatform_1.05
Reviewed-on: #17
2026-08-28 15:52:28 +08:00
ZhuRui 48ab5d013b feat: 优化报名人数筛选功能 2026-08-28 15:39:23 +08:00
ZhuRui ce1a5e3f1f feat: 操作日志添加登录枚举 2026-08-28 14:44:14 +08:00
ZhuRui 8c023e63ec feat: 1.05需求处理 2026-08-28 14:40:40 +08:00
chenzhen a581f64ccb feat: 优化打包 教研 2026-08-26 15:32:14 +08:00
chenzhen 8c317d5b84 Merge pull request 'Release/0826' (#16) from release/0826 into master
Reviewed-on: #16
2026-08-26 15:17:06 +08:00
chenzhen 91d4410c2a Merge pull request 'feat: 处理部分样式与体验问题' (#15) from hotfix/0826/ZR into release/0826
Reviewed-on: #15
2026-08-26 15:10:42 +08:00
ZhuRui 045e491ade feat: 处理部分样式与体验问题 2026-08-26 15:05:32 +08:00
chenzhen 64fdaba93d feat: 添加提现申请弹窗添加复制图片 2026-08-18 10:54:04 +08:00
chenzhen 50d5f54015 Merge pull request 'feat:amount optimization and change style optimization' (#14) from release/amountOptimization into master
Reviewed-on: #14
2026-08-10 21:23:14 +08:00
chenzhen a77592dec6 feat: change style optimization 2026-08-10 21:16:03 +08:00
chenzhen 00867ff960 feat: amount optimization 2026-08-10 20:42:51 +08:00
chenzhen 4c08bd1f8c Merge pull request '赛事运营平台 相关功能' (#13) from release/0.01 into master
Reviewed-on: #13
2026-08-10 16:02:03 +08:00
chenzhen ab6f5c787e Merge pull request 'Feature/0723/zr' (#12) from feature/0723/ZR into release/0.01
Reviewed-on: #12
2026-08-10 11:23:19 +08:00
ZhuRui 79a7ea8c56 fix: 文案修改 2026-08-10 09:44:45 +08:00
ZhuRui b5ba0ca3fb Merge remote-tracking branch 'origin/release/0.01' into feature/0723/ZR 2026-08-10 08:38:37 +08:00
ZhuRui 9b5e2807b5 fix: 处理部分前端问题 2026-08-08 18:40:52 +08:00
chenzhen 3a30e81183 Merge pull request 'fix: 声明改变' (#11) from feature/0723/ZR into release/0.01
Reviewed-on: #11
2026-08-08 15:08:52 +08:00
ZhuRui 28386f5429 fix: 声明改变 2026-08-08 15:04:26 +08:00
chenzhen b0b8689acb Merge pull request 'Feature/0723/zr' (#10) from feature/0723/ZR into release/0.01
Reviewed-on: #10
2026-08-08 14:51:53 +08:00
ZhuRui 8012cb82fc Merge remote-tracking branch 'origin/release/0.01' into feature/0723/ZR 2026-08-08 14:43:33 +08:00
ZhuRui 73b2da2d1e fix: 处理打包问题 2026-08-08 14:42:39 +08:00
chenzhen 6f8e5036f7 Merge pull request 'Feature/0723/zr' (#9) from feature/0723/ZR into release/0.01
Reviewed-on: #9
2026-08-08 14:23:41 +08:00
ZhuRui daab8ec41a fix: 更新 2026-08-08 14:22:32 +08:00
ZhuRui b513d701d8 fix: 更新 2026-08-08 14:22:17 +08:00
ZhuRui f8eaa554b3 fix: 合并更新 2026-08-08 14:11:16 +08:00
ZhuRui 9cee70bc62 fix: 处理部分优化问题 2026-08-08 10:38:38 +08:00
ZhuRui ffd917815d feat: 样式优化 2026-08-07 20:36:55 +08:00
ZhuRui 3841fcc7e5 fix: 字段补充 样式修改 2026-08-07 20:22:31 +08:00
ZhuRui ac7bfeacfe fix: 样式打包失效优化 2026-08-07 20:12:33 +08:00
ZhuRui d9a20c906b fix: 样式优化 2026-08-07 20:06:04 +08:00
ZhuRui 5300a1ec2e fix: 小改动 2026-08-07 19:43:40 +08:00
ZhuRui 68820dfcea fix: 样式修改 去除关闭所有标签页功能 2026-08-07 19:40:22 +08:00
ZhuRui d8ee7b3263 fix: 小改动 2026-08-07 18:42:06 +08:00
ZhuRui 3f47005f76 fix: 新增参数配置页面 处理其它问题 2026-08-07 18:19:14 +08:00
ZhuRui f0f0c88f18 fix: 小修改 2026-08-07 14:55:45 +08:00
ZhuRui 0f611ad926 fix: 修改权限提示 2026-08-07 14:48:25 +08:00
ZhuRui ba71b504e7 fix: 杂项修改 2026-08-07 14:30:54 +08:00
ZhuRui 117a7f408f fix: 对接登出 2026-08-07 11:26:49 +08:00
ZhuRui 05c3d518fa fix: 兼容后端 code 类型不统一问题 2026-08-07 11:20:30 +08:00
ZhuRui ea24625805 fix: 处理部分页面防抖滥用问题 2026-08-07 11:14:16 +08:00
ZhuRui 845829d088 fix: 优化部分样式与文案 2026-08-07 09:32:26 +08:00
101 changed files with 8614 additions and 308 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# 开发环境配置
# 基础API地址
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
# VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
VITE_API_BASE_URL=http://192.168.2.166:8080/cpms
# 是否开启本地调试
LOCAL_DEBUG=true
+4 -1
View File
@@ -1,2 +1,5 @@
# 生产环境
VITE_API_BASE_URL = https://api.cpms.example.com/admin
VITE_API_BASE_URL = https://cpmsapi.yunvip123.com
# 是否开启本地调试
LOCAL_DEBUG=false
+1721
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -6,10 +6,15 @@
"type": "module",
"scripts": {
"serve": "vite",
"serve:prod": "vite --mode production",
"build:dev": "vue-tsc --noEmit && vite build --mode development",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:prod": "vue-tsc --noEmit && vite build --mode production",
"build:prod": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build --mode production",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"preview": "vite preview",
"preview:prod": "pnpm build:prod && vite preview",
"type-check": "vue-tsc --noEmit",
"lint": "eslint src --ext .js,.ts,.tsx",
"lint:fix": "eslint src --ext .js,.ts,.tsx --fix",
@@ -42,17 +47,21 @@
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-vue": "^4.5.0",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"@vitest/coverage-v8": "2.1.9",
"@vue/test-utils": "^2.4.11",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.0.0",
"husky": "^9.1.7",
"jsdom": "^19.0.0",
"less": "^4.6.4",
"lint-staged": "^15.0.0",
"prettier": "^3.0.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
"vite-plugin-remove-console": "^2.2.0",
"vitest": "^2.1.9",
"vue-tsc": "^2.0.0"
},
"lint-staged": {
+57
View File
@@ -0,0 +1,57 @@
# ============================================================
# CPMS 运营平台 - 权限标识字符串列表
# 生成规则:
# 1. 每个页面都有一个 "页面名-查看" 的默认权限
# 2. 表格操作列中的按钮生成对应操作权限
# 3. 同一关键字的两个状态按钮(如上架/下架、启用/禁用)合并为一个权限
# 4. 筛选栏中有"新增"按钮的页面,生成新增权限
# 5. 标识为英文,附带中文说明(含按钮所在位置)
# ============================================================
# ─── 赛事管理模块 ─────────────────────────────────────────
events.list.view # 赛事列表-查看(页面默认查看权限)
events.list.toggle_status # 赛事列表-上架/下架(表格操作列,根据赛事状态显示"上架"或"下架"按钮,合并为一个权限)
events.regulation.view # 赛事规程-查看(赛事列表表格中的"赛事规程"列,点击"查看"链接)
events.orders.view # 订单管理-查看(页面默认查看权限)
events.orders.refund # 订单管理-重新退款(表格操作列,退款状态为3时显示"重新退款"按钮)
events.users.view # 用户列表-查看(页面默认查看权限)
events.users.toggle_status # 用户列表-启用/禁用(表格操作列,根据用户状态显示"启用"或"禁用"按钮,合并为一个权限)
events.banner.view # Banner配置-查看(页面默认查看权限)
events.banner.add # Banner配置-新增(筛选栏中的"新增"按钮)
events.banner.edit # Banner配置-编辑(表格操作列中的"编辑"按钮)
events.banner.toggle_status # Banner配置-启用/禁用(表格操作列,根据状态显示"启用"或"禁用"按钮,合并为一个权限)
events.banner.delete # Banner配置-删除(表格操作列中的"删除"按钮,红色危险样式)
events.logs.view # 赛事操作日志-查看(页面默认查看权限,无操作列按钮)
# ─── 账务管理模块 ─────────────────────────────────────────
finance.withdraw.view # 提现申请-查看(页面默认查看权限)
finance.withdraw.audit # 提现申请-审核(表格操作列,审核状态为"待审核"时显示"审核"按钮)
finance.wallet.view # 用户钱包-查看(页面默认查看权限)
finance.wallet.detail # 用户钱包-查看明细(表格操作列中的"明细"按钮,打开交易明细弹窗)
finance.payments.view # 支付流水-查看(页面默认查看权限,无操作列按钮,仅展示数据+金额汇总)
finance.reports.view # 账务报表-查看(页面默认查看权限,无操作列按钮,展示统计卡+趋势图+明细表)
# ─── 系统管理模块 ─────────────────────────────────────────
system.users.view # 用户管理-查看(页面默认查看权限)
system.users.add # 用户管理-新增(筛选栏中的"新增"按钮)
system.users.edit # 用户管理-编辑(表格操作列中的"编辑"按钮)
system.users.toggle_status # 用户管理-启用/禁用(表格操作列,根据用户状态显示"启用"或"禁用"按钮,合并为一个权限)
system.roles.view # 角色权限-查看(页面默认查看权限)
system.roles.add # 角色权限-新增(筛选栏中的"新增角色"按钮)
system.roles.edit # 角色权限-编辑(表格操作列中的"编辑"按钮)
system.roles.delete # 角色权限-删除(表格操作列中的"删除"按钮,红色危险样式)
system.logs.view # 操作日志-查看(页面默认查看权限,无操作列按钮)
system.params.view # 参数配置-查看
+1045 -1
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -47,7 +47,7 @@ export interface BannerActiveParams {
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+5 -1
View File
@@ -76,6 +76,10 @@ export interface EventListQueryParams {
idCardImg?: string;
/** 是否要求提供证件号码;0=无需,1=需要 */
idCardNo?: string;
/** 报名人数起始(含) */
scBegin?: string;
/** 报名人数结束(含) */
scEnd?: string;
}
/** 分页数据 */
@@ -87,7 +91,7 @@ export interface PageData<T> {
/** 统一响应包裹 */
export interface ApiResult<T> {
/** 编码:200表示成功,其他值表示失败 */
code: string;
code: number | string;
/** 消息内容 */
msg: string;
/** 响应数据 */
+8 -1
View File
@@ -5,8 +5,15 @@ export type { LoginResult } from './types';
export const login = (params: { phone: string; password: string }) => {
return post(`/op/login`, params) as Promise<{
code: string;
code: number | string;
msg: string;
data: LoginResult;
}>;
};
export const logout = () => {
return post(`/op/logout`) as Promise<{
code: number | string;
msg: string;
}>;
};
+1 -1
View File
@@ -57,7 +57,7 @@ export interface PageData<T> {
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+6 -5
View File
@@ -18,6 +18,7 @@ export interface AuthData {
}
const AUTH_FETCH_FAIL_MSG = '账户权限获取失败,请重新登录';
const AUTH_EMPTY_MSG = '当前账户权限无任何权限,请联系管理员';
function normalizeAuthData(res: any): AuthData {
if (res?.code != null && res.code != 200) {
@@ -29,7 +30,7 @@ function normalizeAuthData(res: any): AuthData {
const permsList = data.permsList || [];
if (permsList.length === 0) {
throw new Error(AUTH_FETCH_FAIL_MSG);
throw new Error(AUTH_EMPTY_MSG);
}
const nickname = data.nickName || '';
@@ -46,9 +47,9 @@ function normalizeAuthData(res: any): AuthData {
return { roleList, permsList, nickname, phone };
}
function handleAuthFetchFailure() {
function handleAuthFetchFailure(msg?: string) {
clearAuthDataCache();
forceReLogin(AUTH_FETCH_FAIL_MSG);
forceReLogin(msg);
}
/**
@@ -62,8 +63,8 @@ export function fetchAuthData(): Promise<AuthData> {
const promise = get('/op/permission')
.then((res) => normalizeAuthData(res))
.catch((err) => {
handleAuthFetchFailure();
.catch((err: Error) => {
handleAuthFetchFailure(err.message);
throw err;
});
+1 -1
View File
@@ -88,7 +88,7 @@ export interface PageData<T> {
/** 统一响应包裹 */
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+49
View File
@@ -0,0 +1,49 @@
/**
* 参数配置 API
*
* TODO: 等后端接口确定后,替换占位的 URL 和返回类型
*/
import { get, post, put, del } from '@/utils/request';
import type { ParamQueryParams, SysParamVO, PageData, ApiResult } from './types';
// 类型重导出,方便页面 model 层统一引用
export type { SysParamVO, ParamQueryParams, PageData, ApiResult } from './types';
// ============================================================
// URL 常量(占位,等后端接口确定后替换)
// ============================================================
/** 配置列表分页查询 */
const PARAM_LIST = '/admin/sys/param/page';
/** 新增配置 */
const PARAM_ADD = '/admin/sys/param/add';
/** 编辑配置 */
const PARAM_UPDATE = '/admin/sys/param/update';
/** 删除配置 */
const PARAM_DELETE = '/admin/sys/param/delete';
// ============================================================
// API 函数(占位)
// ============================================================
/** 配置列表分页 */
export async function getParamList(
params: ParamQueryParams,
): Promise<ApiResult<PageData<SysParamVO>>> {
return get(PARAM_LIST, params as Record<string, any>);
}
/** 新增配置 */
export async function addParam(data: Partial<SysParamVO>): Promise<ApiResult<null>> {
return post(PARAM_ADD, data);
}
/** 编辑配置 */
export async function updateParam(data: Partial<SysParamVO>): Promise<ApiResult<null>> {
return put(PARAM_UPDATE, data);
}
/** 删除配置 */
export async function deleteParam(id: number): Promise<ApiResult<null>> {
return del(`${PARAM_DELETE}`, { id });
}
+40
View File
@@ -0,0 +1,40 @@
/**
* 参数配置 — 类型定义
*
* TODO: 等后端接口确定后,根据实际字段补充完整类型
*/
/** 配置项列表项 */
export interface SysParamVO {
/** 配置项 ID */
id: number;
/** 配置项 key */
// paramKey: string;
/** 配置项 value */
// paramValue: string;
/** 备注 */
// remark: string;
/** 更新时间 */
// updateTime: string;
}
/** 配置列表查询参数 */
export interface ParamQueryParams {
page?: string;
limit?: string;
/** 搜索关键字 */
// keyword?: string;
}
/** 分页数据结构 */
export interface PageData<T> {
total: number;
list: T[];
}
/** API 统一响应格式 */
export interface ApiResult<T> {
code: number | string;
msg: string;
data: T;
}
+1 -1
View File
@@ -58,7 +58,7 @@ export interface PaymentFlowPageData {
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+1 -1
View File
@@ -42,7 +42,7 @@ export interface ReportQueryParams {
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+1 -1
View File
@@ -59,7 +59,7 @@ export interface PageData<T> {
list: T[];
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+1 -1
View File
@@ -65,7 +65,7 @@ export interface PageData<T> {
list: T[];
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
+3 -1
View File
@@ -55,7 +55,7 @@ export interface PageData<T> {
list: T[];
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
@@ -84,6 +84,8 @@ export interface WalletTransactionVO {
orderNo: string;
/** 时间 */
createDate: string;
/** 状态;1=已解冻,2=冻结中,3=正常 */
status: number;
}
/** 交易明细查询参数 */
+3 -1
View File
@@ -50,7 +50,7 @@ export interface PageData<T> {
list: T[];
}
export interface ApiResult<T> {
code: string;
code: number | string;
msg: string;
data: T;
}
@@ -101,4 +101,6 @@ export interface WithdrawInfoVO {
auditImg: string;
/** 审核图片列表 */
auditImgList: string[];
/** 审核结果 */
checkMsg: string;
}
+26
View File
@@ -4,6 +4,32 @@
flex-direction: column;
height: 100%;
// Tabs 容器:撑满高度
.tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:global {
.ant-tabs-content-holder {
flex: 1;
min-height: 0;
overflow: hidden;
}
.ant-tabs-content {
height: 100%;
}
.ant-tabs-tabpane {
height: 100%;
display: flex;
flex-direction: column;
}
}
}
// 页面筛选区域:横向 flex,可换行
.filter {
display: flex;
+4 -4
View File
@@ -165,9 +165,9 @@ const PageTabs = defineComponent({
case 'other':
props.onCloseOther?.(props.activeKey);
break;
case 'all':
props.onCloseAll?.();
break;
// case 'all':
// props.onCloseAll?.();
// break;
}
};
@@ -278,7 +278,7 @@ const PageTabs = defineComponent({
{renderBatchMenuItem('left', renderAiIcon(LuPanelLeftClose), '关闭左侧标签页')}
{renderBatchMenuItem('right', renderAiIcon(LuPanelRightClose), '关闭右侧标签页')}
{renderBatchMenuItem('other', renderAiIcon(LuCopyX), '关闭其它标签页')}
{renderBatchMenuItem('all', renderAiIcon(LuListX), '关闭全部标签页')}
{/* {renderBatchMenuItem('all', renderAiIcon(LuListX), '关闭全部标签页')} */}
</Menu>
),
}}
+18
View File
@@ -164,6 +164,24 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
icon: 'AiOutlineSafetyCertificate',
permission: 'system.roles.view',
},
{
id: 'system_params',
name: '参数配置',
path: 'system/params',
component: 'system/params',
componentName: 'SystemParams',
icon: 'AiOutlineSetting',
permission: 'system.params.view',
},
{
id: 'system_ai_agent',
name: 'AI 场景配置',
path: 'system/ai-agent',
component: 'system/ai-agent',
componentName: 'SystemAiAgent',
icon: 'AiOutlineRobot',
permission: 'system.ai.view',
},
{
id: 'system_logs',
name: '操作日志',
+9 -3
View File
@@ -7,6 +7,7 @@ import { usePermissionStore } from '@/stores/permissionStore';
import { useTabsStore } from '@/stores/tabsStore';
import { useUserStore } from '@/stores/userStore';
import { clearAuthDataCache } from '@/api/authCache';
import { logout as logoutApi } from '@/api/login';
import router from '@/router';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
@@ -66,9 +67,14 @@ function createAuth() {
};
/**
* 退出:清除 token → 清除菜单+权限 → 清除动态路由 → 跳转登录页
* 退出:调用登出接口 → 清除 token → 清除菜单+权限 → 清除动态路由 → 跳转登录页
*/
const logout = () => {
const logout = async () => {
try {
await logoutApi();
} catch {
// 即使接口失败也要继续清除本地会话
}
clearSession();
router.push('/login');
};
@@ -95,7 +101,7 @@ export function forceReLogin(msg = '账户权限获取失败,请重新登录')
notification.error({
message: '提示',
description: msg,
duration: 2600,
duration: 3,
});
auth.clearSession();
router.push('/login');
+5 -7
View File
@@ -79,7 +79,7 @@ export default defineComponent({
const { menuItems } = useMenuStore();
const { phone, nickname } = useUserStore();
const displayName = computed(() => nickname.value || phone.value);
const displayName = computed(() => phone.value || nickname.value);
const {
tabs,
cachedViews,
@@ -108,13 +108,11 @@ export default defineComponent({
/** ===== 退出登录 ===== */
const handleLogout = () => {
Modal.confirm({
title: '提示',
content: '是否退出登录?',
title: '确认退出',
content: '确认要退出当前账号吗?',
okText: '确认',
cancelText: '取消',
onOk: () => {
const { clearUser } = useUserStore();
clearUser();
auth.logout();
},
});
@@ -136,7 +134,7 @@ export default defineComponent({
password: payload.newPassword,
confirmPassword: payload.newPassword,
});
if (res.code === '200') {
if (res.code == 200) {
message.success('密码修改成功');
setChangePwdVisible(false);
window.setTimeout(() => {
@@ -298,7 +296,7 @@ export default defineComponent({
</div>
</Content>
<Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer>
{/* <Footer style={footerStyle}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</Footer> */}
</Layout>
{/* 修改密码弹窗 */}
+1 -1
View File
@@ -1,7 +1,7 @@
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import './styles.less';
import App from './App';
import router from './router';
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
@@ -50,7 +50,7 @@ const getDefaultForm = () => ({
type: '1' as '1' | '2' | '3' | '0',
linkUrl: '',
relatedEventId: undefined as string | undefined,
area: '全国',
area: '',
displayTimeRange: null as [string, string] | null,
sort: 1,
});
@@ -84,18 +84,49 @@ function base64ToFile(dataUrl: string, fileName: string): File {
// 工具函数
// ============================================================
/** 从 Cascader 路径中提取城市名(最后一级),用于区域筛选 */
function extractCityName(path: string[]): string {
if (!path || path.length === 0 || path[0] === '全国') return '';
return path[path.length - 1];
/** 在 Cascader options 树中按路径查找末端节点 */
function findNodeByPath(options: any[], path: string[]): any | null {
let level = options;
let node: any = null;
for (const seg of path) {
node = level.find((o) => o.value === seg);
if (!node) return null;
level = node.children || [];
}
return node;
}
/** 从 Cascader 路径中提取存储用的区域字符串 */
function extractAreaValue(path: string[]): string {
if (!path || path.length === 0) return '全国';
if (path[0] === '全国') return '全国';
// 直辖市路径如 ["北京市"],普通城市如 ["广东省", "深圳市"]
return path[path.length - 1];
/** 递归收集节点下的所有叶子节点 value */
function collectLeafValues(node: any, acc: string[]) {
if (node.children && node.children.length > 0) {
node.children.forEach((child: any) => collectLeafValues(child, acc));
} else {
acc.push(String(node.value));
}
}
/**
* 将 Cascader 多选选中的路径数组转为叶子节点 value 数组(去重)
* 多选时可能选中父节点(省),这里递归展开为最下级的叶子节点(市)
* 如 [["广东省"], ["北京市"]] -> ["广州市", "深圳市", ..., "北京市"]
*/
function extractLeafValues(options: any[], paths: string[][]): string[] {
const result: string[] = [];
const seen = new Set<string>();
for (const path of paths) {
if (!path || path.length === 0) continue;
const node = findNodeByPath(options, path);
if (!node) continue;
const leaves: string[] = [];
collectLeafValues(node, leaves);
leaves.forEach((v) => {
if (!seen.has(v)) {
seen.add(v);
result.push(v);
}
});
}
return result;
}
/** 根据跳转类型计算 parms 字段的值 */
@@ -128,8 +159,8 @@ export default defineComponent({
const fileInputRef = ref<HTMLInputElement | null>(null);
const formData = reactive(getDefaultForm());
/** Cascader 区域的路径数组:如 ["全国"]、["广东省", "深圳市"] */
const areaPath = ref<string[]>(['全国']);
/** Cascader 区域的路径数组(多选):如 [["全国"]]、[["广东省", "深圳市"], ["北京市"]] */
const areaPath = ref<string[][]>([]);
/** 是否由用户主动切换了跳转类型 */
const typeChanged = ref(false);
@@ -152,25 +183,25 @@ export default defineComponent({
return [{ value: '', label: '不关联' }, ...eventOptions.value];
});
// ---- 赛事搜索 API 调用 ----
// ---- 赛事列表获取(keyword 可为空:选择跳转类型/所属区域后自动获取) ----
const doSearch = async (keyword: string) => {
if (!keyword.trim()) {
setEventOptions([]);
return;
}
setEventSearchLoading(true);
try {
const cityName = extractCityName(areaPath.value);
// 当前选中的叶子区域名(多选去重后)
const leafNames = extractLeafValues(areaCascaderOptions, areaPath.value);
const params: Record<string, string> = {
page: '1',
limit: '999',
name: keyword.trim(),
};
if (cityName) {
params.areaName = cityName;
if (keyword.trim()) {
params.name = keyword.trim();
}
// 区域筛选:接口 areaName 为单值,仅在恰好选中一个区域时传递
if (leafNames.length === 1 && leafNames[0] !== '全国') {
params.areaName = leafNames[0];
}
const res = await getEventList(params as any);
if (res.code === '200') {
if (res.code == 200) {
const list: TournamentAdminVO[] = res.data?.list || [];
setEventOptions(
list.map((item) => ({
@@ -182,7 +213,7 @@ export default defineComponent({
setEventOptions([]);
}
} catch (e) {
console.error('[Banner] 搜索赛事失败:', e);
console.error('[Banner] 获取赛事列表失败:', e);
setEventOptions([]);
} finally {
setEventSearchLoading(false);
@@ -202,26 +233,24 @@ export default defineComponent({
}, 350);
};
/** 所属区域 Cascader 变化 */
/** 所属区域 Cascader 变化(多选) */
const onAreaChange = (val: any) => {
const path: string[] = val || [];
const newArea = extractAreaValue(path);
areaPath.value = path;
formData.area = newArea;
const paths: string[][] = Array.isArray(val) ? val : [];
areaPath.value = paths;
// 多选后只保留最下级的叶子节点,并用逗号分隔成字符串
const leafNames = extractLeafValues(areaCascaderOptions, paths);
formData.area = leafNames.join(',');
// 切换到非"全国"区域时清空已关联赛事
if (newArea !== '全国') {
if (formData.area !== '全国') {
formData.relatedEventId = undefined;
formRef.value?.clearValidate('relatedEventId');
}
// 区域变化时重新搜索(如果当前有关键词)
if (eventKeyword.value.trim()) {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
doSearch(eventKeyword.value);
}, 0);
}
// 区域变化时自动重新获取赛事列表
if (searchTimer) clearTimeout(searchTimer);
doSearch(eventKeyword.value);
};
// ---- 根据 record 初始化表单 ----
@@ -229,21 +258,30 @@ export default defineComponent({
const fresh = getDefaultForm();
if (!record) {
Object.assign(formData, fresh);
areaPath.value = ['全国'];
areaPath.value = [];
eventKeyword.value = '';
setEventOptions([]);
// 新增时默认跳转类型为"赛事详情页",自动加载赛事下拉
if (formData.type === '1') {
doSearch('');
}
return;
}
// 区域:兼容旧格式 "广东省-深圳市"、新格式 "深圳市"、"全国"
// 区域:兼容旧格式 "广东省-深圳市"、新格式多选 "深圳市,北京市"、"全国"
const rawArea = record.area || record.region || '';
let cityName: string;
if (rawArea.includes('-')) {
cityName = rawArea.split('-').pop() || '全国';
} else {
cityName = rawArea || '全国';
}
areaPath.value = cityNameToCascaderPath(cityName);
const areaNames = rawArea
.split(',')
.map((s: string) => s.trim())
.filter(Boolean);
const leafNames = areaNames.map((name: string) =>
name.includes('-') ? name.split('-').pop()! : name,
);
const paths: string[][] = leafNames.length
? leafNames.map((name: string) => cityNameToCascaderPath(name))
: [['全国']];
areaPath.value = paths;
const normalizedArea = leafNames.length ? leafNames.join(',') : '全国';
// 跳转类型
const type = String(record.type || record.jumpType || '1');
@@ -258,7 +296,7 @@ export default defineComponent({
setEditLoading(true);
getEventDetail(eventId)
.then((detailRes) => {
if (detailRes.code === '200') {
if (detailRes.code == 200) {
const eventName = detailRes.data?.name || eventId;
setEventOptions([{ value: eventId, label: eventName }]);
}
@@ -285,7 +323,7 @@ export default defineComponent({
type,
linkUrl: record.params || record.linkUrl || record.jumpUrl || '',
relatedEventId,
area: cityName,
area: normalizedArea,
displayTimeRange:
(record.showTimeBegin || record.displayStartTime) &&
(record.showTimeEnd || record.displayEndTime)
@@ -341,6 +379,12 @@ export default defineComponent({
if (activeNames.length) {
await formRef.value?.validateFields(activeNames).catch(() => {});
}
// 切换到"赛事详情页"时自动加载赛事下拉
if (formData.type === '1') {
if (searchTimer) clearTimeout(searchTimer);
doSearch(eventKeyword.value);
}
};
syncTypeFields();
@@ -614,8 +658,9 @@ export default defineComponent({
<Cascader
v-model:value={areaPath.value}
options={areaCascaderOptions}
multiple
placeholder="请选择所属区域"
allowClear={false}
allowClear
onChange={onAreaChange}
/>
</Form.Item>
+33
View File
@@ -0,0 +1,33 @@
.cell {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
cursor: default;
}
.text {
flex: 1;
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
word-break: break-all;
color: rgba(0, 0, 0, 0.88);
}
.view {
flex-shrink: 0;
color: #1677ff;
cursor: pointer;
user-select: none;
line-height: 1.5;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}
+68 -2
View File
@@ -1,4 +1,4 @@
import { defineComponent } from 'vue';
import { defineComponent, ref, nextTick } from 'vue';
import {
Button,
Input,
@@ -12,10 +12,70 @@ import {
Image,
} from 'ant-design-vue';
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
import { useContainerSize } from '@/hooks';
import { useContainerSize, useState, useEffect } from '@/hooks';
import { usePermissionStore } from '@/stores/permissionStore';
import BannerFormModal from './components/BannerFormModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import cellStyles from './index.module.less';
/**
* 所属区域单元格:最多 2 行截断,溢出时显示"查看"按钮
*/
const AreaCell = defineComponent({
name: 'AreaCell',
props: { text: { type: String, required: true } },
setup(props) {
const textRef = ref<HTMLElement | null>(null);
const [overflow, setOverflow] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const checkOverflow = () => {
const el = textRef.value;
if (el) setOverflow(el.scrollHeight > el.clientHeight);
};
let observer: ResizeObserver | null = null;
useEffect(() => {
if (!textRef.value) return;
nextTick(checkOverflow);
observer = new ResizeObserver(checkOverflow);
observer.observe(textRef.value);
return () => {
observer?.disconnect();
observer = null;
};
}, [() => textRef.value]);
return () => {
const raw = props.text;
return (
<div class={cellStyles.cell}>
<div ref={textRef} class={cellStyles.text}>
{raw}
</div>
{overflow.value ? (
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
查看
</span>
) : null}
<Modal
visible={modalOpen.value}
title="所属区域"
footer={null}
width={600}
centered
destroyOnClose
onCancel={() => setModalOpen(false)}
>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.8 }}>
{raw.replace(/,/g, ' ')}
</div>
</Modal>
</div>
);
};
},
});
/**
* bodyCell 渲染函数
@@ -72,6 +132,12 @@ function renderBodyCell({
);
}
// 所属区域列
if (column.dataIndex === 'area') {
const raw = text || '';
return raw ? <AreaCell text={raw} /> : <span>--</span>;
}
// 跳转类型列
if (column.dataIndex === 'type') {
return renderJumpType(text);
@@ -82,7 +82,7 @@ export function useBannerModel() {
loading,
run: fetchList,
} = useRequest<TournamentAdminBannerListVO[]>(() => getBannerList(buildQueryParams()), {
formatResult: (res) => (res.code === '200' ? res.data : []),
formatResult: (res) => (res.code == 200 ? res.data : []),
});
const allData = computed(() => data.value || []);
@@ -116,7 +116,7 @@ export function useBannerModel() {
key: 'type',
width: 120,
},
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 140 },
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 300 },
{ title: '展示时间', key: 'displayTime', width: 260 },
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 160 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center' as const },
@@ -198,7 +198,7 @@ export function useBannerModel() {
const res = isEdit
? await updateBanner({ ...payload, id: editingRecord.value.id })
: await saveBanner(payload);
if (res.code === '200') {
if (res.code == 200) {
message.success(isEdit ? '编辑成功' : '新增成功');
handleCloseModal();
handleSearch();
@@ -216,7 +216,7 @@ export function useBannerModel() {
const handleDelete = useThrottleFn(async (record: any) => {
try {
const res = await delBanner(record.id);
if (res.code === '200') {
if (res.code == 200) {
message.success('删除成功');
fetchList();
} else {
@@ -233,7 +233,7 @@ export function useBannerModel() {
const newStatus = record.status === '1' ? '0' : '1';
const actionText = newStatus === '1' ? '启用' : '禁用';
const res = await toggleBannerActive({ id: record.id, status: newStatus });
if (res.code === '200') {
if (res.code == 200) {
message.success(`${actionText}成功`);
fetchList();
} else {
@@ -42,7 +42,7 @@ export default defineComponent({
detail.value = null;
try {
const res = await getEventDetail(id);
if (res.code === '200') {
if (res.code == 200) {
detail.value = res.data;
} else {
message.error(res.msg || '获取赛事详情失败');
@@ -28,12 +28,29 @@
}
.desc {
:global(.ant-descriptions-view table) {
table-layout: fixed;
width: 100%;
}
:global(.ant-descriptions-item-label) {
width: 110px;
color: rgba(0, 0, 0, 0.65);
}
}
// 首列内容区加宽(省市区、公开活动、创建时间等)
.wideCol:global(.ant-descriptions-item-content) {
width: 38%;
min-width: 200px;
}
// 较长 label 加宽且不换行(class 同时挂在 label/content 上,需用复合选择器)
.wideLabel:global(.ant-descriptions-item-label) {
width: 132px;
white-space: nowrap;
}
// Item 的 class 会同时挂在 label/content 两个同级 td/th 上,不能用后代选择器
.statCell:global(.ant-descriptions-item-content) {
min-width: 60px;
@@ -66,13 +83,37 @@
}
.divisionList {
display: inline-flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.coverWrapper {
display: flex;
flex-direction: column;
gap: 6px;
justify-content: center;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
padding: 16px;
:global(.ant-image) {
max-width: 100%;
}
}
.divisionItem {
display: inline-flex;
align-items: center;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
padding: 4px 12px;
background: #f5f5f5;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
color: #1677ff;
background: #e6f4ff;
}
}
}
@@ -1,6 +1,7 @@
import { defineComponent, computed, reactive, ref } from 'vue';
import { Modal, Descriptions, Image, Table, Pagination, Spin, Space } from 'ant-design-vue';
import { message } from 'ant-design-vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import {
getEventDetail,
getSignupList,
@@ -43,6 +44,7 @@ const renderIdImages = (imgList: string[], singleImg: string) => {
width={40}
height={40}
style={{ objectFit: 'cover', borderRadius: '4px', cursor: 'pointer' }}
previewMask={() => <EyeOutlined />}
/>
))}
</Space>
@@ -79,6 +81,13 @@ const ID_CARD_IMG_COLUMN: any = {
record ? renderIdImages(record.idCardImgList || [], record.idCardImg || '') : <span>--</span>,
};
type ConditionalColumnKey = 'isRequireIdCardNo' | 'isRequireIdCardImg';
const CONDITIONAL_PLAYER_COLUMNS: { key: ConditionalColumnKey; column: any }[] = [
{ key: 'isRequireIdCardNo', column: ID_CARD_COLUMN },
{ key: 'isRequireIdCardImg', column: ID_CARD_IMG_COLUMN },
];
export default defineComponent({
name: 'EventDetailModal',
props: {
@@ -92,12 +101,12 @@ export default defineComponent({
const [detailLoading, setDetailLoading] = useState(false);
/** 动态列:根据赛事是否要求证件号/证件图片决定显示 */
const playerColumns = computed(() => {
const cols = [...BASE_PLAYER_COLUMNS];
if (detail.value?.isRequireIdCardNo === '1') cols.push(ID_CARD_COLUMN);
if (detail.value?.isRequireIdCardImg === '1') cols.push(ID_CARD_IMG_COLUMN);
return cols;
});
const playerColumns = computed(() => [
...BASE_PLAYER_COLUMNS,
...CONDITIONAL_PLAYER_COLUMNS.filter(({ key }) => detail.value?.[key] === '1').map(
({ column }) => column,
),
]);
// ===== 每个组别的选手数据(key = categoryId) =====
const categoryPlayers = ref<Record<string, TournamentAdminInfoSignupVO[]>>({});
@@ -116,7 +125,7 @@ export default defineComponent({
page: String(page),
limit: String(PAGE_SIZE),
});
if (res.code === '200') {
if (res.code == 200) {
categoryPlayers.value[categoryId] = res.data.list;
categoryPlayers.value = { ...categoryPlayers.value };
categoryPlayerTotal.value[categoryId] = res.data.total;
@@ -143,7 +152,7 @@ export default defineComponent({
setDetail(null);
try {
const res = await getEventDetail(id);
if (res.code === '200' && res.data) {
if (res.code == 200 && res.data) {
setDetail(res.data);
// 为每个组别加载第一页选手
for (const cat of res.data.categoryList) {
@@ -179,7 +188,7 @@ export default defineComponent({
<Modal
visible={props.visible}
title="查看详情"
width={900}
width={980}
onCancel={props.onClose}
footer={null}
centered
@@ -196,26 +205,32 @@ export default defineComponent({
<div class={styles.section}>
<div class={styles.sectionTitle}>基础信息</div>
<Descriptions size="small" bordered column={3} class={styles.desc}>
<Descriptions.Item label="赛事名称">{detail.value.name}</Descriptions.Item>
<Descriptions.Item label="赛事时间" span={2}>
<Descriptions.Item label="赛事名称" span={2}>
{detail.value.name}
</Descriptions.Item>
<Descriptions.Item label="赛事时间">
{detail.value.startTimeBegin} 至 {detail.value.startTimeEnd}
</Descriptions.Item>
<Descriptions.Item label="赛事说明" span={3}>
{detail.value.descr}
<div style={{ whiteSpace: 'pre-wrap', maxHeight: '280px', overflowY: 'auto' }}>
{detail.value.descr}
</div>
</Descriptions.Item>
<Descriptions.Item label="报名起止时间" span={3}>
<Descriptions.Item label="报名起止时间" span={3} class={styles.wideLabel}>
{signupTimeDisplay(detail.value.signupTimeBegin, detail.value.signupTimeEnd)}
</Descriptions.Item>
<Descriptions.Item label="省市区">{detail.value.venueArea}</Descriptions.Item>
<Descriptions.Item label="省市区" class={styles.wideCol}>
{detail.value.venueArea}
</Descriptions.Item>
<Descriptions.Item label="地点">{detail.value.venue}</Descriptions.Item>
<Descriptions.Item label="场地编号" class={styles.statCell}>
{detail.value.venueNo || '--'}
</Descriptions.Item>
<Descriptions.Item label="公开活动">
<Descriptions.Item label="公开活动" class={styles.wideCol}>
{formatPublic(detail.value.isPublic)}
</Descriptions.Item>
<Descriptions.Item label="证件号">
@@ -225,12 +240,16 @@ export default defineComponent({
{formatYN(detail.value.isRequireIdCardImg)}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{detail.value.createTime}</Descriptions.Item>
<Descriptions.Item label="创建时间" class={styles.wideCol}>
{detail.value.createTime}
</Descriptions.Item>
<Descriptions.Item label="创建人" span={2}>
{detail.value.createNickname}
</Descriptions.Item>
<Descriptions.Item label="报名人数">{detail.value.signupCount}</Descriptions.Item>
<Descriptions.Item label="报名人数" class={styles.wideCol}>
{detail.value.signupCount}
</Descriptions.Item>
<Descriptions.Item label="退赛人数">{detail.value.dropoutCount}</Descriptions.Item>
<Descriptions.Item
label="最大人数"
@@ -239,7 +258,7 @@ export default defineComponent({
{detail.value.totalLimit || '--'}
</Descriptions.Item>
<Descriptions.Item label="总对阵数">
<Descriptions.Item label="总对阵数" class={styles.wideCol}>
{detail.value.matchTotalCount}
</Descriptions.Item>
<Descriptions.Item label="已完成对阵数" span={2}>
@@ -251,11 +270,13 @@ export default defineComponent({
{/* ===== 封面 ===== */}
<div class={styles.section}>
<div class={styles.sectionTitle}>封面</div>
<Image
style={{ maxHeight: '380px' }}
src={detail.value.img || defaultCover}
fallback={defaultCover}
/>
<div class={styles.coverWrapper}>
<Image
style={{ maxHeight: '380px' }}
src={detail.value.img || defaultCover}
fallback={defaultCover}
/>
</div>
</div>
{/* ===== 赛事公告 ===== */}
@@ -25,6 +25,8 @@
padding: 12px 16px;
border-radius: 4px;
border: 1px solid #f0f0f0;
max-height: 360px;
overflow-y: auto;
}
.imageGrid {
@@ -29,7 +29,7 @@ export default defineComponent({
ruleData.value = null;
try {
const res = await getRuleInfo(id);
if (res.code === '200' && res.data) {
if (res.code == 200 && res.data) {
ruleData.value = res.data;
} else {
ruleData.value = null;
+41 -1
View File
@@ -2,6 +2,7 @@ import { defineComponent } from 'vue';
import {
Button,
Input,
InputNumber,
Table,
Form,
Space,
@@ -89,7 +90,7 @@ export default defineComponent({
const renderAction = (record: any) => {
const status = record.status;
if (status === '0' || status === '1') {
if (['0', '1', '2'].includes(status)) {
return (
<Space>
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
@@ -266,6 +267,45 @@ export default defineComponent({
onUpdate:value={(val: any) => (filterForm.region = val || [])}
/>
</Form.Item>
<Form.Item label="报名人数">
<Space.Compact>
<InputNumber
placeholder="请输入"
style={{ width: '140px' }}
value={filterForm.scBegin || undefined}
precision={0}
step={0}
min={0}
controls={false}
onUpdate:value={(val: any) =>
(filterForm.scBegin = val != null ? String(val) : '')
}
onPressEnter={handleSearch}
/>
<Input
style={{
width: '40px',
textAlign: 'center',
backgroundColor: '#fafafa',
pointerEvents: 'none',
color: 'rgba(0,0,0,0.45)',
}}
value="至"
disabled
/>
<InputNumber
placeholder="请输入"
style={{ width: '140px' }}
value={filterForm.scEnd || undefined}
precision={0}
step={0}
min={0}
controls={false}
onUpdate:value={(val: any) => (filterForm.scEnd = val != null ? String(val) : '')}
onPressEnter={handleSearch}
/>
</Space.Compact>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
+4
View File
@@ -15,6 +15,8 @@ export const FILTER_DEFAULTS = {
isPublic: '',
idCardNo: '',
idCardImg: '',
scBegin: '',
scEnd: '',
};
export type FieldMapping = [
@@ -35,6 +37,8 @@ export const FIELD_MAPPINGS: FieldMapping[] = [
['createDateRange', 'createDateEnd', (v) => v?.[1]],
['startDateRange', 'startDate', (v) => v?.[0]],
['startDateRange', 'startDateEnd', (v) => v?.[1]],
['scBegin', 'scBegin'],
['scEnd', 'scEnd'],
];
/** 赛事状态选项(value 对应 API status) */
@@ -92,11 +92,24 @@ export function useEventListModel() {
f.region.length > 0 ||
!!f.isPublic ||
!!f.idCardNo ||
!!f.idCardImg
!!f.idCardImg ||
!!f.scBegin?.trim() ||
!!f.scEnd?.trim()
);
});
/** 报名人数区间:起始大于结束时对调 */
const normalizeSignupCountRange = () => {
const begin = filterForm.scBegin.trim();
const end = filterForm.scEnd.trim();
if (begin && end && Number(begin) > Number(end)) {
filterForm.scBegin = end;
filterForm.scEnd = begin;
}
};
const handleSearch = () => {
normalizeSignupCountRange();
pagination.reset();
fetchList();
};
@@ -145,8 +158,8 @@ export function useEventListModel() {
const isRemoved = record.status === '4';
const actionText = isRemoved ? '上架' : '下架';
const descText = isRemoved
? `确认上架"${record.name}"吗?上架后用户可搜索和报名该赛事。`
: `确认下架"${record.name}"吗?下架后用户将无法搜索和报名该赛事。`;
? `确认上架"${record.name}"该赛事吗?`
: `确认下架"${record.name}"吗?下架后用户将无法搜索和报名该赛事,已有报名和订单不受影响。`;
Modal.confirm({
title: `${actionText}确认`,
@@ -157,7 +170,7 @@ export function useEventListModel() {
setTogglingId(record.id);
try {
const res = await toggleEventOnline({ id: record.id, online: isRemoved ? '1' : '0' });
if (res.code === '200') {
if (res.code == 200) {
message.success(`${actionText}成功`);
fetchList();
} else {
@@ -1,5 +1,6 @@
import { defineComponent, computed } from 'vue';
import { Modal, Table, Button, Pagination, Image, Space, Spin } from 'ant-design-vue';
import { EyeOutlined } from '@ant-design/icons-vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useState, useEffect } from '@/hooks';
import {
@@ -57,16 +58,19 @@ const PLAYER_COLUMNS = [
key: 'idCardImg',
width: 170,
customRender: ({ record }: { record: TournamentAdminOrderInfoPageVO }) => {
const urls = record.idCardImgList?.length
? record.idCardImgList
: record.idCardImg
? [record.idCardImg]
: [];
const urls = record.idCardImgList || [];
if (!urls.length) return <span>--</span>;
return (
<Space>
{urls.map((src) => (
<Image key={src} width={60} height={40} src={src} />
<Image
key={src}
width={60}
height={40}
src={src}
style={{ objectFit: 'cover', borderRadius: '4px', cursor: 'pointer' }}
previewMask={() => <EyeOutlined />}
/>
))}
</Space>
);
@@ -148,7 +152,7 @@ export default defineComponent({
page: String(page),
limit: String(playerPageSize),
});
if (res.code === '200') {
if (res.code == 200) {
setPlayers(res.data.list);
setPlayerTotal(res.data.total);
setPlayerPage(page);
@@ -184,7 +188,7 @@ export default defineComponent({
else params['uniqueId'] = props.uniqueId;
const [detailRes] = await Promise.all([getOrderDetail(params), fetchPlayers(1)]);
if (detailRes.code === '200') {
if (detailRes.code == 200) {
setDetail(detailRes.data);
}
setDetailLoading(false);
+1 -1
View File
@@ -38,7 +38,7 @@ function promptReRefund(record: any) {
onOk: async () => {
try {
const res = await retryRefundOrder({ payOrderNo: record.orderNo });
if (res.code === '200') {
if (res.code == 200) {
message.success('重新退款提交成功');
} else {
message.error(res.msg || '操作失败');
+1 -1
View File
@@ -124,7 +124,7 @@ export function useUserModel() {
setTogglingId(record.id);
try {
const res = await toggleUserActive({ userId: record.id, status: isActive ? '0' : '1' });
if (res.code === '200') {
if (res.code == 200) {
message.success(`${actionText}成功`);
fetchList();
} else {
@@ -1,12 +1,6 @@
import { computed, reactive, toRef, Ref, nextTick } from 'vue';
import {
useDebounce,
useThrottleFn,
useRequest,
usePagination,
useEffect,
syncPaginationTotal,
} from '@/hooks';
import { computed, reactive, nextTick, h } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { useThrottleFn, useRequest, usePagination, useEffect, syncPaginationTotal } from '@/hooks';
import { getPaymentFlowList, type PaymentFlowVO, type PaymentFlowQueryParams } from './services';
import dayjs from 'dayjs';
@@ -20,11 +14,20 @@ export const PAYMENT_TYPE_OPTIONS = [
{ value: '2', label: '取消报名' },
] as const;
export const PAYMENT_TYPE_MAP: Record<number, string> = {
1: '报名',
2: '取消报名',
export const PAYMENT_TYPE_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
1: { label: '报名', tone: 'success' },
2: { label: '取消报名', tone: 'default' },
};
/** 渲染交易类型 StatusTag */
export function renderPaymentType(type: number) {
const info = PAYMENT_TYPE_MAP[type] || {
label: String(type ?? '--'),
tone: 'default' as const,
};
return h(StatusTag, { label: info.label, tone: info.tone });
}
// ============================================================
// Model
// ============================================================
@@ -38,15 +41,6 @@ export function usePaymentsModel() {
type: '',
});
const { debouncedValue: debouncedNickname } = useDebounce(
toRef(filterForm, 'nickname') as Ref<string>,
{ delay: 300 },
);
const { debouncedValue: debouncedPhone } = useDebounce(
toRef(filterForm, 'phone') as Ref<string>,
{ delay: 300 },
);
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
@@ -61,8 +55,8 @@ export function usePaymentsModel() {
if (start) params.createDateBegin = dayjs(start).format('YYYY-MM-DD');
if (end) params.createDateEnd = dayjs(end).format('YYYY-MM-DD');
}
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (filterForm.nickname.trim()) params.nickname = filterForm.nickname.trim();
if (filterForm.phone.trim()) params.phone = filterForm.phone.trim();
if (filterForm.type) params.type = filterForm.type;
return params;
};
@@ -106,7 +100,7 @@ export function usePaymentsModel() {
dataIndex: 'type',
key: 'type',
width: 110,
customRender: ({ text }: { text: number }) => PAYMENT_TYPE_MAP[text] || text || '--',
customRender: ({ text }: { text: number }) => renderPaymentType(text),
},
{ title: '用户昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 140 },
@@ -124,18 +118,18 @@ export function usePaymentsModel() {
const hasFilter = computed(() => {
return (
filterForm.timeRange !== null ||
debouncedNickname.value.trim() !== '' ||
debouncedPhone.value.trim() !== '' ||
filterForm.nickname.trim() !== '' ||
filterForm.phone.trim() !== '' ||
filterForm.type !== ''
);
});
// ===== 方法 =====
const handleSearch = () => {
const handleSearch = useThrottleFn(() => {
pagination.reset();
fetchList();
};
}, 300);
const handleReset = useThrottleFn(async () => {
filterForm.timeRange = null;
+19 -3
View File
@@ -1,10 +1,22 @@
import { defineComponent, ref, nextTick } from 'vue';
import { DatePicker, Select, Table } from 'ant-design-vue';
import * as echarts from 'echarts';
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { useReportsModel } from './model/useReportsModel';
import { useEffect, useContainerSize } from '@/hooks';
import styles from './index.module.less';
echarts.use([
BarChart,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
CanvasRenderer,
]);
const { RangePicker } = DatePicker;
/**
@@ -180,7 +192,7 @@ export default defineComponent({
},
{
key: 'totalBalance',
label: '平台余额(所有用户余额总和)',
label: '平台余额(所有用户余额总和 = 总收入 - 提现金额)',
value: s.totalBalance,
bgColor: '#e6f4ff',
accentColor: '#1677ff',
@@ -224,7 +236,11 @@ export default defineComponent({
<div class={styles.statValue} style={{ color: item.accentColor }}>
¥{formatMoney(item.value)}
</div>
{/* 趋势文字暂时隐藏 */}
{/* {item.key === 'totalBalance' && (
<div class={styles.statDesc} style={{ color: 'rgba(0, 0, 0, 0.45)' }}>
= 总收入 - 提现金额
</div>
)} */}
</div>
))}
</div>
@@ -110,7 +110,7 @@ export function useReportsModel() {
() => getFinanceReport(buildQueryParams()),
{
refreshDeps: [dateRange],
formatResult: (res) => (res.code === '200' ? res.data : null),
formatResult: (res) => (res.code == 200 ? res.data : null),
},
);
@@ -126,7 +126,9 @@ export function useReportsModel() {
// ===== 图表数据 =====
const chartData = computed(() => {
const list = data.value?.monthList || [];
return list.map((item) => ({
// 按月份升序排列,确保图表 X 轴时间顺序正确
const sorted = [...list].sort((a, b) => a.month.localeCompare(b.month));
return sorted.map((item) => ({
month: dayjs(item.month).format('MM月'),
order: toNumber(item.orderAmount),
refund: toNumber(item.refundAmount),
@@ -12,6 +12,7 @@ import {
TRANSACTION_STATUS_OPTIONS,
TRANSACTION_TYPE_MAP,
TRANSACTION_STATUS_LABEL_MAP,
renderTransactionStatus,
} from '../model/useWalletModel';
import styles from './WalletDetailModal.module.less';
@@ -66,7 +67,7 @@ export default defineComponent({
try {
const params = buildQueryParams();
const res = await getWalletTransactionList(params);
if (res.code === '200') {
if (res.code == 200) {
dataSource.value = res.data.list;
pagination.total = res.data.total;
}
@@ -151,6 +152,13 @@ export default defineComponent({
align: 'left' as const,
customRender: ({ text }: { text: string }) => `¥${text || '0.00'}`,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 90,
customRender: ({ text }: { text: number }) => renderTransactionStatus(text),
},
{
title: '冻结时间',
dataIndex: 'freezeTime',
+2 -1
View File
@@ -160,7 +160,8 @@ export default defineComponent({
placeholder="请输入"
style={{ width: '140px' }}
value={filterForm.minBalance || undefined}
precision={0}
precision={2}
step={0.01}
min={0}
controls={false}
onUpdate:value={(val: any) =>
@@ -1,7 +1,7 @@
import { computed, reactive, toRef, Ref, nextTick } from 'vue';
import { computed, reactive, nextTick, h } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import {
useState,
useDebounce,
useThrottleFn,
useRequest,
usePagination,
@@ -37,13 +37,29 @@ export const TRANSACTION_TYPE_MAP: Record<number, string> = {
3: '取消报名退款',
};
/** 交易状态文案映射(弹窗内表格渲染用) */
/** 交易状态文案映射(筛选下拉用,对应请求参数,值与接口返回一致) */
export const TRANSACTION_STATUS_LABEL_MAP: Record<number, string> = {
1: '已解冻',
2: '冻结中',
3: '正常',
};
/** 交易状态色调映射(表格 StatusTag 显示用,对应接口返回的 status 值:1=已解冻,2=冻结中,3=正常) */
const TRANSACTION_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
1: { label: '已解冻', tone: 'success' },
2: { label: '冻结中', tone: 'warning' },
3: { label: '正常', tone: 'default' },
};
/** 渲染交易状态 StatusTag(表格列用) */
export function renderTransactionStatus(status: number) {
const info = TRANSACTION_STATUS_MAP[status] || {
label: String(status ?? '--'),
tone: 'default' as const,
};
return h(StatusTag, { label: info.label, tone: info.tone });
}
// ============================================================
// Model
// ============================================================
@@ -57,15 +73,6 @@ export function useWalletModel() {
maxBalance: '',
});
const { debouncedValue: debouncedNickname } = useDebounce(
toRef(filterForm, 'nickname') as Ref<string>,
{ delay: 300 },
);
const { debouncedValue: debouncedPhone } = useDebounce(
toRef(filterForm, 'phone') as Ref<string>,
{ delay: 300 },
);
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
@@ -83,8 +90,8 @@ export function useWalletModel() {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (filterForm.nickname.trim()) params.nickname = filterForm.nickname.trim();
if (filterForm.phone.trim()) params.phone = filterForm.phone.trim();
if (filterForm.minBalance) params.amountBegin = filterForm.minBalance;
if (filterForm.maxBalance) params.amountEnd = filterForm.maxBalance;
return params;
@@ -150,8 +157,8 @@ export function useWalletModel() {
// ===== 计算属性 =====
const hasFilter = computed(() => {
return (
debouncedNickname.value.trim() !== '' ||
debouncedPhone.value.trim() !== '' ||
filterForm.nickname.trim() !== '' ||
filterForm.phone.trim() !== '' ||
filterForm.minBalance !== '' ||
filterForm.maxBalance !== ''
);
@@ -159,10 +166,10 @@ export function useWalletModel() {
// ===== 方法 =====
const handleSearch = () => {
const handleSearch = useThrottleFn(() => {
pagination.reset();
fetchList();
};
}, 300);
const handleReset = useThrottleFn(async () => {
filterForm.nickname = '';
@@ -96,16 +96,12 @@
min-width: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 13px;
font-weight: 600;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
}
.descValueBold {
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
}
.auditImgList {
display: flex;
gap: 5px;
@@ -147,9 +143,14 @@
}
// ===== 上传 =====
.uploader {
.uploadWrapper {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.uploader {
:global {
.ant-upload-list-picture-card-container {
width: 96px;
@@ -163,6 +164,21 @@
}
}
.pasteTip {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #e6f7ff;
border-radius: 4px;
color: #1677ff;
font-size: 12px;
span {
line-height: 1.5;
}
}
.uploadTrigger {
display: flex;
flex-direction: column;
@@ -1,8 +1,8 @@
import { defineComponent, reactive, ref } from 'vue';
import { defineComponent, reactive, ref, onMounted, onUnmounted } from 'vue';
import { useEffect } from '@/hooks';
import { Modal, Radio, Input, Button, Upload, Image, Spin, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { PlusOutlined, CopyOutlined } from '@ant-design/icons-vue';
import { uploadFile, OssUploadType } from '@/utils/oss';
import { getWithdrawInfo, postWithdrawAudit, type WithdrawInfoVO } from '../model/services';
import { getAuditStatusText, getPayStatusText } from '../model/useWithdrawModel';
@@ -41,6 +41,34 @@ export default defineComponent({
const auditForm = reactive({ pass: true, remark: '' });
const imageList = ref<UploadFile[]>([]);
const submitting = ref(false);
const uploadAreaRef = ref<HTMLElement | null>(null);
// ===== 粘贴图片上传 =====
const handlePaste = async (e: ClipboardEvent) => {
if (!props.visible || !uploadAreaRef.value) return;
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.indexOf('image') !== -1) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
await beforeUpload(file as unknown as UploadFile);
}
}
}
};
onMounted(() => {
document.addEventListener('paste', handlePaste);
});
onUnmounted(() => {
document.removeEventListener('paste', handlePaste);
});
// ===== 拉取详情 =====
const fetchDetail = async (id: number) => {
@@ -48,7 +76,7 @@ export default defineComponent({
detail.value = null;
try {
const res = await getWithdrawInfo(id);
if (res.code === '200') detail.value = res.data;
if (res.code == 200) detail.value = res.data;
} catch {
// 网络层已统一提示
} finally {
@@ -136,7 +164,7 @@ export default defineComponent({
checkMsg: auditForm.remark.trim(),
checkImg,
});
if (res.code === '200') {
if (res.code == 200) {
message.success(auditForm.pass ? '已审核通过' : '已审核驳回');
props.onAudited?.();
props.onClose();
@@ -150,17 +178,17 @@ export default defineComponent({
}
};
const renderItem = (label: string, value: any, isBold = false) => (
const renderItem = (label: string, value: any) => (
<div class={styles.descItem}>
<span class={styles.descLabel}>{label}</span>
<span class={[styles.descValue, isBold ? styles.descValueBold : '']}>{value || '--'}</span>
<span class={styles.descValue}>{value || '--'}</span>
</div>
);
/** 渲染内容区(加载完成后) */
const renderContent = () => {
const d = detail.value!;
const bankCardTypeLabel = d.bankCardType === 1 ? '个人' : d.bankCardType || '--';
const bankCardTypeLabel = d.bankCardType == 1 ? '个人' : d.bankCardType || '--';
const isPending = d.auditStatus == 0;
return (
@@ -175,12 +203,12 @@ export default defineComponent({
{renderItem('申请时间:', d.createDate)}
{renderItem('打款类型:', d.withdrawType)}
<div style="display: contents;" />
{renderItem('提现金额:', `¥${d.withdrawAmount}`, true)}
{renderItem('提现金额:', `¥${d.withdrawAmount}`)}
{renderItem('费率:', `${(Number(d.feeRate) * 100).toFixed(2)}%`)}
{renderItem('手续费:', `¥${d.feeAmount}`)}
{renderItem('到账金额:', `¥${d.receivedAmount}`, true)}
{renderItem('审核状态:', getAuditStatusText(d.auditStatus), true)}
{renderItem('列账状态:', getPayStatusText(d.payStatus))}
{renderItem('到账金额:', `¥${d.receivedAmount}`)}
{renderItem('审核状态:', getAuditStatusText(d.auditStatus))}
{renderItem('到账状态:', getPayStatusText(d.payStatus))}
</div>
</div>
@@ -239,21 +267,27 @@ export default defineComponent({
{auditForm.pass && <span class={styles.required}>*</span>}
审核图片
</span>
<Upload
listType="picture-card"
fileList={imageList.value}
beforeUpload={beforeUpload}
onRemove={handleRemove}
accept="image/*"
class={styles.uploader}
>
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
<div class={styles.uploadTrigger}>
<PlusOutlined />
<div class={styles.uploadText}>请选择图片</div>
</div>
)}
</Upload>
<div ref={uploadAreaRef} class={styles.uploadWrapper}>
<Upload
listType="picture-card"
fileList={imageList.value}
beforeUpload={beforeUpload}
onRemove={handleRemove}
accept="image/*"
class={styles.uploader}
>
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
<div class={styles.uploadTrigger}>
<PlusOutlined />
<div class={styles.uploadText}>选择或粘贴图片</div>
</div>
)}
</Upload>
<div class={styles.pasteTip}>
<CopyOutlined />
<span>支持 Ctrl+V 粘贴图片</span>
</div>
</div>
</div>
<div class={styles.footer}>
<Button type="primary" onClick={handleSubmit} loading={submitting.value}>
@@ -267,6 +301,7 @@ export default defineComponent({
<div class={styles.infoColumn}>
{renderItem('审核时间:', d.auditDate)}
{renderItem('审核人:', d.auditNickname)}
{d.checkMsg && renderItem('审核内容:', d.checkMsg)}
<div class={styles.descItem}>
<span class={styles.descLabel}>审核图片:</span>
{d.auditImgList?.length ? (
@@ -1,8 +1,7 @@
import { computed, reactive, toRef, Ref, h, nextTick } from 'vue';
import { computed, reactive, h, nextTick } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import {
useState,
useDebounce,
useThrottleFn,
useRequest,
usePagination,
@@ -87,20 +86,6 @@ export function useWithdrawModel() {
payStatus: '',
});
// 可搜索字段防抖
const { debouncedValue: debouncedNickname } = useDebounce(
toRef(filterForm, 'nickname') as Ref<string>,
{ delay: 300 },
);
const { debouncedValue: debouncedPhone } = useDebounce(
toRef(filterForm, 'phone') as Ref<string>,
{ delay: 300 },
);
const { debouncedValue: debouncedRealName } = useDebounce(
toRef(filterForm, 'realName') as Ref<string>,
{ delay: 300 },
);
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
@@ -110,9 +95,9 @@ export function useWithdrawModel() {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (debouncedNickname.value.trim()) params.nickname = debouncedNickname.value.trim();
if (debouncedPhone.value.trim()) params.phone = debouncedPhone.value.trim();
if (debouncedRealName.value.trim()) params.realName = debouncedRealName.value.trim();
if (filterForm.nickname.trim()) params.nickname = filterForm.nickname.trim();
if (filterForm.phone.trim()) params.phone = filterForm.phone.trim();
if (filterForm.realName.trim()) params.realName = filterForm.realName.trim();
if (filterForm.auditStatus) params.auditStatus = filterForm.auditStatus;
if (filterForm.payStatus) params.payStatus = filterForm.payStatus;
return params;
@@ -169,9 +154,9 @@ export function useWithdrawModel() {
// ===== 计算属性 =====
const hasFilter = computed(() => {
return (
debouncedNickname.value.trim() !== '' ||
debouncedPhone.value.trim() !== '' ||
debouncedRealName.value.trim() !== '' ||
filterForm.nickname.trim() !== '' ||
filterForm.phone.trim() !== '' ||
filterForm.realName.trim() !== '' ||
filterForm.auditStatus !== '' ||
filterForm.payStatus !== ''
);
@@ -179,10 +164,10 @@ export function useWithdrawModel() {
// ===== 方法 =====
const handleSearch = () => {
const handleSearch = useThrottleFn(() => {
pagination.reset();
fetchList();
};
}, 300);
/** 重置(重置后自动查询) */
const handleReset = useThrottleFn(async () => {
+2 -2
View File
@@ -130,7 +130,7 @@ export default defineComponent({
</div>
</div>
<div class={styles.brandFooter}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</div>
{/* <div class={styles.brandFooter}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</div> */}
</div>
</div>
@@ -186,7 +186,7 @@ export default defineComponent({
</FormItem>
</Form>
<div class={styles.loginFooter}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</div>
{/* <div class={styles.loginFooter}>六个羽友赛事运营平台 ©{new Date().getFullYear()}</div> */}
</div>
</div>
</div>
+1 -1
View File
@@ -69,7 +69,7 @@ export function useLoginModel() {
try {
const res = await login({ phone: form.phone, password: form.password });
if (res.code !== '200') {
if (res.code != 200) {
message.error(res.msg || '登录失败');
return;
}
@@ -0,0 +1,21 @@
.form {
padding-top: 8px;
:global(.ant-form-item) {
margin-bottom: 16px;
}
}
.tip {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 0;
}
@@ -0,0 +1,187 @@
import { defineComponent, reactive } from 'vue';
import { Modal, Form, Input, InputNumber, Switch, Button, message } from 'ant-design-vue';
import { useEffect } from '@/hooks/useEffect';
import type { AiAgentConfigVO, AiAgentUpdateParams } from '../model/services';
import { MIN_MULTIPLIER, MAX_FREE_POINTS } from '../model/config';
import styles from './AiAgentEditModal.module.less';
const { TextArea } = Input;
interface AiAgentEditModalProps {
visible: boolean;
record: AiAgentConfigVO | null;
submitting: boolean;
onClose: () => void;
onSubmit: (params: AiAgentUpdateParams) => void;
}
const DEFAULT_FORM = {
enabled: false,
billingEnabled: false,
billingMultiplier: 1,
dailyFreePoints: 0,
descr: '',
};
/**
* AI 场景配置编辑弹窗
*
* 字段:业务开关 / 计费开关 / 计费倍率 / 每日免费额度 / 备注
* 注意:场景名称和业务类型不可编辑(由代码注册)
*/
export default defineComponent({
name: 'AiAgentEditModal',
props: {
visible: { type: Boolean, default: false },
record: { type: Object, default: null },
submitting: { type: Boolean, default: false },
onClose: { type: Function, default: null },
onSubmit: { type: Function, default: null },
},
setup(props: AiAgentEditModalProps) {
const formState = reactive({ ...DEFAULT_FORM });
/** 根据 record 初始化表单(与项目其他弹窗保持一致的写法) */
const initFormFromRecord = (record: AiAgentConfigVO | null) => {
if (record) {
Object.assign(formState, {
enabled: String(record.enabled) === '1',
billingEnabled: String(record.billingEnabled) === '1',
billingMultiplier: Number(record.billingMultiplier) || 1,
dailyFreePoints: Number(record.dailyFreePoints) || 0,
descr: record.descr || '',
});
} else {
Object.assign(formState, DEFAULT_FORM);
}
};
// 弹窗打开时回填表单(useEffect 内置 immediate,首次挂载也会执行)
useEffect(() => {
if (props.visible) {
initFormFromRecord(props.record);
}
}, [() => props.visible]);
const handleSubmit = () => {
// 校验
if (formState.billingMultiplier < MIN_MULTIPLIER) {
message.warning(`计费倍率必须大于 ${MIN_MULTIPLIER}`);
return;
}
if (formState.dailyFreePoints < 0 || formState.dailyFreePoints > MAX_FREE_POINTS) {
message.warning(`每日免费额度范围为 0 ~ ${MAX_FREE_POINTS}`);
return;
}
// 构造提交参数(对齐文档 POST /admin/manager/ai/agent/update Body 格式)
const params: AiAgentUpdateParams = {
id: props.record!.id,
enabled: formState.enabled ? 1 : 0,
billingEnabled: formState.billingEnabled ? 1 : 0,
billingMultiplier: formState.billingMultiplier,
dailyFreePoints: formState.dailyFreePoints,
descr: formState.descr.trim(),
};
props.onSubmit?.(params);
};
return () => (
<Modal
visible={props.visible}
onCancel={props.onClose}
title="编辑场景配置"
width={520}
centered
destroyOnClose
footer={null}
>
<Form layout="vertical" class={styles.form}>
{/* 场景信息(只读) */}
<Form.Item label="场景名称">
<Input value={props.record?.name || ''} disabled />
</Form.Item>
<Form.Item label="业务类型">
<Input value={props.record?.bizType || ''} disabled />
</Form.Item>
{/* 业务开关 */}
<Form.Item label="业务开关">
<Switch
checked={formState.enabled}
onChange={(checked: boolean | string | number) => (formState.enabled = !!checked)}
checkedChildren="开启"
unCheckedChildren="关闭"
/>
</Form.Item>
{/* 计费开关 */}
<Form.Item label="计费开关">
<Switch
checked={formState.billingEnabled}
onChange={(checked: boolean | string | number) =>
(formState.billingEnabled = !!checked)
}
checkedChildren="计费"
unCheckedChildren="免费"
/>
</Form.Item>
{/* 计费倍率 */}
<Form.Item label="计费倍率" required>
<InputNumber
value={formState.billingMultiplier}
onChange={(value: any) =>
(formState.billingMultiplier = typeof value === 'number' ? value : 1)
}
min={MIN_MULTIPLIER}
step={0.1}
precision={2}
style={{ width: '100%' }}
/>
<div class={styles.tip}>倍率必须大于 0,如 1.0 = 原价,1.5 = 1.5 倍计费</div>
</Form.Item>
{/* 每日免费额度 */}
<Form.Item label="每日免费额度">
<InputNumber
value={formState.dailyFreePoints}
onChange={(value: any) =>
(formState.dailyFreePoints = typeof value === 'number' ? value : 0)
}
min={0}
max={MAX_FREE_POINTS}
step={1}
precision={0}
style={{ width: '100%' }}
/>
<div class={styles.tip}>每日免费结算条数,0 = 无免费额度</div>
</Form.Item>
{/* 备注 */}
<Form.Item label="运营备注">
<TextArea
value={formState.descr}
onUpdate:value={(val: any) => (formState.descr = val?.target?.value ?? val ?? '')}
placeholder="请输入备注"
rows={3}
maxlength={200}
showCount
/>
</Form.Item>
{/* 底部按钮 */}
<Form.Item>
<div class={styles.footer}>
<Button onClick={props.onClose}>取消</Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
保存
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
},
});
@@ -0,0 +1,277 @@
// 外层容器:撑满 Tab 面板,纵向 flex
.container {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
// 内容滚动区
.wrapper {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 24px 16px;
background: #fff;
}
// ===== 顶部说明栏 =====
.infoBar {
display: flex;
align-items: flex-start;
gap: 8px;
flex-shrink: 0;
margin-bottom: 24px;
padding: 12px 16px;
background: #f0f5ff;
border-radius: 8px;
border-left: 3px solid #1677ff;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
}
.infoIcon {
flex-shrink: 0;
margin-top: 2px;
color: #1677ff;
font-size: 15px;
}
// ===== 分组容器 =====
.group {
margin-bottom: 28px;
}
.groupHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 0;
padding: 12px 16px;
background: #fafafa;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #f0f0f0;
}
// 左侧彩色竖线
.groupBar {
width: 3px;
height: 16px;
border-radius: 2px;
flex-shrink: 0;
}
.groupName {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
white-space: nowrap;
}
.groupDesc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
flex: 1;
min-width: 0;
}
.hint {
font-size: 12px;
color: #ff4d4f;
white-space: nowrap;
}
// ===== 表单项表格 =====
.itemTable {
border: 1px solid #f0f0f0;
border-top: none;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
// 表头
.itemHead {
display: flex;
align-items: center;
padding: 0 16px;
height: 40px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
font-weight: 500;
}
.headLabel {
width: 200px;
flex-shrink: 0;
padding-left: 4px;
}
.headValue {
flex: 1;
min-width: 0;
}
.headAction {
width: 60px;
flex-shrink: 0;
text-align: center;
}
// 表行
.formItem {
display: flex;
align-items: center;
padding: 10px 16px;
min-height: 56px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: #fafcff;
}
}
// 左侧:label + 标签
.itemLeft {
flex-shrink: 0;
width: 200px;
display: flex;
align-items: center;
gap: 6px;
padding-left: 4px;
}
.required {
color: #ff4d4f;
font-size: 14px;
font-weight: bold;
line-height: 1;
flex-shrink: 0;
}
.itemLabel {
font-family: 'SF Mono', 'Courier New', Consolas, monospace;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 中间:输入控件
.itemRight {
flex: 1;
min-width: 0;
padding-right: 16px;
}
.input {
width: 100%;
max-width: 480px;
}
.itemRemark {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
line-height: 1.4;
max-width: 480px;
}
.requiredTip {
color: #ff4d4f;
}
// 右侧:操作
.itemAction {
flex-shrink: 0;
width: 60px;
text-align: center;
}
// ===== 底部操作栏(固定占满底部宽度) =====
.footer {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
.tip {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-right: auto;
line-height: 1.5;
}
// ===== 空状态 =====
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
}
// ===== 模型单价区域 =====
.priceSection {
margin-bottom: 28px;
}
.priceHeader {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #fafafa;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #f0f0f0;
}
.priceBar {
width: 3px;
height: 16px;
border-radius: 2px;
flex-shrink: 0;
}
.priceCell {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
}
.priceLoadingRow,
.priceEmptyRow {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.priceTip {
margin-top: 8px;
padding: 8px 12px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
background: #f6f0ff;
border-radius: 0 0 8px 8px;
border: 1px solid #f0f0f0;
border-top: none;
}
@@ -0,0 +1,791 @@
import { defineComponent, reactive, ref, computed } from 'vue';
import {
Input,
InputNumber,
Form,
Button,
Spin,
Tag,
Select,
Empty,
message,
Modal,
} from 'ant-design-vue';
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { useEffect } from '@/hooks';
import {
getAiModelDict,
saveAiModelDict,
resetAiModelDict,
getAiBillingPrices,
getDictTypes,
getDictDataList,
saveDictData,
deleteDictData,
AI_BILLING_PRICE_DICT_TYPE,
type AiModelDictVO,
type AiBillingPriceVO,
type SysDictDataVO,
type ApiResult,
} from '../model/services';
import styles from './AiModelDict.module.less';
// 用途组中文名
const PURPOSE_MAP: Record<string, string> = {
chat: '全局默认',
steward: '赛事管家',
extract: '导入提取',
failover: '备用网关',
};
// 用途组竖线颜色
const PURPOSE_BAR_COLOR: Record<string, string> = {
chat: '#1677ff',
steward: '#52c41a',
extract: '#fa8c16',
failover: '#ff4d4f',
};
// 用途组描述
const PURPOSE_DESC: Record<string, string> = {
chat: '对话/工具(全局默认),未单独配置的助手均回落此项',
steward: '赛事管家模型,以查询问答为主,可配轻量高并发模型',
extract: '导入提取+诊断,整组未配置时回落全局默认',
failover: '备用网关,未配置 = 故障转移功能整体关闭',
};
/** label 后缀 → 校验类型 */
const MODEL_SUFFIX = '.model';
const BASEURL_SUFFIX = '.baseUrl';
const THINKING_SUFFIX = '.thinking';
const APIKEY_SUFFIX = '.apiKey';
/** 判断是否为 model 类键(必填) */
const isModelKey = (label: string) => label === 'model' || label.endsWith(MODEL_SUFFIX);
/** 判断是否为 baseUrl 类键(须 http(s) 开头) */
const isBaseUrlKey = (label: string) => label === 'baseUrl' || label.endsWith(BASEURL_SUFFIX);
/** 判断是否为 thinking 类键(取值 on/off/default) */
const isThinkingKey = (label: string) => label === 'thinking' || label.endsWith(THINKING_SUFFIX);
/** 判断是否为 apiKey 类键(敏感,留空=保留原值) */
const isApiKeyKey = (label: string) => label === 'apiKey' || label.endsWith(APIKEY_SUFFIX);
/** thinking 合法取值 */
const THINKING_OPTIONS = ['on', 'off', 'default'];
/** 获取 label 的中文用途描述,用于校验提示 */
const getLabelDesc = (label: string, remark?: string) => remark || label;
/** 单价 label 后缀 */
const INPUT_SUFFIX = '.input';
const OUTPUT_SUFFIX = '.output';
/** 兜底默认模型名 */
const DEFAULT_MODEL = 'default';
export default defineComponent({
name: 'AiModelDict',
setup() {
const loading = ref(false);
const saving = ref(false);
const dictList = ref<AiModelDictVO[]>([]);
const formMap = reactive<Record<string, string>>({});
// ===== 加载数据 =====
const fetchDict = async () => {
loading.value = true;
try {
const res = await getAiModelDict();
if (res.code == 200) {
dictList.value = res.data || [];
// 初始化表单值
dictList.value.forEach((item) => {
formMap[item.label] = item.value || '';
});
}
} catch {
// 网络层已提示
} finally {
loading.value = false;
}
};
useEffect(() => {
fetchDict();
}, []);
const groupedData = computed(() => {
const groups: Record<string, AiModelDictVO[]> = {};
dictList.value.forEach((item) => {
const key = item.purpose || 'chat';
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
return groups;
});
// purpose 排序
const purposeOrder = ['chat', 'steward', 'extract', 'failover'];
// 是否有数据
const hasData = computed(() => dictList.value.length > 0);
const validateForm = (): string[] => {
const errors: string[] = [];
dictList.value.forEach((item) => {
const label = item.label;
const val = (formMap[label] ?? '').trim();
const desc = getLabelDesc(label, item.remark);
// 1. *model 必填
if (isModelKey(label) && !val) {
errors.push(`${desc}不能为空(模型标识必填;如需回落配置文件请用重置)`);
}
// 2. *baseUrl 须 http(s) 开头(填了才校验)
if (isBaseUrlKey(label) && val && !/^https?:\/\//i.test(val)) {
errors.push(`${desc}须以 http:// 或 https:// 开头`);
}
// 3. *thinking 取值 on/off/default(填了才校验)
if (isThinkingKey(label) && val && !THINKING_OPTIONS.includes(val)) {
errors.push(`${desc}取值只能为 on / off / default`);
}
});
return errors;
};
const handleSave = async () => {
// 前端校验
const errors = validateForm();
if (errors.length > 0) {
// 逐条提示,重点展示第一个错误
message.warning(errors[0]);
return;
}
saving.value = true;
try {
// 构造提交参数:apiKey 类含掩码 **** 的传空=保留原值,其余原样提交
const items = dictList.value.map((item) => {
const val = formMap[item.label] ?? '';
// 敏感键:如果值包含掩码 ****,说明未修改,传空让后端保留原值
if (isApiKeyKey(item.label) && val.includes('****')) {
return { label: item.label, value: '' };
}
return { label: item.label, value: val };
});
const res = await saveAiModelDict({ items });
if (res && res.code == 200) {
message.success('已保存(最长 30 秒生效)');
fetchDict();
} else {
message.error(res?.msg || '保存失败');
}
} catch (err) {
console.error('[AiModelDict] saveAiModelDict error:', err);
message.error('保存失败');
} finally {
saving.value = false;
}
};
const handleReset = (label: string, remark: string) => {
Modal.confirm({
title: '确认重置',
content: `确认重置"${remark || label}"吗?重置后该键将回落后一级配置。`,
okText: '确认重置',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const res = await resetAiModelDict({ labels: [label] });
if (res.code == 200) {
message.success(res.msg || '已重置(最长 30 秒生效)');
fetchDict();
} else {
message.error(res.msg || '重置失败');
}
} catch {
message.error('重置失败');
}
},
});
};
const priceLoading = ref(false);
const priceSaving = ref(false);
/** 生效价列表(来自 billing/prices,仅展示用) */
const priceList = ref<AiBillingPriceVO[]>([]);
/** 字典数据行列表(来自 dict/data/list,含 id,用于保存/删除) */
const priceDictRows = ref<SysDictDataVO[]>([]);
/** ai_billing_price 的 dictTypeId */
const priceDictTypeId = ref<number>(0);
/** 编辑态:key = model 名,value = { inputPrice, outputPrice } */
const priceFormMap = reactive<Record<string, { inputPrice: string; outputPrice: string }>>({});
/** 新增模型弹窗 */
const addModalVisible = ref(false);
const newModelName = ref('');
/** 从 dictLabel 提取 model 名(去掉 .input / .output 后缀) */
const extractModelName = (dictLabel: string): string => {
if (dictLabel.endsWith(INPUT_SUFFIX)) return dictLabel.slice(0, -INPUT_SUFFIX.length);
if (dictLabel.endsWith(OUTPUT_SUFFIX)) return dictLabel.slice(0, -OUTPUT_SUFFIX.length);
return dictLabel;
};
/** 加载单价列表:先查 types 拿 dictTypeId,再查 data/list 拿完整行,最后查 billing/prices 拿生效价 */
const fetchPrices = async () => {
priceLoading.value = true;
try {
// 1. 查字典类型,找到 ai_billing_price 的 dictTypeId
const typesRes = await getDictTypes();
if (typesRes.code != 200 || !typesRes.data) {
priceLoading.value = false;
return;
}
const dictType = typesRes.data.find((t) => t.dictType === AI_BILLING_PRICE_DICT_TYPE);
if (!dictType) {
message.warning(`未找到字典类型 ${AI_BILLING_PRICE_DICT_TYPE},请先在通用字典页创建`);
priceLoading.value = false;
return;
}
priceDictTypeId.value = dictType.id;
// 2. 查该类型下全部数据行(含 id)
const dataRes = await getDictDataList(dictType.id);
if (dataRes.code == 200 && dataRes.data) {
priceDictRows.value = dataRes.data;
}
// 3. 查生效价(展示用)
const priceRes = await getAiBillingPrices();
if (priceRes.code == 200 && priceRes.data) {
priceList.value = priceRes.data;
}
// 4. 构建编辑表单:以字典数据行为主,提取 model 名和单价
const modelSet = new Set<string>();
priceDictRows.value.forEach((row) => {
const modelName = extractModelName(row.dictLabel);
modelSet.add(modelName);
});
// 也把生效价中出现但字典没有的模型纳入(漏配模型)
priceList.value.forEach((item) => modelSet.add(item.model));
// 清空旧表单数据
Object.keys(priceFormMap).forEach((key) => delete priceFormMap[key]);
// 构建表单:优先用生效价,其次用字典行
modelSet.forEach((modelName) => {
const inputRow = priceDictRows.value.find(
(r) => r.dictLabel === `${modelName}${INPUT_SUFFIX}`,
);
const outputRow = priceDictRows.value.find(
(r) => r.dictLabel === `${modelName}${OUTPUT_SUFFIX}`,
);
const priceItem = priceList.value.find((p) => p.model === modelName);
priceFormMap[modelName] = {
inputPrice: inputRow
? inputRow.dictValue
: priceItem
? String(priceItem.inputPrice ?? '')
: '',
outputPrice: outputRow
? outputRow.dictValue
: priceItem
? String(priceItem.outputPrice ?? '')
: '',
};
});
} catch {
// 网络层已提示
} finally {
priceLoading.value = false;
}
};
useEffect(() => {
fetchPrices();
}, []);
/** 排序后的模型列表(default 排最前) */
const sortedModels = computed(() => {
const models = Object.keys(priceFormMap);
return models.sort((a, b) => {
if (a === DEFAULT_MODEL) return -1;
if (b === DEFAULT_MODEL) return 1;
return a.localeCompare(b);
});
});
/** 保存某模型的输入单价 */
const handleSaveInputPrice = async (model: string) => {
const val = priceFormMap[model]?.inputPrice ?? '';
const num = Number(val);
if (val === '' || Number.isNaN(num) || num < 0) {
message.warning('请输入有效的单价(≥0)');
return;
}
priceSaving.value = true;
try {
const existingRow = priceDictRows.value.find(
(r) => r.dictLabel === `${model}${INPUT_SUFFIX}`,
);
const res = await saveDictData({
id: existingRow?.id,
dictTypeId: priceDictTypeId.value,
dictLabel: `${model}${INPUT_SUFFIX}`,
dictValue: val,
sort: existingRow?.sort ?? 0,
remark: existingRow?.remark ?? '',
});
if (res.code == 200) {
message.success(`${model} 输入单价已保存(最长 30 秒生效)`);
fetchPrices();
} else {
message.error(res.msg || '保存失败');
}
} catch {
message.error('保存失败');
} finally {
priceSaving.value = false;
}
};
/** 保存某模型的输出单价 */
const handleSaveOutputPrice = async (model: string) => {
const val = priceFormMap[model]?.outputPrice ?? '';
const num = Number(val);
if (val === '' || Number.isNaN(num) || num < 0) {
message.warning('请输入有效的单价(≥0)');
return;
}
priceSaving.value = true;
try {
const existingRow = priceDictRows.value.find(
(r) => r.dictLabel === `${model}${OUTPUT_SUFFIX}`,
);
const res = await saveDictData({
id: existingRow?.id,
dictTypeId: priceDictTypeId.value,
dictLabel: `${model}${OUTPUT_SUFFIX}`,
dictValue: val,
sort: existingRow?.sort ?? 0,
remark: existingRow?.remark ?? '',
});
if (res.code == 200) {
message.success(`${model} 输出单价已保存(最长 30 秒生效)`);
fetchPrices();
} else {
message.error(res.msg || '保存失败');
}
} catch {
message.error('保存失败');
} finally {
priceSaving.value = false;
}
};
/** 删除某模型的单条单价(按数据行 id 删除) */
const handleDeletePrice = (model: string, type: 'input' | 'output') => {
const suffix = type === 'input' ? INPUT_SUFFIX : OUTPUT_SUFFIX;
const row = priceDictRows.value.find((r) => r.dictLabel === `${model}${suffix}`);
if (!row) {
message.warning(`未找到 ${model}${suffix} 的字典数据行,可能尚未配置`);
return;
}
Modal.confirm({
title: '确认重置',
content: `确认重置 ${model} 的${type === 'input' ? '输入' : '输出'}单价吗?重置后回落后一级配置。`,
okText: '确认重置',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteDictData(row.id);
if (res.code == 200) {
message.success(res.msg || '已重置(最长 30 秒生效)');
fetchPrices();
} else {
message.error(res.msg || '重置失败');
}
} catch {
message.error('重置失败');
}
},
});
};
/** 打开新增模型弹窗 */
const openAddModal = () => {
newModelName.value = '';
addModalVisible.value = true;
};
/** 确认新增模型单价 */
const handleAddModel = async () => {
const name = newModelName.value.trim();
if (!name) {
message.warning('请输入模型名称');
return;
}
// 检查是否已存在
if (Object.keys(priceFormMap).includes(name)) {
message.warning(`模型 ${name} 已存在`);
return;
}
if (!priceDictTypeId.value) {
message.error('字典类型未就绪,请刷新页面重试');
return;
}
// 初始化输入/输出单价为 0(通过字典 data/save 写入)
priceSaving.value = true;
try {
const [inputRes, outputRes] = await Promise.all([
saveDictData({
dictTypeId: priceDictTypeId.value,
dictLabel: `${name}${INPUT_SUFFIX}`,
dictValue: '0',
}),
saveDictData({
dictTypeId: priceDictTypeId.value,
dictLabel: `${name}${OUTPUT_SUFFIX}`,
dictValue: '0',
}),
]);
if (inputRes.code == 200 && outputRes.code == 200) {
message.success(`已新增模型 ${name} 单价(最长 30 秒生效)`);
addModalVisible.value = false;
fetchPrices();
} else {
message.error(inputRes.msg || outputRes.msg || '新增失败');
}
} catch {
message.error('新增失败');
} finally {
priceSaving.value = false;
}
};
return () => {
const groups = groupedData.value;
return (
<div class={styles.container}>
<div class={styles.wrapper}>
{loading.value ? (
<div class={styles.emptyState}>
<Spin size="large" />
</div>
) : !hasData.value ? (
<div class={styles.emptyState}>
<Empty description="暂无字典数据" />
</div>
) : (
<>
<div class={styles.infoBar}>
<InfoCircleOutlined class={styles.infoIcon} />
<span>
模型/网关/Key 运行时热切换配置,共 16 个白名单键。敏感键留空 = 保留原值;
<span style={{ color: '#ff4d4f' }}>model 类必填</span>;保存后最长 30 秒生效。
</span>
</div>
{purposeOrder.map((purpose) => {
const items = groups[purpose];
if (!items || items.length === 0) return null;
const barColor = PURPOSE_BAR_COLOR[purpose] || '#1677ff';
return (
<div key={purpose} class={styles.group}>
{/* 分组头部 */}
<div class={styles.groupHeader}>
<span class={styles.groupBar} style={{ backgroundColor: barColor }} />
<span class={styles.groupName}>{PURPOSE_MAP[purpose] || purpose}</span>
<span class={styles.groupDesc}>{PURPOSE_DESC[purpose] || ''}</span>
{purpose === 'failover' && (
<span class={styles.hint}>未配置 = 整体关闭</span>
)}
</div>
{/* 表格容器 */}
<div class={styles.itemTable}>
{/* 表头 */}
<div class={styles.itemHead}>
<div class={styles.headLabel}>配置键</div>
<div class={styles.headValue}>配置值</div>
<div class={styles.headAction}>操作</div>
</div>
{/* 表行 */}
{items.map((item) => {
const isModel = isModelKey(item.label);
const isThinking = isThinkingKey(item.label);
const originalVal = item.value || '';
const currentVal = formMap[item.label] ?? '';
const isModified =
currentVal !== originalVal &&
!(isApiKeyKey(item.label) && currentVal.includes('****'));
return (
<div key={item.label} class={styles.formItem}>
{/* 左侧:label + 标签 */}
<div class={styles.itemLeft}>
{isModel && <span class={styles.required}>*</span>}
<span class={styles.itemLabel} title={item.label}>
{item.label}
</span>
{item.sensitive && <Tag color="red">敏感</Tag>}
{isModified && <Tag color="blue">已改</Tag>}
</div>
{/* 中间:输入控件 */}
<div class={styles.itemRight}>
{item.sensitive ? (
<Input.Password
value={formMap[item.label]}
onUpdate:value={(val: string) =>
(formMap[item.label] = val ?? '')
}
placeholder="留空 = 保留原值"
visibilityToggle={true}
class={styles.input}
allowClear
/>
) : isThinking ? (
<Select
value={formMap[item.label] || undefined}
onUpdate:value={(val: any) => (formMap[item.label] = val ?? '')}
placeholder="未配置(跟随全局默认)"
class={styles.input}
allowClear
options={THINKING_OPTIONS.map((v) => ({
value: v,
label: v,
}))}
/>
) : (
<Input
value={formMap[item.label]}
onUpdate:value={(val: string) =>
(formMap[item.label] = val ?? '')
}
placeholder={isModel ? '必填,如 deepseek-flash' : '未配置'}
class={styles.input}
allowClear
/>
)}
<div class={styles.itemRemark}>
{item.remark}
{isModel && (
<span class={styles.requiredTip}> (必填,回落请用重置)</span>
)}
</div>
</div>
{/* 右侧:重置 */}
<div class={styles.itemAction}>
<Button
type="link"
size="small"
danger
onClick={() => handleReset(item.label, item.remark)}
>
重置
</Button>
</div>
</div>
);
})}
</div>
</div>
);
})}
</>
)}
<div class={styles.priceSection}>
<div class={styles.priceHeader}>
<span class={styles.priceBar} style={{ backgroundColor: '#722ed1' }} />
<span class={styles.groupName}>模型计费单价</span>
<span class={styles.groupDesc}>
单价 = 点/千 token;字典类型 ai_billing_price,label 契约:{'{model}.input'} /{' '}
{'{model}.output'}
</span>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={openAddModal}
style={{ marginLeft: 'auto' }}
>
新增模型
</Button>
</div>
<div class={styles.itemTable}>
{/* 表头 */}
<div class={styles.itemHead}>
<div class={styles.headLabel}>模型</div>
<div class={styles.headValue}>输入单价(点/千token)</div>
<div class={styles.headValue}>输出单价(点/千token)</div>
<div class={styles.headAction}>操作</div>
</div>
{/* 数据行 */}
{priceLoading.value ? (
<div class={styles.priceLoadingRow}>
<Spin />
</div>
) : sortedModels.value.length === 0 ? (
<div class={styles.priceEmptyRow}>
<Empty description="暂无单价数据" imageStyle={{ height: '48px' }} />
</div>
) : (
sortedModels.value.map((modelName) => {
const isDefault = modelName === DEFAULT_MODEL;
return (
<div key={modelName} class={styles.formItem}>
{/* 模型名 */}
<div class={styles.itemLeft}>
<span class={styles.itemLabel} title={modelName}>
{modelName}
</span>
{isDefault && <Tag color="purple">兜底</Tag>}
</div>
{/* 输入单价 */}
<div class={styles.priceCell}>
<InputNumber
value={priceFormMap[modelName]?.inputPrice}
onChange={(val: any) => {
if (!priceFormMap[modelName]) {
priceFormMap[modelName] = { inputPrice: '', outputPrice: '' };
}
priceFormMap[modelName].inputPrice =
typeof val === 'number' ? String(val) : '';
}}
min={0}
step={0.1}
precision={4}
placeholder="0"
style={{ width: '100%' }}
/>
<Button
type="link"
size="small"
onClick={() => handleSaveInputPrice(modelName)}
loading={priceSaving.value}
>
保存
</Button>
<Button
type="link"
size="small"
danger
onClick={() => handleDeletePrice(modelName, 'input')}
>
重置
</Button>
</div>
{/* 输出单价 */}
<div class={styles.priceCell}>
<InputNumber
value={priceFormMap[modelName]?.outputPrice}
onChange={(val: any) => {
if (!priceFormMap[modelName]) {
priceFormMap[modelName] = { inputPrice: '', outputPrice: '' };
}
priceFormMap[modelName].outputPrice =
typeof val === 'number' ? String(val) : '';
}}
min={0}
step={0.1}
precision={4}
placeholder="0"
style={{ width: '100%' }}
/>
<Button
type="link"
size="small"
onClick={() => handleSaveOutputPrice(modelName)}
loading={priceSaving.value}
>
保存
</Button>
<Button
type="link"
size="small"
danger
onClick={() => handleDeletePrice(modelName, 'output')}
>
重置
</Button>
</div>
{/* 操作列占位 */}
<div class={styles.itemAction} />
</div>
);
})
)}
</div>
<div class={styles.priceTip}>
生效价回落链:字典 → 配置文件 → 内置默认(输入 0.8 / 输出 2.0);改字典最长 30s 生效
</div>
</div>
</div>
<Modal
visible={addModalVisible.value}
onCancel={() => (addModalVisible.value = false)}
title="新增模型单价"
centered
destroyOnClose
width={420}
footer={null}
>
<Form layout="vertical">
<Form.Item label="模型名称" required>
<Input
value={newModelName.value}
onUpdate:value={(val: string) => (newModelName.value = val ?? '')}
placeholder="如 deepseek-flash"
onPressEnter={handleAddModel}
/>
<div class={styles.tip}>
新增后将初始化输入/输出单价为 0,可稍后编辑。default 模型为兜底默认价。
</div>
</Form.Item>
<Form.Item>
<div class={styles.footer}>
<Button onClick={() => (addModalVisible.value = false)}>取消</Button>
<Button type="primary" loading={priceSaving.value} onClick={handleAddModel}>
确认新增
</Button>
</div>
</Form.Item>
</Form>
</Modal>
{hasData.value && !loading.value && (
<div class={styles.footer}>
<span class={styles.tip}>保存后最长 30 秒生效,无需重启</span>
<Button type="primary" size="middle" onClick={handleSave} loading={saving.value}>
保存配置
</Button>
</div>
)}
</div>
);
};
},
});
@@ -0,0 +1,25 @@
// ===== 高频标记 =====
.highFreqTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: #fff1f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.normalTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background: #f0f0f0;
color: rgba(0, 0, 0, 0.45);
border: 1px solid #d9d9d9;
}
@@ -0,0 +1,368 @@
import { defineComponent, reactive, ref, computed } from 'vue';
import { Button, Table, Space, DatePicker, InputNumber, Radio, Pagination } from 'ant-design-vue';
import { type Dayjs } from 'dayjs';
import { useEffect } from '@/hooks/useEffect';
import { useContainerSize } from '@/hooks';
import {
getAiBillingReport,
getAiFreeformReport,
getAiAgentList,
type AiBillingReportVO,
type AiFreeformReportVO,
type AiAgentConfigVO,
} from '../model/services';
import styles from './BillingReportTab.module.less';
import pageStyles from '@/assets/styles/pageLayout.module.less';
// ===== 工具函数 =====
/** 格式化数字(千分位),兼容后端返回字符串类型 */
const formatNum = (val: unknown): string => {
const n = Number(val);
if (val == null || Number.isNaN(n)) return '-';
return n.toLocaleString('zh-CN');
};
/** 安全的文本渲染:null/undefined/空 → '-' */
const renderText = (text: unknown): string => (text != null && text !== '' ? String(text) : '-');
/** 等宽字体渲染(用于指纹、签名等哈希值) */
const renderMono = (text: string) => (
<span style={{ fontFamily: "'SF Mono', 'Courier New', Consolas, monospace", fontSize: '12px' }}>
{renderText(text)}
</span>
);
// ===== 场景名称映射 =====
const useBizTypeMap = () => {
const map = ref<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await getAiAgentList();
if (res.code == 200 && res.data) {
const m: Record<string, string> = {};
res.data.forEach((item: AiAgentConfigVO) => {
m[item.bizType] = item.name;
});
map.value = m;
}
} catch {
// ignore
}
})();
}, []);
return map;
};
// ===== 组件 =====
export default defineComponent({
name: 'BillingReportTab',
setup() {
type SubView = 'billing' | 'freeform';
const subView = ref<SubView>('billing');
const bizTypeMap = useBizTypeMap();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// ===== 计费报表(前端分页) =====
const billingLoading = ref(false);
const billingData = ref<AiBillingReportVO[]>([]);
const billingFilter = reactive({
startDate: null as Dayjs | null,
endDate: null as Dayjs | null,
});
const billingPage = reactive({ current: 1, pageSize: 20 });
const billingPagedData = computed(() => {
const start = (billingPage.current - 1) * billingPage.pageSize;
return billingData.value.slice(start, start + billingPage.pageSize);
});
const fetchBilling = async () => {
billingLoading.value = true;
try {
const params: Record<string, string> = {};
if (billingFilter.startDate)
params.startDate = billingFilter.startDate.format('YYYY-MM-DD');
if (billingFilter.endDate) params.endDate = billingFilter.endDate.format('YYYY-MM-DD');
const res = await getAiBillingReport(params);
billingData.value = res.code == 200 && res.data ? res.data : [];
} catch {
billingData.value = [];
} finally {
billingLoading.value = false;
}
};
// 组件初始化时立即加载
fetchBilling();
const onBillingReset = () => {
billingFilter.startDate = null;
billingFilter.endDate = null;
billingPage.current = 1;
fetchBilling();
};
const onBillingPageChange = (page: number, pageSize: number) => {
billingPage.current = page;
billingPage.pageSize = pageSize;
};
const billingColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 130,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
(a.date || '').localeCompare(b.date || ''),
defaultSortOrder: 'descend' as const,
},
{
title: '业务场景',
dataIndex: 'bizType',
key: 'bizType',
width: 160,
align: 'center' as const,
customRender: ({ text }: { text: string }) => bizTypeMap.value[text] || text || '-',
},
{
title: '消息数',
dataIndex: 'messageCount',
key: 'messageCount',
width: 120,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
Number(a.messageCount) - Number(b.messageCount),
customRender: ({ text }: { text: number }) => formatNum(text),
},
{
title: '总 Token',
dataIndex: 'totalTokens',
key: 'totalTokens',
width: 140,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
Number(a.totalTokens) - Number(b.totalTokens),
customRender: ({ text }: { text: number }) => formatNum(text),
},
{
title: '消耗积分',
dataIndex: 'totalPoints',
key: 'totalPoints',
width: 140,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
Number(a.totalPoints) - Number(b.totalPoints),
customRender: ({ text }: { text: number }) => (
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>{formatNum(text)}</span>
),
},
];
// ===== 直解报表 =====
const freeformLoading = ref(false);
const freeformData = ref<AiFreeformReportVO[]>([]);
const freeformTop = ref(50);
const freeformLoaded = ref(false);
const fetchFreeform = async () => {
freeformLoading.value = true;
try {
const res = await getAiFreeformReport({ top: freeformTop.value });
freeformData.value = res.code == 200 && res.data ? res.data : [];
} catch {
freeformData.value = [];
} finally {
freeformLoading.value = false;
freeformLoaded.value = true;
}
};
// 切换到直解格式统计时自动加载(首次)
useEffect(() => {
if (subView.value === 'freeform' && !freeformLoaded.value) {
fetchFreeform();
}
}, [subView]);
const onFreeformReset = () => {
freeformTop.value = 50;
fetchFreeform();
};
const freeformColumns = [
{
title: '文档指纹',
dataIndex: 'fingerprint',
key: 'fingerprint',
width: 200,
align: 'left' as const,
ellipsis: true,
customRender: ({ text }: { text: string }) => renderMono(text),
},
{
title: '文档性质',
dataIndex: 'docNature',
key: 'docNature',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string }) => renderText(text),
},
{
title: '信号集',
dataIndex: 'signalSet',
key: 'signalSet',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string }) => renderText(text),
},
{
title: '表头签名',
dataIndex: 'headerSignature',
key: 'headerSignature',
width: 180,
align: 'left' as const,
ellipsis: true,
customRender: ({ text }: { text: string }) => renderMono(text),
},
{
title: '使用次数',
dataIndex: 'useCount',
key: 'useCount',
width: 100,
align: 'center' as const,
sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) =>
Number(a.useCount) - Number(b.useCount),
defaultSortOrder: 'descend' as const,
customRender: ({ text }: { text: number }) => formatNum(text),
},
{
title: '最后使用',
dataIndex: 'lastUsedAt',
key: 'lastUsedAt',
width: 180,
align: 'center' as const,
},
{
title: '频次',
dataIndex: 'highFrequency',
key: 'highFrequency',
width: 100,
align: 'center' as const,
sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) =>
Number(a.highFrequency) - Number(b.highFrequency),
customRender: ({ text }: { text: boolean }) =>
text ? (
<span class={styles.highFreqTag}>高频</span>
) : (
<span class={styles.normalTag}>普通</span>
),
},
];
return () => (
<>
{/* 筛选栏 */}
<div class={pageStyles.filter}>
<Radio.Group
value={subView.value}
onUpdate:value={(val: SubView) => (subView.value = val)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="billing">计费报表</Radio.Button>
<Radio.Button value="freeform">直解格式统计</Radio.Button>
</Radio.Group>
{subView.value === 'billing' ? (
<Space>
<DatePicker
value={billingFilter.startDate ?? undefined}
onUpdate:value={(val: any) => (billingFilter.startDate = val ?? null)}
format="YYYY-MM-DD"
placeholder="开始日期"
allowClear
/>
<DatePicker
value={billingFilter.endDate ?? undefined}
onUpdate:value={(val: any) => (billingFilter.endDate = val ?? null)}
format="YYYY-MM-DD"
placeholder="结束日期"
allowClear
/>
<Button type="primary" onClick={fetchBilling} loading={billingLoading.value}>
查询
</Button>
<Button onClick={onBillingReset}>重置</Button>
</Space>
) : (
<Space>
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.65)' }}>返回行数:</span>
<InputNumber
value={freeformTop.value}
onUpdate:value={(val: any) =>
(freeformTop.value = typeof val === 'number' ? val : 50)
}
min={1}
max={500}
step={10}
precision={0}
placeholder="50"
style={{ width: 100 }}
/>
<Button type="primary" onClick={fetchFreeform} loading={freeformLoading.value}>
查询
</Button>
<Button onClick={onFreeformReset}>重置</Button>
</Space>
)}
</div>
{/* 表格区 */}
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
{subView.value === 'billing' ? (
<Table
columns={billingColumns as any}
dataSource={billingPagedData.value}
loading={billingLoading.value}
rowKey={(record: AiBillingReportVO) => `${record.date}_${record.bizType}`}
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
size="middle"
/>
) : (
<Table
columns={freeformColumns as any}
dataSource={freeformData.value}
loading={freeformLoading.value}
rowKey="fingerprint"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
size="middle"
/>
)}
</div>
{subView.value === 'billing' && (
<div class={pageStyles.pagination}>
<Pagination
current={billingPage.current}
pageSize={billingPage.pageSize}
total={billingData.value.length}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `共 ${total} 条`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={onBillingPageChange}
/>
</div>
)}
</div>
</>
);
},
});
@@ -0,0 +1,21 @@
.form {
padding-top: 8px;
:global(.ant-form-item) {
margin-bottom: 16px;
}
}
.tip {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 0;
}
@@ -0,0 +1,159 @@
import { defineComponent, reactive, watch } from 'vue';
import { Modal, Form, Input, Select, DatePicker, Button, message } from 'ant-design-vue';
import dayjs, { type Dayjs } from 'dayjs';
import { useUserSearch } from '../hooks/useUserSearch';
import type { AiBlacklistAddParams } from '../model/services';
import styles from './BlacklistAddModal.module.less';
interface BlacklistAddModalProps {
visible: boolean;
submitting: boolean;
onClose: () => void;
onSubmit: (params: AiBlacklistAddParams) => void;
}
/**
* 拉黑弹窗
*
* 字段:用户ID / 拉黑原因 / 到期时间(可选,空=永久)
*
* 注意(文档 §1.3 ⑫):
* - userId 必填,建议用用户选择器(当前简化为输入)
* - reason 必填(审计要求)
* - expireDate 可选,空=永久;必须晚于当前时间
*/
export default defineComponent({
name: 'BlacklistAddModal',
props: {
visible: { type: Boolean, default: false },
submitting: { type: Boolean, default: false },
onClose: { type: Function, default: null },
onSubmit: { type: Function, default: null },
},
setup(props: BlacklistAddModalProps) {
const form = reactive({
userId: undefined as number | undefined,
reason: '',
expireDate: null as Dayjs | null,
});
// 用户搜索
const userSearch = useUserSearch();
// 弹窗打开时重置表单
watch(
() => props.visible,
(val) => {
if (val) {
form.userId = undefined;
form.reason = '';
form.expireDate = null;
userSearch.reset();
}
},
);
const handleSubmit = () => {
if (!form.userId || form.userId <= 0) {
message.warning('请搜索并选择用户');
return;
}
if (!form.reason.trim()) {
message.warning('请输入拉黑原因');
return;
}
// 校验到期时间必须晚于当前
if (form.expireDate) {
const now = dayjs();
if (form.expireDate.isBefore(now)) {
message.warning('到期时间必须晚于当前时间');
return;
}
}
const params: AiBlacklistAddParams = {
userId: form.userId,
reason: form.reason.trim(),
};
if (form.expireDate) {
params.expireDate = form.expireDate.format('YYYY-MM-DD HH:mm:ss');
}
props.onSubmit?.(params);
};
return () => (
<Modal
visible={props.visible}
onCancel={props.onClose}
title="拉黑用户"
width={520}
centered
destroyOnClose
footer={null}
>
<Form layout="vertical" class={styles.form}>
<Form.Item label="用户" required>
<Select
showSearch
value={form.userId ? String(form.userId) : undefined}
placeholder="输入手机号搜索用户"
filterOption={false}
loading={userSearch.searching.value}
options={userSearch.options.value as any}
style={{ width: '100%' }}
onSearch={userSearch.onSearch}
onChange={(val: any) => {
form.userId = val ? Number(val) : undefined;
}}
allowClear
notFoundContent={
userSearch.searching.value
? '搜索中...'
: userSearch.keyword.value
? '无匹配用户'
: '请输入手机号搜索'
}
/>
<div class={styles.tip}>搜索手机号选择需要拉黑的用户,展示手机号,传用户ID给后端</div>
</Form.Item>
<Form.Item label="拉黑原因" required>
<Input.TextArea
value={form.reason}
onUpdate:value={(val: string) => (form.reason = val ?? '')}
placeholder="请输入拉黑原因(审计要求必填)"
rows={3}
maxlength={200}
showCount
/>
</Form.Item>
<Form.Item label="到期时间">
<DatePicker
value={form.expireDate ?? undefined}
onUpdate:value={(val: any) => (form.expireDate = val ?? null)}
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="留空 = 永久拉黑"
style={{ width: '100%' }}
disabledDate={(current: Dayjs) => current && current.isBefore(dayjs().startOf('day'))}
/>
<div class={styles.tip}>留空表示永久拉黑;选择日期则到指定时间自动解除</div>
</Form.Item>
<Form.Item>
<div class={styles.footer}>
<Button onClick={props.onClose}>取消</Button>
<Button type="primary" danger loading={props.submitting} onClick={handleSubmit}>
确认拉黑
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
},
});
@@ -0,0 +1,162 @@
import { defineComponent } from 'vue';
import { Button, Table, Space, Select, Pagination } from 'ant-design-vue';
import { useBlacklistModel } from '../model/useBlacklistModel';
import { useBlacklistColumns } from '../model/useBlacklistColumns';
import { BLACKLIST_STATUS_OPTIONS } from '../model/config';
import { useContainerSize } from '@/hooks';
import { useUserSearch } from '../hooks/useUserSearch';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import BlacklistAddModal from './BlacklistAddModal';
import type { AiBlacklistVO } from '../model/services';
/**
* AI 黑名单 Tab 组件
*
* 对接文档 §1.3:
* - 黑名单分页列表
* - 拉黑 / 解除拉黑
*/
export default defineComponent({
name: 'BlacklistTab',
setup() {
const {
filterForm,
loading,
dataSource,
pagination,
addModalVisible,
submitting,
handleSearch,
handleReset,
handlePageChange,
openAddModal,
closeAddModal,
handleSubmitAdd,
handleRemove,
} = useBlacklistModel();
const { columns } = useBlacklistColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// 用户搜索
const userSearch = useUserSearch();
/** 重置(包装:同时清除搜索状态) */
const onReset = () => {
userSearch.reset();
handleReset();
};
/** 渲染操作列 */
const renderAction = (record: AiBlacklistVO) => {
if (record.status === 1) {
return (
<Button type="link" size="small" danger onClick={() => handleRemove(record)}>
解除拉黑
</Button>
);
}
return <span style={{ color: '#999' }}>-</span>;
};
/** 完整表格列 */
const tableColumns = [
...columns,
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as const,
align: 'center' as const,
},
];
return () => (
<>
<div class={pageStyles.filter}>
<Space>
<Select
showSearch
value={filterForm.userId ? String(filterForm.userId) : undefined}
placeholder="手机号搜索用户"
filterOption={false}
loading={userSearch.searching.value}
options={userSearch.options.value as any}
style={{ width: '220px' }}
onSearch={userSearch.onSearch}
onChange={(val: any) => {
filterForm.userId = val ?? '';
}}
allowClear
notFoundContent={
userSearch.searching.value
? '搜索中...'
: userSearch.keyword.value
? '无匹配用户'
: '输入手机号搜索'
}
/>
<Select
value={filterForm.status}
onUpdate:value={(val: any) => (filterForm.status = val ?? '')}
style={{ width: '200px' }}
options={BLACKLIST_STATUS_OPTIONS as any}
/>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
查询
</Button>
<Button onClick={onReset}>重置</Button>
</Space>
<div class={pageStyles.filterActions}>
<Button type="primary" danger onClick={openAddModal}>
拉黑用户
</Button>
</div>
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
columns={tableColumns}
dataSource={dataSource.value}
loading={loading.value}
rowKey="id"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: { column: { key: string }; record: AiBlacklistVO }) => {
if (args.column.key === 'action') {
return renderAction(args.record);
}
return undefined;
},
}}
</Table>
</div>
<div class={pageStyles.pagination}>
<Pagination
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `共 ${total} 条`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={handlePageChange}
/>
</div>
</div>
{addModalVisible.value && (
<BlacklistAddModal
visible={addModalVisible.value}
submitting={submitting.value}
onClose={closeAddModal}
onSubmit={handleSubmitAdd}
/>
)}
</>
);
},
});
@@ -0,0 +1,21 @@
.form {
padding-top: 8px;
:global(.ant-form-item) {
margin-bottom: 16px;
}
}
.tip {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 0;
}
@@ -0,0 +1,167 @@
import { defineComponent, reactive } from 'vue';
import { Modal, Form, InputNumber, Input, Select, Button, message } from 'ant-design-vue';
import { useEffect } from '@/hooks/useEffect';
import { POINTS_ADJUST_TYPE_OPTIONS } from '../model/config';
import { useUserSearch } from '../hooks/useUserSearch';
import type { AiPointsAdjustParams } from '../model/services';
import styles from './PointsAdjustModal.module.less';
interface PointsAdjustModalProps {
visible: boolean;
submitting: boolean;
/** 预设用户信息(从账户行点击调点时带入) */
presetUser?: { userId: number; phone?: string; nickname?: string };
onClose: () => void;
onSubmit: (params: AiPointsAdjustParams) => void;
}
/**
* 手工调点弹窗(充值/纠错)
*
* 对接文档 §1.2 ④:
* - userId 必填,后端不校验真实性,前端建议用用户选择器(当前简化为输入)
* - points 必填,非 0;正=充/赠,负=扣
* - type 可选 1=充值/赠送 2=调账;缺省按正负推导
* - descr 可选,审计备注
*
* ⚠️ 后端自动建户:目标用户无账户时自动创建(0 余额起户后原子加点)
*/
export default defineComponent({
name: 'PointsAdjustModal',
props: {
visible: { type: Boolean, default: false },
submitting: { type: Boolean, default: false },
presetUser: { type: Object, default: undefined },
onClose: { type: Function, default: null },
onSubmit: { type: Function, default: null },
},
setup(props: PointsAdjustModalProps) {
const form = reactive({
userId: undefined as number | undefined,
points: undefined as number | undefined,
type: 1 as number,
descr: '',
});
// 用户搜索
const userSearch = useUserSearch();
useEffect(() => {
if (props.visible) {
form.userId = props.presetUser?.userId ?? undefined;
form.points = undefined;
form.type = 1;
form.descr = '';
userSearch.reset();
if (props.presetUser) {
userSearch.setPresetUser(props.presetUser);
}
}
}, [() => props.visible]);
const handleSubmit = () => {
if (!form.userId || form.userId <= 0) {
message.warning('请搜索并选择用户');
return;
}
if (form.points == null || form.points === 0) {
message.warning('变更积分不能为 0');
return;
}
const params: AiPointsAdjustParams = {
userId: form.userId,
points: form.points,
type: form.type,
};
if (form.descr.trim()) {
params.descr = form.descr.trim();
}
props.onSubmit?.(params);
};
return () => (
<Modal
visible={props.visible}
onCancel={props.onClose}
title="手工调点"
width={520}
centered
destroyOnClose
footer={null}
>
<Form layout="vertical" class={styles.form}>
<Form.Item label="用户" required>
<Select
showSearch
value={form.userId ? String(form.userId) : undefined}
placeholder="输入手机号搜索用户"
filterOption={false}
loading={userSearch.searching.value}
options={userSearch.options.value as any}
disabled={!!props.presetUser}
style={{ width: '100%' }}
onSearch={userSearch.onSearch}
onChange={(val: any) => {
form.userId = val ? Number(val) : undefined;
}}
allowClear
notFoundContent={
userSearch.searching.value
? '搜索中...'
: userSearch.keyword.value
? '无匹配用户'
: '请输入手机号搜索'
}
/>
<div class={styles.tip}>
搜索手机号选择用户,展示手机号,传用户ID给后端;目标用户无账户时系统自动建户
</div>
</Form.Item>
<Form.Item label="变更积分" required>
<InputNumber
value={form.points}
onChange={(val: any) => (form.points = typeof val === 'number' ? val : undefined)}
placeholder="正数=充/赠,负数=扣"
precision={0}
style={{ width: '100%' }}
/>
<div class={styles.tip}>正数 = 充值/赠送;负数 = 扣减;不能为 0</div>
</Form.Item>
<Form.Item label="调点类型" name="type">
<Select
value={form.type}
options={POINTS_ADJUST_TYPE_OPTIONS as any}
style={{ width: '100%' }}
onUpdate:value={(val: any) => (form.type = val ?? 1)}
/>
</Form.Item>
<Form.Item label="审计备注">
<Input.TextArea
value={form.descr}
onUpdate:value={(val: string) => (form.descr = val ?? '')}
placeholder="可选,如:客诉补偿"
rows={3}
maxlength={200}
showCount
/>
</Form.Item>
<Form.Item>
<div class={styles.footer}>
<Button onClick={props.onClose}>取消</Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
确认调点
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
},
});
@@ -0,0 +1,308 @@
import { defineComponent, onMounted, ref } from 'vue';
import { Button, Table, Space, Select, DatePicker, Pagination, Radio } from 'ant-design-vue';
import dayjs from 'dayjs';
import { usePointsModel } from '../model/usePointsModel';
import { usePointsAccountColumns, usePointsTxnColumns } from '../model/usePointsColumns';
import { POINTS_TXN_TYPE_OPTIONS } from '../model/config';
import { useContainerSize } from '@/hooks';
import { useUserSearch } from '../hooks/useUserSearch';
import {
getAiAgentList,
type AiAgentConfigVO,
type AiPointsAccountVO,
type AiPointsTxnVO,
} from '../model/services';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import PointsAdjustModal from './PointsAdjustModal';
/** 用户搜索 Select 的 notFoundContent 公共渲染 */
const renderNotFound = (searching: boolean, keyword: string) =>
searching ? '搜索中...' : keyword ? '无匹配用户' : '输入手机号搜索';
/** 分页组件公共配置 */
const paginationProps = {
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `共 ${total} 条`,
pageSizeOptions: ['10', '20', '50', '100'],
};
/**
* AI 积分账户与流水 Tab 组件
*
* 对接文档 §1.2:
* - 账户分页:GET /admin/manager/ai/points/accounts
* - 手工调点:POST /admin/manager/ai/points/adjust
* - 流水分页:GET /admin/manager/ai/points/transactions
*
* 子视图切换:
* - account:积分账户列表(支持调点 / 查看流水)
* - txn:积分流水列表(支持多维筛选)
*/
export default defineComponent({
name: 'PointsTab',
setup() {
const {
subView,
// 账户
accountFilter,
accountLoading,
accountDataSource,
accountPagination,
handleAccountSearch,
handleAccountReset,
handleAccountPageChange,
handleViewTxn,
// 流水
txnFilter,
txnLoading,
txnDataSource,
txnPagination,
handleTxnSearch,
handleTxnReset,
handleTxnPageChange,
// 调点
adjustModalVisible,
adjusting,
presetUser,
openAdjustModal,
closeAdjustModal,
handleSubmitAdjust,
} = usePointsModel();
const { columns: accountColumns } = usePointsAccountColumns();
const { columns: txnColumns } = usePointsTxnColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// 用户搜索实例
const accountSearch = useUserSearch();
const txnSearch = useUserSearch();
// 场景配置下拉选项(流水筛选用)
const agentOptions = ref<{ value: string; label: string }[]>([]);
onMounted(async () => {
try {
const res = await getAiAgentList();
if (res.code == 200 && res.data) {
agentOptions.value = res.data.map((item: AiAgentConfigVO) => ({
value: item.bizType,
label: item.name,
}));
}
} catch {
// 网络层已提示
}
});
/** 账户重置 */
const onAccountReset = () => {
accountSearch.reset();
handleAccountReset();
};
/** 流水重置 */
const onTxnReset = () => {
txnSearch.reset();
handleTxnReset();
};
/** 账户表格列(追加操作列) */
const accountTableColumns = [
...accountColumns,
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right' as const,
align: 'center' as const,
},
];
/** 渲染账户操作列 */
const renderAccountAction = (record: AiPointsAccountVO) => (
<Space size={0}>
<Button
type="link"
size="small"
onClick={() =>
openAdjustModal({
userId: record.userId,
phone: record.phone,
nickname: record.nickname,
})
}
>
调点
</Button>
<Button type="link" size="small" onClick={() => handleViewTxn(record)}>
流水
</Button>
</Space>
);
return () => (
<>
{/* ===== 工具栏:子视图切换 + 操作按钮 ===== */}
<div class={pageStyles.filter}>
<Radio.Group
value={subView.value}
onUpdate:value={(val: 'account' | 'txn') => (subView.value = val)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="account">积分账户</Radio.Button>
<Radio.Button value="txn">积分流水</Radio.Button>
</Radio.Group>
<div class={pageStyles.filterActions} style={{ marginLeft: 'auto' }}>
{subView.value === 'account' && (
<Button type="primary" onClick={() => openAdjustModal()}>
手工调点
</Button>
)}
</div>
</div>
<div class={pageStyles.filter} style={{ marginTop: '12px' }}>
{subView.value === 'account' ? (
<Space>
<Select
showSearch
value={accountFilter.userId ? String(accountFilter.userId) : undefined}
placeholder="手机号搜索用户"
filterOption={false}
loading={accountSearch.searching.value}
options={accountSearch.options.value as any}
style={{ width: '220px' }}
onSearch={accountSearch.onSearch}
onChange={(val: any) => {
accountFilter.userId = val ?? '';
}}
allowClear
notFoundContent={renderNotFound(
accountSearch.searching.value,
accountSearch.keyword.value,
)}
/>
<Button type="primary" onClick={handleAccountSearch} loading={accountLoading.value}>
查询
</Button>
<Button onClick={onAccountReset}>重置</Button>
</Space>
) : (
<Space wrap>
<Select
showSearch
value={txnFilter.userId ? String(txnFilter.userId) : undefined}
placeholder="手机号搜索用户"
filterOption={false}
loading={txnSearch.searching.value}
options={txnSearch.options.value as any}
style={{ width: '200px' }}
onSearch={txnSearch.onSearch}
onChange={(val: any) => {
txnFilter.userId = val ?? '';
}}
allowClear
notFoundContent={renderNotFound(txnSearch.searching.value, txnSearch.keyword.value)}
/>
<Select
value={txnFilter.bizType || undefined}
onUpdate:value={(val: any) => (txnFilter.bizType = val ?? '')}
placeholder="业务场景"
style={{ width: '200px' }}
options={agentOptions.value as any}
allowClear
/>
<Select
value={txnFilter.type}
onUpdate:value={(val: any) => (txnFilter.type = val ?? '')}
placeholder="流水类型"
style={{ width: '140px' }}
options={POINTS_TXN_TYPE_OPTIONS as any}
/>
<DatePicker
value={txnFilter.date ? dayjs(txnFilter.date) : undefined}
onUpdate:value={(val: any) => {
txnFilter.date = val ? val.format('YYYY-MM-DD') : '';
}}
format="YYYY-MM-DD"
placeholder="日期"
style={{ width: 160 }}
allowClear
/>
<Button type="primary" onClick={handleTxnSearch} loading={txnLoading.value}>
查询
</Button>
<Button onClick={onTxnReset}>重置</Button>
</Space>
)}
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
{subView.value === 'account' ? (
<Table
columns={accountTableColumns}
dataSource={accountDataSource.value}
loading={accountLoading.value}
rowKey="userId"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: { column: { key: string }; record: AiPointsAccountVO }) => {
if (args.column.key === 'action') {
return renderAccountAction(args.record);
}
return undefined;
},
}}
</Table>
) : (
<Table
columns={txnColumns}
dataSource={txnDataSource.value}
loading={txnLoading.value}
rowKey={(record: AiPointsTxnVO) =>
`${record.userId}_${record.messageId}_${record.createDate}`
}
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
/>
)}
</div>
<div class={pageStyles.pagination}>
{subView.value === 'account' ? (
<Pagination
{...paginationProps}
current={(accountPagination as any).current.value}
pageSize={(accountPagination as any).pageSize.value}
total={(accountPagination as any).total.value}
onChange={handleAccountPageChange}
/>
) : (
<Pagination
{...paginationProps}
current={(txnPagination as any).current.value}
pageSize={(txnPagination as any).pageSize.value}
total={(txnPagination as any).total.value}
onChange={handleTxnPageChange}
/>
)}
</div>
</div>
{adjustModalVisible.value && (
<PointsAdjustModal
visible={adjustModalVisible.value}
submitting={adjusting.value}
presetUser={presetUser.value}
onClose={closeAdjustModal}
onSubmit={handleSubmitAdjust}
/>
)}
</>
);
},
});
@@ -0,0 +1,119 @@
import { ref } from 'vue';
import { getUserList, type EventUserVO } from '@/pages/events/users/model/services';
export interface UserSearchOption {
/** userId 作为 value(传给后端) */
value: string;
/** 展示文案:手机号(昵称) */
label: string;
/** 原始用户数据 */
raw: EventUserVO;
}
export interface PresetUserInfo {
userId: number | string;
phone?: string;
nickname?: string;
}
export function useUserSearch(delay = 300) {
/** 搜索关键字(内部管理,调用方无需关心) */
const keyword = ref('');
/** Select 选项列表 */
const options = ref<UserSearchOption[]>([]);
/** 搜索中状态 */
const searching = ref(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const doSearch = async (val: string) => {
const trimmed = val.trim();
if (!trimmed) {
options.value = [];
return;
}
searching.value = true;
try {
const res = await getUserList({
page: '1',
limit: '20',
phone: trimmed,
});
if (res.code == 200 && res.data?.list) {
options.value = res.data.list.map((user) => ({
value: user.id,
label: `${user.phone}${user.nickname ? `(${user.nickname})` : ''}`,
raw: user,
}));
} else {
options.value = [];
}
} catch {
options.value = [];
} finally {
searching.value = false;
}
};
/** Select onSearch 回调(带防抖) */
const onSearch = (val: string) => {
keyword.value = val;
if (timer) clearTimeout(timer);
if (!val || !val.trim()) {
options.value = [];
return;
}
timer = setTimeout(() => doSearch(val), delay);
};
/** Select onChange 回调:返回 userId(数字),清空时返回 undefined */
const onChange = (val: any): number | undefined => {
return val ? Number(val) : undefined;
};
/** 注入预设用户选项(从表格行点击带入时使用) */
const setPresetUser = (user: PresetUserInfo) => {
const label = user.phone
? `${user.phone}${user.nickname ? `(${user.nickname})` : ''}`
: `用户ID: ${user.userId}`;
options.value = [
{
value: String(user.userId),
label,
raw: {
id: String(user.userId),
nickname: user.nickname || '',
phone: user.phone || '',
realName: '',
idCard: '',
idCardImg: '',
idCardImgList: [],
gender: '',
status: '',
createTime: '',
},
},
];
};
/** 重置:清除 keyword + options(供筛选栏重置按钮调用) */
const reset = () => {
keyword.value = '';
options.value = [];
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return {
keyword,
options,
searching,
onSearch,
onChange,
doSearch,
setPresetUser,
reset,
};
}
+122
View File
@@ -0,0 +1,122 @@
import { defineComponent, ref } from 'vue';
import { Button, Table, Space, Tabs, TabPane } from 'ant-design-vue';
import { useAiAgentModel } from './model/useAiAgentModel';
import { useAiAgentColumns } from './model/useAiAgentColumns';
import { useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import AiAgentEditModal from './components/AiAgentEditModal';
import AiModelDict from './components/AiModelDict';
import BlacklistTab from './components/BlacklistTab';
import PointsTab from './components/PointsTab';
import BillingReportTab from './components/BillingReportTab';
import type { AiAgentConfigVO } from './model/services';
export default defineComponent({
name: 'SystemAiAgent',
setup() {
const activeTab = ref('agent');
const {
loading,
dataSource,
editModalVisible,
currentRecord,
submitting,
handleSearch,
openEditModal,
closeEditModal,
handleSubmitEdit,
} = useAiAgentModel();
const { columns } = useAiAgentColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
/** 渲染操作列 */
const renderAction = (record: AiAgentConfigVO) => (
<Button type="link" size="small" onClick={() => openEditModal(record)}>
编辑
</Button>
);
/** 完整表格列 */
const tableColumns = [
...columns,
{
title: '操作',
key: 'action',
width: 80,
fixed: 'right' as const,
align: 'center' as const,
},
];
return () => (
<div class={pageStyles.containerMain}>
<Tabs
activeKey={activeTab.value}
onUpdate:activeKey={(val: string | number) => (activeTab.value = String(val))}
class={pageStyles.tabs}
destroyInactiveTabPane
>
<TabPane key="agent" tab="场景配置">
<div class={pageStyles.filter}>
<Space>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
刷新
</Button>
</Space>
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
columns={tableColumns}
dataSource={dataSource.value}
loading={loading.value}
rowKey="id"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: { column: { key: string }; record: AiAgentConfigVO }) => {
if (args.column.key === 'action') {
return renderAction(args.record);
}
return undefined;
},
}}
</Table>
</div>
</div>
</TabPane>
<TabPane key="model" tab="模型字典">
<AiModelDict />
</TabPane>
<TabPane key="points" tab="积分账户">
<PointsTab />
</TabPane>
<TabPane key="blacklist" tab="黑名单管理">
<BlacklistTab />
</TabPane>
<TabPane key="report" tab="报表">
<BillingReportTab />
</TabPane>
</Tabs>
{editModalVisible.value && (
<AiAgentEditModal
visible={editModalVisible.value}
record={currentRecord.value as any}
submitting={submitting.value}
onClose={closeEditModal}
onSubmit={handleSubmitEdit}
/>
)}
</div>
);
},
});
+59
View File
@@ -0,0 +1,59 @@
/**
* AI 场景配置 - 配置文件
* 集中管理选项配置和状态映射
*/
import type { StatusTagTone } from '@/components';
/** 业务开关映射 */
export const ENABLED_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '开启', tone: 'success' },
'0': { label: '关闭', tone: 'danger' },
};
/** 计费开关映射 */
export const BILLING_ENABLED_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '计费', tone: 'primary' },
'0': { label: '免费', tone: 'default' },
};
/** 倍率最小值 */
export const MIN_MULTIPLIER = 0.01;
/** 免费额度最大值 */
export const MAX_FREE_POINTS = 999999;
/** 黑名单状态映射 */
export const BLACKLIST_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
1: { label: '拉黑中', tone: 'danger' },
0: { label: '已解除', tone: 'default' },
};
/** 黑名单状态筛选选项 */
export const BLACKLIST_STATUS_OPTIONS = [
{ value: '' as '' | number, label: '全部' },
{ value: 1, label: '拉黑中' },
{ value: 0, label: '已解除' },
] as const;
/** 流水类型映射 */
export const POINTS_TXN_TYPE_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
1: { label: '充值/赠送', tone: 'success' },
2: { label: '调账', tone: 'primary' },
3: { label: '消费', tone: 'warning' },
4: { label: '欠款', tone: 'danger' },
};
/** 流水类型筛选选项 */
export const POINTS_TXN_TYPE_OPTIONS = [
{ value: '' as '' | number, label: '全部' },
{ value: 1, label: '充值/赠送' },
{ value: 2, label: '调账' },
{ value: 3, label: '消费' },
{ value: 4, label: '欠款' },
] as const;
/** 调点类型选项 */
export const POINTS_ADJUST_TYPE_OPTIONS = [
{ value: 1, label: '充值/赠送' },
{ value: 2, label: '调账' },
] as const;
+416
View File
@@ -0,0 +1,416 @@
import { get, post } from '@/utils/request';
/** AI 场景配置 VO */
export interface AiAgentConfigVO {
id: string | number;
/** 业务类型(唯一键) */
bizType: string;
/** 展示名 */
name: string;
/** 业务开关 1/0(0=前端隐藏入口、会话创建被拒) */
enabled: string | number;
/** 计费开关 1/0(0=该场景免费) */
billingEnabled: string | number;
/** 计费倍率(>0,促销可调) */
billingMultiplier: string | number;
/** 每日免费额度(口径=每日免费结算条数;0=无免费额) */
dailyFreePoints: string | number;
/** 运营备注 */
descr: string;
}
/** 更新场景配置参数 */
export interface AiAgentUpdateParams {
id: string | number;
enabled?: number;
billingEnabled?: number;
billingMultiplier?: number;
dailyFreePoints?: number;
descr?: string;
}
/** 模型字典 VO */
export interface AiModelDictVO {
/** 配置键(如 model / extract.apiKey) */
label: string;
/** 配置值(敏感键脱敏;未配置 null) */
value: string | null;
/** 是否敏感(apiKey 类:留空=保留原值) */
sensitive: boolean;
/** 用途组:chat=对话/工具(全局默认) extract=导入提取 failover=备用网关 */
purpose: string;
/** 表单提示文案 */
remark: string;
}
/** 模型字典保存参数 */
export interface AiModelDictSaveParams {
items: { label: string; value: string }[];
}
/** 模型字典重置参数 */
export interface AiModelDictResetParams {
labels: string[];
}
/** AI 积分账户 VO */
export interface AiPointsAccountVO {
/** 用户 ID */
userId: number;
/** 昵称 */
nickname?: string;
/** 手机号 */
phone?: string;
/** 当前余额(可为负=欠款) */
points: number;
/** 累计充值 */
totalRecharged: number;
/** 累计消费 */
totalConsumed: number;
}
/** AI 积分流水 VO */
export interface AiPointsTxnVO {
/** 用户 ID */
userId: number;
/** 昵称 */
nickname?: string;
/** 手机号 */
phone?: string;
/** 业务场景(如 matchImport / chat 等) */
bizType: string;
/** 流水类型 1=充值/赠送 2=调账 3=消费 4=欠款 */
type: number;
/** 变更积分数(正=充/赠/调增,负=扣/消费) */
changePoints: number;
/** 输入 token 数 */
inputTokens: number;
/** 输出 token 数 */
outputTokens: number;
/** 模型标识 */
model: string;
/** 消息 ID */
messageId: string;
/** 是否免费 1=免费 0=付费 */
freeFlag: number;
/** 审计备注 */
descr: string;
/** 创建时间 */
createDate: string;
}
/** 积分账户分页查询参数 */
export interface AiPointsAccountQueryParams {
page: number;
size: number;
/** 可选:用户 ID 筛选 */
userId?: number;
}
/** 积分流水分页查询参数 */
export interface AiPointsTxnQueryParams {
page: number;
size: number;
/** 可选:用户 ID 筛选 */
userId?: number;
/** 可选:业务场景筛选 */
bizType?: string;
/** 可选:流水类型 1充值/2调账/3消费/4欠款 */
type?: number;
/** 可选:日期筛选 yyyy-MM-dd */
date?: string;
}
/** 手工调点参数 */
export interface AiPointsAdjustParams {
/** 必填:用户 ID */
userId: number;
/** 必填:积分数(非0;正=充/赠,负=扣) */
points: number;
/** 可选 1=充值/赠送 2=调账;缺省按正负推导 */
type?: number;
/** 可选:审计备注 */
descr?: string;
}
/** AI 黑名单 VO */
export interface AiBlacklistVO {
id: number;
/** 用户 ID */
userId: number;
/** 昵称 */
nickname?: string;
/** 手机号 */
phone?: string;
/** 拉黑原因(审计要求) */
reason: string;
/** 状态:1=拉黑中 0=已解除 */
status: number;
/** 到期时间(null=永久;yyyy-MM-dd HH:mm:ss) */
expireDate: string | null;
/** 操作人 ID */
operatorId: number;
/** 创建时间 */
createDate: string;
/** 更新时间 */
updateDate: string;
}
/** 黑名单分页查询参数 */
export interface AiBlacklistQueryParams {
page: number;
size: number;
/** 可选:用户 ID 筛选 */
userId?: number;
/** 可选:状态筛选 1=拉黑中 0=已解除 */
status?: number;
}
/** 拉黑参数 */
export interface AiBlacklistAddParams {
/** 必填:用户 ID */
userId: number;
/** 必填:拉黑原因 */
reason: string;
/** 可选:到期时间(yyyy-MM-dd[ HH:mm:ss];空=永久) */
expireDate?: string;
}
/** 解除拉黑参数 */
export interface AiBlacklistRemoveParams {
userId: number;
}
/** 分页数据 */
export interface PageData<T> {
total: number;
list: T[];
}
/** 接口通用返回结构 */
export interface ApiResult<T> {
code: number;
msg: string;
data: T;
}
/** 计费单价 VO(文档 §3.3,GET /admin/manager/ai/billing/prices) */
export interface AiBillingPriceVO {
/** 模型标识 */
model: string;
/** 输入单价(点/千 token) */
inputPrice: number;
/** 输出单价(点/千 token) */
outputPrice: number;
}
/** 通用字典类型 VO(§2.4 GET types) */
export interface SysDictTypeVO {
id: number;
dictType: string;
dictName: string;
remark: string;
sort: number;
dataCount: number;
}
/** 通用字典数据行 VO(§2.4 GET data/list) */
export interface SysDictDataVO {
id: number;
dictTypeId: number;
dictLabel: string;
dictValue: string;
sort: number;
remark: string;
}
/** 通用字典 data/save 参数(§2.4) */
export interface DictDataSaveParams {
/** 数据行 id;空=新建 */
id?: number;
/** 关联类型 id */
dictTypeId: number;
/** 字典 label,如 deepseek-flash.input / default.output */
dictLabel: string;
/** 字典 value,单价值 */
dictValue: string;
/** 排序 */
sort?: number;
/** 备注 */
remark?: string;
}
/** 通用字典 data/delete 参数(§2.4,按 id 删除) */
export interface DictDataDeleteParams {
id: number;
}
// ===== 报表相关(文档 §1.4) =====
/** 计费报表行 VO(§1.4 ⑥ GET /admin/manager/ai/billing/report) */
export interface AiBillingReportVO {
/** 日期 yyyy-MM-dd */
date: string;
/** 业务场景 */
bizType: string;
/** 消息数 */
messageCount: number;
/** 总 token 数 */
totalTokens: number;
/** 总消耗积分 */
totalPoints: number;
}
/** 直解格式统计报表行 VO(§1.4 ⑦ GET /admin/manager/ai/freeform/report) */
export interface AiFreeformReportVO {
/** 文档指纹 */
fingerprint: string;
/** 文档性质 */
docNature: string;
/** 信号集 */
signalSet: string;
/** 表头签名 */
headerSignature: string;
/** 使用次数 */
useCount: number;
/** 最后使用时间 */
lastUsedAt: string;
/** 是否高频(月频次≥5) */
highFrequency: boolean;
}
/** 计费报表查询参数(§1.4 ⑥) */
export interface AiBillingReportQueryParams {
/** 可选:开始日期 yyyy-MM-dd */
startDate?: string;
/** 可选:结束日期 yyyy-MM-dd */
endDate?: string;
}
/** 直解报表查询参数(§1.4 ⑦) */
export interface AiFreeformReportQueryParams {
/** 返回行数,默认 50 */
top?: number;
}
const AI_AGENT_LIST = '/admin/manager/ai/agent/list';
const AI_AGENT_UPDATE = '/admin/manager/ai/agent/update';
const AI_MODEL_DICT_GET = '/admin/manager/ai/model/dict';
const AI_MODEL_DICT_SAVE = '/admin/manager/ai/model/dict';
const AI_MODEL_DICT_RESET = '/admin/manager/ai/model/dict/reset';
const AI_BLACKLIST_LIST = '/admin/manager/ai/blacklist/list';
const AI_BLACKLIST_ADD = '/admin/manager/ai/blacklist/add';
const AI_BLACKLIST_REMOVE = '/admin/manager/ai/blacklist/remove';
const AI_POINTS_ACCOUNTS = '/admin/manager/ai/points/accounts';
const AI_POINTS_ADJUST = '/admin/manager/ai/points/adjust';
const AI_POINTS_TRANSACTIONS = '/admin/manager/ai/points/transactions';
const AI_BILLING_PRICES = '/admin/manager/ai/billing/prices';
const DICT_TYPES = '/admin/manager/dict/types';
const DICT_DATA_LIST = '/admin/manager/dict/data/list';
const DICT_DATA_SAVE = '/admin/manager/dict/data/save';
const DICT_DATA_DELETE = '/admin/manager/dict/data/delete';
const AI_BILLING_REPORT = '/admin/manager/ai/billing/report';
const AI_FREEFORM_REPORT = '/admin/manager/ai/freeform/report';
/** 单价字典类型标识 */
export const AI_BILLING_PRICE_DICT_TYPE = 'ai_billing_price';
/** 场景配置列表 */
export function getAiAgentList(): Promise<ApiResult<AiAgentConfigVO[]>> {
return get(AI_AGENT_LIST, {});
}
/** 更新场景配置 */
export function updateAiAgent(params: AiAgentUpdateParams): Promise<ApiResult<string>> {
return post(AI_AGENT_UPDATE, params);
}
/** 读取模型字典当前配置(白名单 16 键全量返回) */
export function getAiModelDict(): Promise<ApiResult<AiModelDictVO[]>> {
return get(AI_MODEL_DICT_GET, {});
}
/** 保存模型字典(整组 upsert) */
export function saveAiModelDict(params: AiModelDictSaveParams): Promise<ApiResult<string>> {
return post(AI_MODEL_DICT_SAVE, params);
}
/** 重置模型字典指定键(回落后一级) */
export function resetAiModelDict(params: AiModelDictResetParams): Promise<ApiResult<string>> {
return post(AI_MODEL_DICT_RESET, params);
}
/** 积分账户分页列表 */
export function getAiPointsAccounts(
params: AiPointsAccountQueryParams,
): Promise<ApiResult<PageData<AiPointsAccountVO>>> {
return get(AI_POINTS_ACCOUNTS, params as unknown as Record<string, unknown>);
}
/** 手工调点(充值/纠错) */
export function adjustAiPoints(params: AiPointsAdjustParams): Promise<ApiResult<string>> {
return post(AI_POINTS_ADJUST, params);
}
/** 积分流水分页列表 */
export function getAiPointsTransactions(
params: AiPointsTxnQueryParams,
): Promise<ApiResult<PageData<AiPointsTxnVO>>> {
return get(AI_POINTS_TRANSACTIONS, params as unknown as Record<string, unknown>);
}
/** 黑名单分页列表 */
export function getAiBlacklistList(
params: AiBlacklistQueryParams,
): Promise<ApiResult<PageData<AiBlacklistVO>>> {
return get(AI_BLACKLIST_LIST, params as unknown as Record<string, unknown>);
}
/** 拉黑 */
export function addAiBlacklist(params: AiBlacklistAddParams): Promise<ApiResult<string>> {
return post(AI_BLACKLIST_ADD, params);
}
/** 解除拉黑 */
export function removeAiBlacklist(params: AiBlacklistRemoveParams): Promise<ApiResult<string>> {
return post(AI_BLACKLIST_REMOVE, params);
}
/** 读取模型计费单价列表(生效价,§3.3) */
export function getAiBillingPrices(): Promise<ApiResult<AiBillingPriceVO[]>> {
return get(AI_BILLING_PRICES, {});
}
/** 通用字典 — 获取全部类型(§2.4 GET types) */
export function getDictTypes(): Promise<ApiResult<SysDictTypeVO[]>> {
return get(DICT_TYPES, {});
}
/** 通用字典 — 获取某类型下数据行(§2.4 GET data/list) */
export function getDictDataList(dictTypeId: number): Promise<ApiResult<SysDictDataVO[]>> {
return get(DICT_DATA_LIST, { dictTypeId });
}
/** 通用字典 — 保存/更新数据行(§2.4 POST data/save) */
export function saveDictData(params: DictDataSaveParams): Promise<ApiResult<string>> {
return post(DICT_DATA_SAVE, params);
}
/** 通用字典 — 删除数据行(§2.4 POST data/delete,按 id 删除) */
export function deleteDictData(id: number): Promise<ApiResult<string>> {
return post(DICT_DATA_DELETE, { id });
}
/** 计费报表(§1.4 ⑥,按日按场景汇总) */
export function getAiBillingReport(
params: AiBillingReportQueryParams,
): Promise<ApiResult<AiBillingReportVO[]>> {
return get(AI_BILLING_REPORT, params as Record<string, unknown>);
}
/** 直解格式统计报表(§1.4 ⑦) */
export function getAiFreeformReport(
params: AiFreeformReportQueryParams,
): Promise<ApiResult<AiFreeformReportVO[]>> {
return get(AI_FREEFORM_REPORT, params as Record<string, unknown>);
}
@@ -0,0 +1,93 @@
import { h, type VNodeChild } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { ENABLED_MAP, BILLING_ENABLED_MAP } from './config';
/**
* AI 场景配置页 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
* 业务开关 / 计费开关:列表仅展示文案,编辑在弹窗中进行
* 操作列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
*/
/** 渲染状态标签 */
const renderStatusTag = (
text: string | number,
map: Record<string, { label: string; tone: StatusTagTone }>,
): VNodeChild => {
const info = map[String(text)] || { label: String(text), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
};
/** 渲染计费倍率 */
const renderMultiplier = (text: string | number): string => `${text}x`;
/** 渲染每日免费额度 */
const renderFreePoints = (text: string | number): string =>
Number(text) > 0 ? `${text} 条` : '无';
/** 渲染备注 */
const renderDescr = (text: string): string => text || '-';
export function useAiAgentColumns() {
const columns = [
{
title: '场景名称',
dataIndex: 'name',
key: 'name',
width: 160,
align: 'center' as const,
},
{
title: '业务类型',
dataIndex: 'bizType',
key: 'bizType',
width: 160,
align: 'center' as const,
},
{
title: '业务开关',
dataIndex: 'enabled',
key: 'enabled',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): VNodeChild =>
renderStatusTag(text, ENABLED_MAP),
},
{
title: '计费开关',
dataIndex: 'billingEnabled',
key: 'billingEnabled',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): VNodeChild =>
renderStatusTag(text, BILLING_ENABLED_MAP),
},
{
title: '计费倍率',
dataIndex: 'billingMultiplier',
key: 'billingMultiplier',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): string => renderMultiplier(text),
},
{
title: '每日免费额度',
dataIndex: 'dailyFreePoints',
key: 'dailyFreePoints',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): string => renderFreePoints(text),
},
{
title: '备注',
dataIndex: 'descr',
key: 'descr',
width: 200,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderDescr(text),
},
];
return { columns };
}
@@ -0,0 +1,87 @@
import { computed } from 'vue';
import { message } from 'ant-design-vue';
import { useRequest } from '@/hooks/useRequest';
import { useState } from '@/hooks';
import {
getAiAgentList,
updateAiAgent,
type AiAgentConfigVO,
type AiAgentUpdateParams,
type ApiResult,
} from './services';
/**
* AI 场景配置页数据模型
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*
* 架构分层:
* - 数据层:services.ts(API 调用)
* - 逻辑层:本文件(状态 + 业务逻辑)
* - 视图层:useAiAgentColumns.ts + index.tsx(纯 UI 渲染)
*/
export function useAiAgentModel() {
const [editModalVisible, setEditModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<AiAgentConfigVO | null>(null);
const [submitting, setSubmitting] = useState(false);
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<AiAgentConfigVO[]>>(() => getAiAgentList(), {
refreshDeps: [],
formatResult: (res) => res,
});
// 提取列表数据
const dataSource = computed<AiAgentConfigVO[]>(() => {
const res = data.value;
return res ? (res as any).data || [] : [];
});
const handleSearch = () => fetchList();
/** 打开编辑弹窗 */
const openEditModal = (record: AiAgentConfigVO) => {
setCurrentRecord({ ...record });
setEditModalVisible(true);
};
/** 关闭编辑弹窗 */
const closeEditModal = () => {
setEditModalVisible(false);
setCurrentRecord(null);
};
/** 提交编辑(对接文档 §1.1② POST /admin/manager/ai/agent/update) */
const handleSubmitEdit = async (params: AiAgentUpdateParams) => {
setSubmitting(true);
try {
const res = await updateAiAgent(params);
if (res && res.code == 200) {
message.success('配置已更新');
closeEditModal();
fetchList();
} else {
message.error(res?.msg || '更新失败');
}
} catch (err) {
console.error('[useAiAgentModel] updateAiAgent error:', err);
message.error('更新失败');
} finally {
setSubmitting(false);
}
};
return {
loading,
dataSource,
editModalVisible,
currentRecord,
submitting,
handleSearch,
openEditModal,
closeEditModal,
handleSubmitEdit,
};
}
@@ -0,0 +1,99 @@
import { h, type VNodeChild } from 'vue';
import { StatusTag } from '@/components';
import { BLACKLIST_STATUS_MAP } from './config';
/**
* AI 黑名单 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
*/
/** 渲染状态标签 */
const renderStatusTag = (text: number): VNodeChild => {
const info = BLACKLIST_STATUS_MAP[text] || { label: String(text), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
};
/** 渲染到期时间 */
const renderExpireDate = (text: string | null): string => {
if (!text) return '永久';
return text;
};
/** 渲染原因 */
const renderReason = (text: string): string => text || '-';
export function useBlacklistColumns() {
const columns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
align: 'center' as const,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderReason(text),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderReason(text),
},
{
title: '拉黑原因',
dataIndex: 'reason',
key: 'reason',
width: 240,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderReason(text),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderStatusTag(text),
},
{
title: '到期时间',
dataIndex: 'expireDate',
key: 'expireDate',
width: 180,
align: 'center' as const,
customRender: ({ text }: { text: string | null }): string => renderExpireDate(text),
},
{
title: '操作人ID',
dataIndex: 'operatorId',
key: 'operatorId',
width: 100,
align: 'center' as const,
},
{
title: '拉黑时间',
dataIndex: 'createDate',
key: 'createDate',
width: 180,
align: 'center' as const,
},
{
title: '更新时间',
dataIndex: 'updateDate',
key: 'updateDate',
width: 180,
align: 'center' as const,
},
];
return { columns };
}
@@ -0,0 +1,162 @@
import { computed, reactive, nextTick, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
import { useThrottleFn } from '@/hooks';
import {
getAiBlacklistList,
addAiBlacklist,
removeAiBlacklist,
type AiBlacklistVO,
type AiBlacklistQueryParams,
type AiBlacklistAddParams,
type ApiResult,
type PageData,
} from './services';
/**
* AI 黑名单 - 数据模型
*
* 职责:状态管理 + 数据获取 + 业务逻辑(拉黑/解除)
*
* 对接文档 §1.3:
* - GET /admin/manager/ai/blacklist/list — 分页
* - POST /admin/manager/ai/blacklist/add — 拉黑
* - POST /admin/manager/ai/blacklist/remove — 解除
*/
export function useBlacklistModel() {
const filterForm = reactive({
userId: '' as string | number,
status: '' as '' | number,
});
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const buildQueryParams = (): AiBlacklistQueryParams => {
const params: AiBlacklistQueryParams = {
page: (pagination as any).current.value,
size: (pagination as any).pageSize.value,
};
const uid = filterForm.userId;
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
params.userId = Number(uid);
}
if (filterForm.status !== '') {
params.status = Number(filterForm.status);
}
return params;
};
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<AiBlacklistVO>>>(() => getAiBlacklistList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
const dataSource = computed<AiBlacklistVO[]>(() => {
const res = data.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(pagination, (data.value as any)?.data?.total);
}, [data]);
const addModalVisible = ref(false);
const submitting = ref(false);
/** 搜索 */
const handleSearch = () => {
pagination.reset();
fetchList();
};
/** 重置 */
const handleReset = useThrottleFn(async () => {
filterForm.userId = '';
filterForm.status = '';
pagination.reset();
await fetchList();
}, 500);
/** 分页变更 */
const handlePageChange = (page: number, pageSize: number) => {
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
nextTick().then(() => fetchList());
};
/** 打开拉黑弹窗 */
const openAddModal = () => {
addModalVisible.value = true;
};
/** 关闭拉黑弹窗 */
const closeAddModal = () => {
addModalVisible.value = false;
};
/** 提交拉黑 */
const handleSubmitAdd = async (params: AiBlacklistAddParams) => {
submitting.value = true;
try {
const res = await addAiBlacklist(params);
if (res.code == 200) {
message.success(res.msg || '已拉黑');
closeAddModal();
fetchList();
} else {
message.error(res.msg || '拉黑失败');
}
} catch {
message.error('拉黑失败');
} finally {
submitting.value = false;
}
};
/** 解除拉黑(带确认) */
const handleRemove = useThrottleFn((record: AiBlacklistVO) => {
Modal.confirm({
title: '确认解除拉黑',
content: `确认解除用户 ${record.userId} 的拉黑状态吗?解除后该用户将恢复 AI 功能使用权限。`,
okText: '确认解除',
cancelText: '取消',
onOk: async () => {
try {
const res = await removeAiBlacklist({ userId: record.userId });
if (res.code == 200) {
message.success(res.msg || '已解除拉黑');
fetchList();
} else {
message.error(res.msg || '解除失败');
}
} catch {
message.error('解除失败');
}
},
});
}, 500);
return {
filterForm,
loading,
dataSource,
pagination,
addModalVisible,
submitting,
handleSearch,
handleReset,
handlePageChange,
openAddModal,
closeAddModal,
handleSubmitAdd,
handleRemove,
};
}
@@ -0,0 +1,197 @@
import { h, type VNodeChild } from 'vue';
import { StatusTag } from '@/components';
import { POINTS_TXN_TYPE_MAP } from './config';
/**
* AI 积分账户与流水 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
*/
/** 渲染余额(负数=欠款,红色) */
const renderPoints = (text: number): VNodeChild => {
if (text < 0) {
return h('span', { style: { color: '#f5222d', fontWeight: 600 } }, String(text));
}
return String(text);
};
/** 渲染流水类型标签 */
const renderTxnTypeTag = (text: number): VNodeChild => {
const info = POINTS_TXN_TYPE_MAP[text] || { label: String(text), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
};
/** 渲染变更积分(正数绿色,负数红色) */
const renderChangePoints = (text: number): VNodeChild => {
const color = text >= 0 ? '#52c41a' : '#f5222d';
const sign = text >= 0 ? '+' : '';
return h('span', { style: { color, fontWeight: 600 } }, `${sign}${text}`);
};
/** 渲染免费标记 */
const renderFreeFlag = (text: number): string => (text === 1 ? '免费' : '付费');
/** 渲染文本兜底 */
const renderText = (text: string): string => text || '-';
/** 积分账户表格列 */
export function usePointsAccountColumns() {
const columns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
align: 'center' as const,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '当前余额',
dataIndex: 'points',
key: 'points',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderPoints(text),
},
{
title: '累计充值',
dataIndex: 'totalRecharged',
key: 'totalRecharged',
width: 140,
align: 'center' as const,
},
{
title: '累计消费',
dataIndex: 'totalConsumed',
key: 'totalConsumed',
width: 140,
align: 'center' as const,
},
];
return { columns };
}
/** 积分流水表格列 */
export function usePointsTxnColumns() {
const columns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
align: 'center' as const,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 130,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '业务场景',
dataIndex: 'bizType',
key: 'bizType',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '流水类型',
dataIndex: 'type',
key: 'type',
width: 110,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderTxnTypeTag(text),
},
{
title: '变更积分',
dataIndex: 'changePoints',
key: 'changePoints',
width: 110,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderChangePoints(text),
},
{
title: '输入Token',
dataIndex: 'inputTokens',
key: 'inputTokens',
width: 110,
align: 'center' as const,
},
{
title: '输出Token',
dataIndex: 'outputTokens',
key: 'outputTokens',
width: 110,
align: 'center' as const,
},
{
title: '模型',
dataIndex: 'model',
key: 'model',
width: 160,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '消息ID',
dataIndex: 'messageId',
key: 'messageId',
width: 160,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '免费',
dataIndex: 'freeFlag',
key: 'freeFlag',
width: 80,
align: 'center' as const,
customRender: ({ text }: { text: number }): string => renderFreeFlag(text),
},
{
title: '备注',
dataIndex: 'descr',
key: 'descr',
width: 200,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '时间',
dataIndex: 'createDate',
key: 'createDate',
width: 180,
align: 'center' as const,
},
];
return { columns };
}
@@ -0,0 +1,241 @@
import { computed, reactive, nextTick, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
import { useThrottleFn } from '@/hooks';
import {
getAiPointsAccounts,
adjustAiPoints,
getAiPointsTransactions,
type AiPointsAccountVO,
type AiPointsTxnVO,
type AiPointsAccountQueryParams,
type AiPointsTxnQueryParams,
type AiPointsAdjustParams,
type ApiResult,
type PageData,
} from './services';
/**
* AI 积分账户与流水 - 数据模型
*
* 职责:状态管理 + 数据获取 + 业务逻辑(账户分页 / 流水分页 / 手工调点)
*
* 对接文档 §1.2:
* - GET /admin/manager/ai/points/accounts — 账户分页
* - POST /admin/manager/ai/points/adjust — 手工调点(充值/纠错)
* - GET /admin/manager/ai/points/transactions — 流水分页
*/
export function usePointsModel() {
const subView = ref<'account' | 'txn'>('account');
const accountFilter = reactive({
userId: '' as string | number,
});
const txnFilter = reactive({
userId: '' as string | number,
bizType: '' as string,
type: '' as '' | number,
date: '' as string,
});
const accountPagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const txnPagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const buildAccountQueryParams = (): AiPointsAccountQueryParams => {
const params: AiPointsAccountQueryParams = {
page: (accountPagination as any).current.value,
size: (accountPagination as any).pageSize.value,
};
const uid = accountFilter.userId;
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
params.userId = Number(uid);
}
return params;
};
const buildTxnQueryParams = (): AiPointsTxnQueryParams => {
const params: AiPointsTxnQueryParams = {
page: (txnPagination as any).current.value,
size: (txnPagination as any).pageSize.value,
};
const uid = txnFilter.userId;
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
params.userId = Number(uid);
}
if (txnFilter.bizType) {
params.bizType = txnFilter.bizType;
}
if (txnFilter.type !== '') {
params.type = Number(txnFilter.type);
}
if (txnFilter.date) {
params.date = txnFilter.date;
}
return params;
};
const {
data: accountData,
loading: accountLoading,
run: fetchAccounts,
} = useRequest<ApiResult<PageData<AiPointsAccountVO>>>(
() => getAiPointsAccounts(buildAccountQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const accountDataSource = computed<AiPointsAccountVO[]>(() => {
const res = accountData.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(accountPagination, (accountData.value as any)?.data?.total);
}, [accountData]);
const {
data: txnData,
loading: txnLoading,
run: fetchTxns,
} = useRequest<ApiResult<PageData<AiPointsTxnVO>>>(
() => getAiPointsTransactions(buildTxnQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const txnDataSource = computed<AiPointsTxnVO[]>(() => {
const res = txnData.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(txnPagination, (txnData.value as any)?.data?.total);
}, [txnData]);
const adjustModalVisible = ref(false);
const adjusting = ref(false);
/** 调点弹窗预设用户信息(从账户行点击「调点」时带入) */
const presetUser = ref<{ userId: number; phone?: string; nickname?: string } | undefined>(
undefined,
);
/** 账户搜索 */
const handleAccountSearch = () => {
accountPagination.reset();
fetchAccounts();
};
/** 账户重置 */
const handleAccountReset = useThrottleFn(async () => {
accountFilter.userId = '';
accountPagination.reset();
await fetchAccounts();
}, 500);
/** 账户分页变更 */
const handleAccountPageChange = (page: number, pageSize: number) => {
accountPagination.setCurrent(page);
if (pageSize !== (accountPagination as any).pageSize.value) {
accountPagination.setPageSize(pageSize);
}
nextTick().then(() => fetchAccounts());
};
/** 流水搜索 */
const handleTxnSearch = () => {
txnPagination.reset();
fetchTxns();
};
/** 流水重置 */
const handleTxnReset = useThrottleFn(async () => {
txnFilter.userId = '';
txnFilter.bizType = '';
txnFilter.type = '';
txnFilter.date = '';
txnPagination.reset();
await fetchTxns();
}, 500);
/** 流水分页变更 */
const handleTxnPageChange = (page: number, pageSize: number) => {
txnPagination.setCurrent(page);
if (pageSize !== (txnPagination as any).pageSize.value) {
txnPagination.setPageSize(pageSize);
}
nextTick().then(() => fetchTxns());
};
/** 查看某用户流水(从账户行点击) */
const handleViewTxn = (record: AiPointsAccountVO) => {
subView.value = 'txn';
txnFilter.userId = String(record.userId);
txnFilter.bizType = '';
txnFilter.type = '';
txnFilter.date = '';
txnPagination.reset();
nextTick().then(() => fetchTxns());
};
/** 打开调点弹窗(可预设用户信息) */
const openAdjustModal = (user?: { userId: number; phone?: string; nickname?: string }) => {
presetUser.value = user;
adjustModalVisible.value = true;
};
/** 关闭调点弹窗 */
const closeAdjustModal = () => {
adjustModalVisible.value = false;
presetUser.value = undefined;
};
/** 提交调点 */
const handleSubmitAdjust = async (params: AiPointsAdjustParams) => {
adjusting.value = true;
try {
const res = await adjustAiPoints(params);
if (res.code == 200) {
message.success(res.msg || '调点成功');
closeAdjustModal();
// 刷新当前视图数据
if (subView.value === 'account') {
fetchAccounts();
} else {
fetchTxns();
}
} else {
message.error(res.msg || '调点失败');
}
} catch {
message.error('调点失败');
} finally {
adjusting.value = false;
}
};
return {
subView,
accountFilter,
accountLoading,
accountDataSource,
accountPagination,
handleAccountSearch,
handleAccountReset,
handleAccountPageChange,
handleViewTxn,
txnFilter,
txnLoading,
txnDataSource,
txnPagination,
handleTxnSearch,
handleTxnReset,
handleTxnPageChange,
adjustModalVisible,
adjusting,
presetUser,
openAdjustModal,
closeAdjustModal,
handleSubmitAdjust,
};
}
+33
View File
@@ -0,0 +1,33 @@
.cell {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
cursor: default;
}
.text {
flex: 1;
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
word-break: break-all;
color: rgba(0, 0, 0, 0.88);
}
.view {
flex-shrink: 0;
color: #1677ff;
cursor: pointer;
user-select: none;
line-height: 1.5;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}
+77 -3
View File
@@ -1,11 +1,81 @@
import { defineComponent } from 'vue';
import { Button, Input, Table, DatePicker, Form, Space, Select, Pagination } from 'ant-design-vue';
import { defineComponent, ref, nextTick } from 'vue';
import {
Button,
Input,
Table,
DatePicker,
Form,
Space,
Select,
Pagination,
Modal,
} from 'ant-design-vue';
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_TYPE_MAP } from './model/useLogModel';
import { useContainerSize } from '@/hooks';
import { useContainerSize, useState, useEffect } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import cellStyles from './index.module.less';
const { RangePicker } = DatePicker;
/**
* 操作内容单元格:最多 2 行截断,溢出时显示"查看"按钮
*/
const LogContentCell = defineComponent({
name: 'LogContentCell',
props: { text: { type: String, required: true } },
setup(props) {
const textRef = ref<HTMLElement | null>(null);
const [overflow, setOverflow] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const checkOverflow = () => {
const el = textRef.value;
if (el) setOverflow(el.scrollHeight > el.clientHeight);
};
let observer: ResizeObserver | null = null;
useEffect(() => {
if (!textRef.value) return;
nextTick(checkOverflow);
observer = new ResizeObserver(checkOverflow);
observer.observe(textRef.value);
return () => {
observer?.disconnect();
observer = null;
};
}, [() => textRef.value]);
return () => {
const raw = props.text;
return (
<div class={cellStyles.cell}>
<div ref={textRef} class={cellStyles.text}>
{raw}
</div>
{overflow.value ? (
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
查看
</span>
) : null}
<Modal
visible={modalOpen.value}
title="操作内容"
footer={null}
width={600}
centered
destroyOnClose
onCancel={() => setModalOpen(false)}
>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.8 }}>
{raw}
</div>
</Modal>
</div>
);
};
},
});
/**
* bodyCell 渲染
*/
@@ -13,6 +83,10 @@ function renderBodyCell({ column, text }: { column: any; text: any }) {
if (column.key === 'type') {
return <span>{ACTION_TYPE_MAP[text as number] || text || '--'}</span>;
}
if (column.dataIndex === 'content') {
const raw = text || '';
return raw ? <LogContentCell text={raw} /> : <span>--</span>;
}
return <span>{text || '--'}</span>;
}
+13 -15
View File
@@ -1,6 +1,6 @@
import { computed, reactive, toRef, Ref, nextTick } from 'vue';
import { computed, reactive, nextTick } from 'vue';
import dayjs from 'dayjs';
import { useDebounce, useThrottleFn } from '@/hooks';
import { useThrottleFn } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
@@ -23,6 +23,8 @@ export const ACTION_TYPE_OPTIONS = [
{ value: '2', label: '上架赛事' },
{ value: '3', label: '禁用用户' },
{ value: '4', label: '启用用户' },
{ value: '7', label: '系统参数' },
{ value: '8', label: '用户登录' },
] as const;
/** 操作类型文案映射 */
@@ -31,6 +33,8 @@ export const ACTION_TYPE_MAP: Record<number, string> = {
2: '上架赛事',
3: '禁用用户',
4: '启用用户',
7: '系统参数',
8: '用户登录',
};
// ============================================================
@@ -48,12 +52,6 @@ export function useLogModel() {
searchOperator: '',
});
// 操作人字段防抖 300ms
const { debouncedValue: debouncedOperator } = useDebounce(
toRef(filterForm, 'searchOperator') as Ref<string>,
{ delay: 300 },
);
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
@@ -76,9 +74,9 @@ export function useLogModel() {
params.type = filterForm.actionType;
}
// 操作人昵称(防抖后的值)
if (debouncedOperator.value.trim()) {
params.nickname = debouncedOperator.value.trim();
// 操作人昵称
if (filterForm.searchOperator.trim()) {
params.nickname = filterForm.searchOperator.trim();
}
return params;
@@ -107,7 +105,7 @@ export function useLogModel() {
const columns = [
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 140 },
{ title: '操作人', dataIndex: 'nickname', key: 'nickname', width: 140 },
{ title: '操作内容', dataIndex: 'content', key: 'content', minWidth: 360 },
{ title: '操作内容', dataIndex: 'content', key: 'content', width: 480 },
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 180 },
];
@@ -116,16 +114,16 @@ export function useLogModel() {
return (
filterForm.actionTimeRange !== null ||
filterForm.actionType !== '' ||
debouncedOperator.value.trim() !== ''
filterForm.searchOperator.trim() !== ''
);
});
// ===== 方法 =====
const handleSearch = () => {
const handleSearch = useThrottleFn(() => {
pagination.reset();
fetchList();
};
}, 300);
/** 重置(重置后自动查询) */
const handleReset = useThrottleFn(async () => {
@@ -0,0 +1,10 @@
.paramFormModalMain {
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
margin-top: 8px;
}
}
@@ -0,0 +1,142 @@
import { defineComponent, ref, reactive, nextTick } from 'vue';
import { useEffect } from '@/hooks';
import { Modal, Form, Input, Button } from 'ant-design-vue';
import { paramCodeRules, paramValueRules, remarkRules } from '../model/form';
import type { SysParamsDTO } from '../model/services';
import styles from './ParamFormModal.module.less';
const { TextArea } = Input;
interface ParamFormModalProps {
visible: boolean;
record: SysParamsDTO | null;
submitting?: boolean;
onClose: () => void;
onSave: (formData: { paramCode: string; paramValue: string; remark: string }) => void;
}
/** 默认表单数据 */
const getDefaultForm = () => ({
paramCode: '',
paramValue: '',
remark: '',
});
export default defineComponent({
name: 'ParamFormModal',
props: {
visible: { type: Boolean, default: false },
record: { type: Object, default: null },
submitting: { type: Boolean, default: false },
onClose: { type: Function, required: true },
onSave: { type: Function, required: true },
},
setup(props: ParamFormModalProps) {
const formRef = ref<any>();
const formData = reactive(getDefaultForm());
/** 根据 record 初始化表单 */
const initFormFromRecord = (record: SysParamsDTO | null) => {
if (record) {
Object.assign(formData, {
paramCode: record.paramCode || '',
paramValue: record.paramValue || '',
remark: record.remark || '',
});
} else {
Object.assign(formData, getDefaultForm());
}
};
/** 监听 visible 变化重置表单 */
useEffect(() => {
if (props.visible) {
initFormFromRecord(props.record);
nextTick().then(() => formRef.value?.clearValidate());
}
}, [() => props.visible]);
/** 提交 */
const handleSubmit = async () => {
try {
await formRef.value?.validate();
props.onSave({
paramCode: formData.paramCode,
paramValue: formData.paramValue,
remark: formData.remark,
});
} catch {
// 校验未通过,Form 已展示错误,无需处理
}
};
/** 关闭 */
const handleClose = () => {
props.onClose();
};
return () => {
const isEdit = !!props.record;
return (
<Modal
title={isEdit ? '编辑配置' : '新增配置'}
visible={props.visible}
onCancel={handleClose}
width={520}
destroyOnClose
footer={null}
centered
wrapClassName={styles.paramFormModalMain}
>
<Form ref={formRef} layout="vertical" model={formData} requiredMark>
<Form.Item label="参数编码" name="paramCode" rules={paramCodeRules}>
<Input
value={formData.paramCode}
onUpdate:value={(val: any) => {
formData.paramCode = val?.target?.value ?? val ?? '';
}}
placeholder="请输入参数编码"
allowClear
readonly={isEdit}
/>
</Form.Item>
<Form.Item label="参数值" name="paramValue" rules={paramValueRules}>
<TextArea
value={formData.paramValue}
onUpdate:value={(val: any) => {
formData.paramValue = val?.target?.value ?? val ?? '';
}}
placeholder="请输入参数值"
rows={2}
/>
</Form.Item>
<Form.Item label="备注说明" name="remark" rules={remarkRules}>
<TextArea
value={formData.remark}
onUpdate:value={(val: any) => {
formData.remark = val?.target?.value ?? val ?? '';
}}
placeholder="请输入备注说明(选填,最多200字)"
maxlength={200}
showCount
rows={4}
/>
</Form.Item>
</Form>
{/* 底部按钮 */}
<div class={styles.footer}>
<Button onClick={handleClose}>取消</Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
确认保存
</Button>
</div>
</Modal>
);
};
},
});
+33
View File
@@ -0,0 +1,33 @@
.cell {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
cursor: default;
}
.text {
flex: 1;
min-width: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
word-break: break-all;
color: rgba(0, 0, 0, 0.88);
}
.view {
flex-shrink: 0;
color: #1677ff;
cursor: pointer;
user-select: none;
line-height: 1.5;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}
+270
View File
@@ -0,0 +1,270 @@
import { defineComponent, ref, nextTick } from 'vue';
import { Button, Input, Table, Form, Space, Modal, Pagination, message } from 'ant-design-vue';
import { useParamsModel } from './model/useParamsModel';
import { saveParam, updateParam } from './model/services';
import { useState, useThrottleFn, useContainerSize, useEffect } from '@/hooks';
import ParamFormModal from './components/ParamFormModal';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import cellStyles from './index.module.less';
/**
* 文本单元格:最多 2 行截断,溢出时显示"查看"按钮
*/
const TruncatedTextCell = defineComponent({
name: 'TruncatedTextCell',
props: {
text: { type: String, required: true },
modalTitle: { type: String, required: true },
},
setup(props) {
const textRef = ref<HTMLElement | null>(null);
const [overflow, setOverflow] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const checkOverflow = () => {
const el = textRef.value;
if (el) setOverflow(el.scrollHeight > el.clientHeight);
};
let observer: ResizeObserver | null = null;
useEffect(() => {
if (!textRef.value) return;
nextTick(checkOverflow);
observer = new ResizeObserver(checkOverflow);
observer.observe(textRef.value);
return () => {
observer?.disconnect();
observer = null;
};
}, [() => textRef.value]);
return () => {
const raw = props.text;
return (
<div class={cellStyles.cell}>
<div ref={textRef} class={cellStyles.text}>
{raw}
</div>
{overflow.value ? (
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
查看
</span>
) : null}
<Modal
visible={modalOpen.value}
title={props.modalTitle}
footer={null}
width={600}
centered
destroyOnClose
onCancel={() => setModalOpen(false)}
>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.8 }}>
{raw}
</div>
</Modal>
</div>
);
};
},
});
/**
* bodyCell 渲染
*/
function renderBodyCell({
column,
text,
record,
onEdit,
}: {
column: any;
text: any;
record: any;
onEdit: (record: any) => void;
}) {
// 操作列
if (column.key === 'action') {
return (
<Space>
<Button type="link" size="small" onClick={() => onEdit(record)}>
编辑
</Button>
</Space>
);
}
// 参数值 / 备注列:两行截断 + 查看弹窗
if (column.dataIndex === 'paramValue' || column.dataIndex === 'remark') {
const raw = text || '';
const modalTitle = column.dataIndex === 'paramValue' ? '参数值' : '备注说明';
return raw ? <TruncatedTextCell text={raw} modalTitle={modalTitle} /> : <span>--</span>;
}
return <span>{text ?? '--'}</span>;
}
/**
* 系统管理 - 配置管理
*/
export default defineComponent({
name: 'SystemParams',
setup() {
const {
filterForm,
loading,
dataSource,
columns,
pagination,
handleSearch,
handleReset,
handlePageChange,
} = useParamsModel();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// ===== 弹窗状态 =====
const [modalVisible, setModalVisible] = useState<boolean>(false);
const [editingRecord, setEditingRecord] = useState<any>(null);
const [formSubmitting, setFormSubmitting] = useState<boolean>(false);
// ===== 新增 =====
const handleAdd = () => {
setEditingRecord(null);
setModalVisible(true);
};
// ===== 编辑 =====
const handleEdit = (record: any) => {
setEditingRecord(record);
setModalVisible(true);
};
// ===== 关闭弹窗 =====
const handleCloseModal = () => {
setModalVisible(false);
setEditingRecord(null);
};
// ===== 保存(新增/编辑) =====
const handleSave = useThrottleFn(async (formPayload: any) => {
if (formSubmitting.value) return;
setFormSubmitting(true);
try {
let res: any;
if (editingRecord.value) {
res = await updateParam({
id: editingRecord.value.id,
paramCode: formPayload.paramCode,
paramValue: formPayload.paramValue,
remark: formPayload.remark,
});
} else {
res = await saveParam({
paramCode: formPayload.paramCode,
paramValue: formPayload.paramValue,
remark: formPayload.remark,
});
}
if (res.code == 200) {
message.success(editingRecord.value ? '编辑成功' : '新增成功');
handleCloseModal();
handleSearch();
} else {
message.error(res.msg || '操作失败');
}
} catch (error: any) {
console.error('保存配置失败:', error);
} finally {
setFormSubmitting(false);
}
}, 500);
// ===== 表格最终列(含操作列) =====
const tableColumns = [
...columns,
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as const,
align: 'center' as const,
},
];
return () => (
<div class={pageStyles.containerMain}>
{/* ===== 筛选区 ===== */}
<div class={pageStyles.filter}>
<Form layout="inline" model={filterForm}>
<Form.Item label="参数编码" name="paramCode">
<Input
value={filterForm.paramCode}
placeholder="请输入参数编码"
style={{ width: '200px' }}
allowClear
onUpdate:value={(val: any) =>
(filterForm.paramCode = val?.target?.value ?? val ?? '')
}
onPressEnter={handleSearch}
/>
</Form.Item>
<Form.Item>
<Space>
<Button onClick={handleReset}>重置</Button>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
查询
</Button>
<Button type="primary" onClick={handleAdd}>
新增
</Button>
</Space>
</Form.Item>
</Form>
</div>
{/* ===== 表格区 ===== */}
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
columns={tableColumns}
dataSource={dataSource.value}
loading={loading.value}
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: any) =>
renderBodyCell({
...args,
onEdit: handleEdit,
}),
}}
</Table>
</div>
{/* 独立分页 */}
<div class={pageStyles.pagination}>
<Pagination
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showTotal={(total: number) => `共 ${total} 条`}
onChange={handlePageChange}
/>
</div>
</div>
{/* ===== 新增/编辑弹窗 ===== */}
{modalVisible.value && (
<ParamFormModal
visible={modalVisible.value}
record={editingRecord.value}
submitting={formSubmitting.value}
onClose={handleCloseModal}
onSave={handleSave}
/>
)}
</div>
);
},
});
+29
View File
@@ -0,0 +1,29 @@
/**
* 参数配置 - 常量配置
*
* 集中放置该页面的下拉选项、字段映射、状态文案等常量
*/
import type { SelectOption } from '@/types';
// ============================================================
// 字段映射(表格列与 API 字段对齐)
// ============================================================
/** API 字段 → 表格列中文映射 */
export const FIELD_MAPPINGS: Record<string, string> = {
// TODO: 等后端接口确定后,根据实际字段补充
};
// ============================================================
// 下拉选项
// ============================================================
/** 示例:状态筛选选项 */
export const STATUS_OPTIONS: SelectOption[] = [{ value: '', label: '全部' }];
/** 示例:每页条数选项 */
export const PAGE_SIZE_OPTIONS: SelectOption[] = [
{ value: 10, label: '10条/页' },
{ value: 20, label: '20条/页' },
{ value: 50, label: '50条/页' },
];
+19
View File
@@ -0,0 +1,19 @@
/**
* 参数配置 - 表单校验规则
*
* 集中管理新增/编辑弹窗的表单校验规则。
*/
import type { Rule } from 'ant-design-vue/es/form';
// ============================================================
// 校验规则
// ============================================================
/** 参数编码:必填 */
export const paramCodeRules: Rule[] = [{ required: true, message: '请输入参数编码' }];
/** 参数值:必填 */
export const paramValueRules: Rule[] = [{ required: true, message: '请输入参数值' }];
/** 备注说明:选填,最多 200 字 */
export const remarkRules: Rule[] = [{ max: 200, message: '备注说明最多 200 个字' }];
+81
View File
@@ -0,0 +1,81 @@
/**
* 参数配置 - 接口服务
*
* 该页面使用的所有接口集中管理,供 useModel Hook 调用。
*/
import { get, post } from '@/utils/request';
// ============================================================
// 类型定义
// ============================================================
/** 配置项 VO */
export interface SysParamsDTO {
id: string;
paramCode: string;
paramValue: string;
remark: string;
}
/** 分页查询参数 */
export interface ParamQueryParams {
page: string;
limit: string;
paramCode?: string;
}
/** 新增参数 */
export interface ParamSaveParams {
paramCode: string;
paramValue: string;
remark?: string;
}
/** 编辑参数 */
export interface ParamUpdateParams {
id: string;
paramCode: string;
paramValue: string;
remark?: string;
}
/** 分页数据结构 */
export interface PageData<T> {
total: number;
list: T[];
exData?: Record<string, any>;
}
/** 接口通用返回结构 */
export interface ApiResult<T> {
code: number;
msg: string;
data: T;
}
// ============================================================
// URL 常量
// ============================================================
const PARAM_PAGE = '/sys/params/page';
const PARAM_SAVE = '/sys/params/save';
const PARAM_UPDATE = '/sys/params/update';
// ============================================================
// API 函数
// ============================================================
/** 配置列表分页查询 */
export function getParamList(params: ParamQueryParams): Promise<ApiResult<PageData<SysParamsDTO>>> {
return get(PARAM_PAGE, params as Record<string, any>);
}
/** 新增配置 */
export function saveParam(params: ParamSaveParams): Promise<ApiResult<null>> {
return post(PARAM_SAVE, params);
}
/** 编辑配置 */
export function updateParam(params: ParamUpdateParams): Promise<ApiResult<null>> {
return post(PARAM_UPDATE, params);
}
@@ -0,0 +1,36 @@
/**
* 参数配置 - 表格列配置(视图层)
* 纯展示逻辑,不包含状态管理
*/
import type { SysParamsDTO } from './services';
/**
* 参数配置表格列定义
*
* 列:参数编码 / 参数值 / 备注说明
* 操作列在 index.tsx 中动态追加
*/
export function useParamsColumns() {
const columns = [
{
title: '参数编码',
dataIndex: 'paramCode',
key: 'paramCode',
width: 230,
},
{
title: '参数值',
dataIndex: 'paramValue',
key: 'paramValue',
width: 480,
},
{
title: '备注说明',
dataIndex: 'remark',
key: 'remark',
width: 240,
},
];
return { columns };
}
@@ -0,0 +1,111 @@
import { computed, reactive, toRef, nextTick, type Ref } from 'vue';
import { useThrottleFn, useDebounce } from '@/hooks';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
import {
getParamList,
type SysParamsDTO,
type ParamQueryParams,
type PageData,
type ApiResult,
} from './services';
import { useParamsColumns } from './useParamsColumns';
// ============================================================
// Model(业务逻辑 Hook)
// ============================================================
/**
* 参数配置页数据模型
*/
export function useParamsModel() {
// ===== 筛选条件 =====
const filterForm = reactive({
paramCode: '',
});
// 参数编码筛选防抖 300ms
const { debouncedValue: debouncedParamCode } = useDebounce(
toRef(filterForm, 'paramCode') as Ref<string>,
{ delay: 300 },
);
// ===== 分页 =====
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
// ===== 构建 API 查询参数 =====
const buildQueryParams = (): ParamQueryParams => {
const params: ParamQueryParams = {
page: String((pagination as any).current.value),
limit: String((pagination as any).pageSize.value),
};
if (filterForm.paramCode.trim()) {
params.paramCode = filterForm.paramCode.trim();
}
return params;
};
// ===== 数据请求 =====
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<SysParamsDTO>>>(() => getParamList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
const dataSource = computed(() => {
const res = data.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(pagination, (data.value as any)?.data?.total);
}, [data]);
// ===== 表格列配置 =====
const { columns } = useParamsColumns();
// ===== 计算属性 =====
const hasFilter = computed(() => {
return debouncedParamCode.value.trim() !== '';
});
// ===== 方法 =====
const handleSearch = () => {
pagination.reset();
fetchList();
};
/** 重置(重置后自动查询) */
const handleReset = useThrottleFn(async () => {
filterForm.paramCode = '';
pagination.reset();
await fetchList();
}, 500);
/** 分页变更 */
const handlePageChange = (page: number, pageSize: number) => {
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
nextTick().then(() => fetchList());
};
return {
filterForm,
loading,
dataSource,
columns,
pagination,
hasFilter,
handleSearch,
handleReset,
handlePageChange,
fetchList,
};
}
@@ -45,7 +45,7 @@ export default defineComponent({
treeLoading.value = true;
try {
const res = await getPermissionTree(roleId);
if (res.code === '200' && res.data) {
if (res.code == 200 && res.data) {
rawApiTree.value = res.data;
const result = buildTreeData(res.data);
treeData.value = result.treeData;
@@ -5,7 +5,7 @@
.bottom {
display: flex;
justify-content: space-between;
justify-content: flex-end;
align-items: center;
margin-top: 16px;
padding-top: 12px;
@@ -19,7 +19,8 @@
.paginationRow {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
margin-top: 12px;
}
}
@@ -63,7 +63,7 @@ export default defineComponent({
roleId: String(props.roleId),
...(filterForm.text.trim() ? { text: filterForm.text.trim() } : {}),
});
if (res.code === '200') {
if (res.code == 200) {
setDataSource(res.data.list);
setPagination({ ...pagination.value, total: res.data.total });
} else {
@@ -152,25 +152,22 @@ export default defineComponent({
rowKey="phone"
/>
{/* 底部 */}
<div class={styles.bottom}>
{/* 分页 */}
<div class={styles.paginationRow}>
<div class={styles.total}>共 {pagination.value.total} 人</div>
<Button onClick={props.onClose}>关闭</Button>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
size="small"
onChange={handlePageChange}
/>
</div>
{/* 分页 */}
{pagination.value.total > pagination.value.pageSize && (
<div class={styles.paginationRow}>
<Pagination
current={pagination.value.current}
pageSize={pagination.value.pageSize}
total={pagination.value.total}
size="small"
showTotal={(total: number) => `共 ${total} 条`}
onChange={handlePageChange}
/>
</div>
)}
{/* 底部 */}
<div class={styles.bottom}>
<Button onClick={props.onClose}>关闭</Button>
</div>
</Modal>
);
},
+2 -2
View File
@@ -152,7 +152,7 @@ export function useRoleModel() {
menuIds,
});
}
if (res.code === '200') {
if (res.code == 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功');
handleCloseForm();
fetchList();
@@ -170,7 +170,7 @@ export function useRoleModel() {
const handleDelete = useThrottleFn(async (record: any) => {
try {
const res = await deleteRole(record.roleId);
if (res.code === '200') {
if (res.code == 200) {
message.success('删除成功');
fetchList();
} else {
@@ -71,7 +71,7 @@ export default defineComponent({
try {
const params: Record<string, string> = { page: '1', limit: '999', name: keyword.trim() };
const res = await getRoleList(params);
if (res.code === '200') {
if (res.code == 200) {
const list: TournamentAdminRolePageVO[] = res.data?.list || [];
setRoleOptions(
list.map((item) => ({
@@ -91,7 +91,7 @@ export default defineComponent({
const fetchInitialRoles = async () => {
try {
const res = await getRoleList({ page: '1', limit: '999' });
if (res.code === '200') {
if (res.code == 200) {
const list: TournamentAdminRolePageVO[] = res.data?.list || [];
const opts = list.map((item) => ({
value: String(item.roleId),
+9 -6
View File
@@ -52,7 +52,7 @@ function renderBodyCell({
)}
{canToggleStatus && (
<Button type="link" size="small" onClick={() => onToggleStatus(record)}>
{isActive ? '停用' : '启用'}
{isActive ? '禁用' : '启用'}
</Button>
)}
</Space>
@@ -92,17 +92,20 @@ export default defineComponent({
const [editingRecord, setEditingRecord] = useState<any>(null);
const [formSubmitting, setFormSubmitting] = useState<boolean>(false);
// ===== 启用/禁用(使用真实 API) =====
// ===== 启用/禁用 =====
const handleToggleStatus = useThrottleFn(async (record: any) => {
const isActive = record.status === '1';
const actionText = isActive ? '停用' : '启用';
const actionText = isActive ? '禁用' : '启用';
Modal.confirm({
title: `${actionText}确认`,
title: `确认${actionText}`,
content: (
<div class={pageStyles.confirmContent}>
<span>确定要{actionText}用户</span>
<strong>{record.realName}</strong>
<span>吗?</span>
<div class={pageStyles.confirmTip}>
{isActive ? '禁用后该用户将无法登录后台。' : '启用后该用户将恢复登录权限。'}
</div>
</div>
),
okText: '确认',
@@ -114,7 +117,7 @@ export default defineComponent({
userId: record.id,
status: isActive ? '0' : '1',
});
if (res.code === '200') {
if (res.code == 200) {
message.success(`${actionText}成功`);
handleSearch();
} else {
@@ -169,7 +172,7 @@ export default defineComponent({
confirmPassword: formPayload.password,
});
}
if (res.code === '200') {
if (res.code == 200) {
message.success(editingRecord.value ? '编辑成功' : '新增成功');
handleCloseModal();
handleSearch();
+1 -1
View File
@@ -41,7 +41,7 @@ const userUpdatePwd = '/admin/sys/user/updatepwd';
/**
* 用户列表分页
* 接口: GET /admin/sys/user/page
* 参数: page, limit, text(姓名/手机号), roleId(角色ID), status(0=停用,1=正常)
* 参数: page, limit, text(姓名/手机号), roleId(角色ID), status(0=禁用,1=正常)
*/
export function getUserList(
params: UserListQueryParams,
+4 -4
View File
@@ -21,14 +21,14 @@ import { getRoleList, type TournamentAdminRolePageVO } from '../../roles/model/s
/** 状态选项(筛选) */
export const USER_STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: '0', label: '停用' },
{ value: '0', label: '禁用' },
{ value: '1', label: '正常' },
] as const;
/** 状态文本/色调映射(表格渲染),对应 API status 字段:0=停用、1=正常 */
/** 状态文本/色调映射(表格渲染),对应 API status 字段:0=禁用、1=正常 */
const STATUS_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '正常', tone: 'success' },
'0': { label: '停用', tone: 'danger' },
'0': { label: '禁用', tone: 'danger' },
};
// ============================================================
@@ -53,7 +53,7 @@ export function useUserModel() {
const fetchRoleOptions = async () => {
try {
const res = await getRoleList({ page: '1', limit: '999' });
if (res.code === '200') {
if (res.code == 200) {
const list: TournamentAdminRolePageVO[] = res.data?.list || [];
const opts = list.map((item) => ({
value: String(item.roleId),
+33
View File
@@ -0,0 +1,33 @@
@import url('//at.alicdn.com/t/c/font_5142813_uugy4s5x1d.css');
/* 全局样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
overflow-x: hidden;
max-width: 100%;
overscroll-behavior-y: none;
touch-action: pan-x;
height: 100%;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', Arial, sans-serif;
background-color: #f0f2f5;
}
#app {
height: 100vh;
}
img {
-webkit-user-drag: none;
user-drag: none;
}
+1 -1
View File
@@ -1,4 +1,4 @@
declare module 'china-area-data' {
declare module 'china-area-data/data.json' {
const data: Record<string, Record<string, string>>;
export default data;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import areaData from 'china-area-data';
import areaData from 'china-area-data/data.json';
export interface CascadeOption {
value: string;
+3 -3
View File
@@ -34,8 +34,8 @@ export const REAL_NAME_REGEX = /^[\u4e00-\u9fa5A-Za-z0-9·]{1,8}$/;
export const IMAGE_TYPE_REGEX = /^image\//;
/**
* 纯数字(整数)
* - 用于过滤非数字字符
* 非数字字符
* - 用于过滤 0-9 以外的字符(含小数点、字母、符号等)
*/
export const INTEGER_ONLY_REGEX = /\D/g;
@@ -63,7 +63,7 @@ export function isImageFile(file: { type: string }): boolean {
return IMAGE_TYPE_REGEX.test(file.type);
}
/** 过滤非数字字符,只保留整数 */
/** 只保留数字字符 */
export function toIntegerOnly(value: unknown): string {
return String(value ?? '').replace(INTEGER_ONLY_REGEX, '');
}
+1 -1
View File
@@ -94,7 +94,7 @@ const request = (
const data = await res.json();
if (data.code === '401') {
if (data.code == 401) {
handleUnauthorized(data.msg);
return Promise.reject(new Error(data.msg || 'Unauthorized'));
}
+2
View File
@@ -7,6 +7,7 @@ import {
AiOutlineMoneyCollect,
AiOutlinePayCircle,
AiOutlinePicture,
AiOutlineRobot,
AiOutlineSafetyCertificate,
AiOutlineSetting,
AiOutlineShoppingCart,
@@ -35,6 +36,7 @@ const ROUTE_ICON_MAP: Record<string, IconType> = {
AiOutlineUser,
AiOutlineSafetyCertificate,
AiOutlineAudit,
AiOutlineRobot,
};
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;

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