Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e67731a7cd | |||
| 9e0cb3f27a | |||
| ae960df897 | |||
| 955914995c | |||
| 1e9b013065 | |||
| e21c1458ae | |||
| 5f6325689f | |||
| 48ab5d013b | |||
| ce1a5e3f1f | |||
| 8c023e63ec | |||
| a581f64ccb | |||
| 8c317d5b84 | |||
| 91d4410c2a | |||
| 045e491ade | |||
| 64fdaba93d | |||
| 50d5f54015 | |||
| a77592dec6 | |||
| 00867ff960 | |||
| 4c08bd1f8c | |||
| ab6f5c787e | |||
| 79a7ea8c56 | |||
| b5ba0ca3fb | |||
| 9b5e2807b5 | |||
| 3a30e81183 | |||
| 28386f5429 | |||
| b0b8689acb | |||
| 8012cb82fc | |||
| 73b2da2d1e | |||
| 6f8e5036f7 | |||
| daab8ec41a | |||
| b513d701d8 | |||
| f8eaa554b3 | |||
| 9cee70bc62 | |||
| ffd917815d | |||
| 3841fcc7e5 | |||
| ac7bfeacfe | |||
| d9a20c906b | |||
| 5300a1ec2e | |||
| 68820dfcea | |||
| d8ee7b3263 | |||
| 3f47005f76 | |||
| f0f0c88f18 | |||
| 0f611ad926 | |||
| ba71b504e7 | |||
| 117a7f408f | |||
| 05c3d518fa | |||
| ea24625805 | |||
| 845829d088 |
+2
-2
@@ -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
@@ -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
|
||||
|
||||
Generated
+1721
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -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": {
|
||||
|
||||
@@ -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 # 参数配置-查看
|
||||
Generated
+1045
-1
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@ export interface BannerActiveParams {
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
/** 响应数据 */
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
};
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface PageData<T> {
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
+6
-5
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface PageData<T> {
|
||||
|
||||
/** 统一响应包裹 */
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export interface PaymentFlowPageData {
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface ReportQueryParams {
|
||||
}
|
||||
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface PageData<T> {
|
||||
list: T[];
|
||||
}
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface PageData<T> {
|
||||
list: T[];
|
||||
}
|
||||
export interface ApiResult<T> {
|
||||
code: string;
|
||||
code: number | string;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/** 交易明细查询参数 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
|
||||
@@ -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: '操作日志',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || '操作失败');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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条/页' },
|
||||
];
|
||||
@@ -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 个字' }];
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
-1
@@ -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,4 +1,4 @@
|
||||
import areaData from 'china-area-data';
|
||||
import areaData from 'china-area-data/data.json';
|
||||
|
||||
export interface CascadeOption {
|
||||
value: string;
|
||||
|
||||
+3
-3
@@ -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, '');
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user