feat: 运营平台 ai 口子调整
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# 开发环境配置
|
# 开发环境配置
|
||||||
# 基础API地址
|
# 基础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
|
LOCAL_DEBUG=true
|
||||||
|
|||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
# AI 平台运营对接接口清单(给前端)
|
||||||
|
|
||||||
|
> 版本:v1.0(2026-10-15) 后端模块:cpms-operations-platform(运营管理端)
|
||||||
|
> 后端路由前缀:`admin/manager/ai` 鉴权:角色 `coo` + `ai:*` 权限(Shiro)
|
||||||
|
> 状态说明:✅=后端已有、前端待对接;🚧=后端缺口(需后端开发后再给前端对接)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 已有接口(✅ 后端已实现,前端可直接对接)
|
||||||
|
|
||||||
|
### 1.1 AI 场景配置(ai_agent_config)
|
||||||
|
|
||||||
|
运营实时配置:场景开关 / 计费开关 / 倍率 / 每日免费额。改动**实时生效**(无缓存)。
|
||||||
|
|
||||||
|
#### ① 场景配置列表
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/agent/list`
|
||||||
|
- 权限:`ai.agent.view`
|
||||||
|
- 参数:无
|
||||||
|
- 返回:`Result<List<AiAgentConfigVO>>`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"bizType": "matchImport", // 业务类型(唯一键)
|
||||||
|
"name": "对阵导入助手", // 展示名
|
||||||
|
"enabled": 1, // 业务开关 1/0(0=前端隐藏入口、会话创建被拒)
|
||||||
|
"billingEnabled": 1, // 计费开关 1/0(0=该场景免费)
|
||||||
|
"billingMultiplier": 1.0, // 计费倍率(>0,促销可调)
|
||||||
|
"dailyFreePoints": 10, // 每日免费额度(口径=每日免费结算条数;0=无免费额)
|
||||||
|
"descr": "运营备注"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ② 更新场景配置
|
||||||
|
|
||||||
|
- `POST /admin/manager/ai/agent/update`
|
||||||
|
- 权限:`ai.agent.edit`(操作留痕 LogOperation)
|
||||||
|
- Body(JSON,id 必填,其余传了才更新):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"enabled": 1,
|
||||||
|
"billingEnabled": 1,
|
||||||
|
"billingMultiplier": 1.5,
|
||||||
|
"dailyFreePoints": 20,
|
||||||
|
"descr": "双11促销"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 校验:倍率必须 >0;免费额 ≥0
|
||||||
|
- 返回:`Result<String>`("配置已更新")
|
||||||
|
- 🚧 注意:**目前没有"新增/删除场景配置"接口**(bizType 由代码注册,见 §3.2)
|
||||||
|
|
||||||
|
### 1.2 积分账户与流水(ai_points_account / ai_points_transaction)
|
||||||
|
|
||||||
|
#### ③ 账户分页
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/points/accounts`
|
||||||
|
- 权限:`ai.points.view`
|
||||||
|
- Query:`page`(默认1)、`size`(默认10)、`userId`(可选)
|
||||||
|
- 返回:`Result<PageData<AiPointsAccountVO>>`,行含 `userId / points(余额,可为负=欠款) / totalRecharged / totalConsumed`
|
||||||
|
- 口径说明:**筛选目前仅支持 userId**(接口注解里的"手机号筛选"未实现,勿按手机号出筛选项;如需后端补关联查询另行排期)
|
||||||
|
- 账户产生时机:初始表为空属正常态——用户**首次对话预检/结算或首次被调点**时自动建户(余额 0 起户)
|
||||||
|
|
||||||
|
#### ④ 手工调点(充值/纠错)
|
||||||
|
|
||||||
|
- `POST /admin/manager/ai/points/adjust`
|
||||||
|
- 权限:`ai.points.adjust`(留痕)
|
||||||
|
- **自动建户**:目标用户无账户时自动创建(0 余额起户后再原子加点,并发安全)——无需先引导用户消费
|
||||||
|
- ⚠️ **userId 不校验真实性**:后端不校验 userId 是否存在于用户表,输错会静默创建孤儿账户——
|
||||||
|
前端调点弹窗务必做成**用户选择器**(按 userId/昵称搜索选中回填),不要裸文本输入
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 123, // 必填
|
||||||
|
"points": 100, // 必填,非0;正=充/赠,负=扣
|
||||||
|
"type": 1, // 可选 1=充值/赠送 2=调账;缺省按正负推导
|
||||||
|
"descr": "客诉补偿" // 可选,审计备注
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 返回:`Result<String>`("调点成功")
|
||||||
|
|
||||||
|
#### ⑤ 流水分页
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/points/transactions`
|
||||||
|
- 权限:`ai.points.view`
|
||||||
|
- Query:`page`、`size`、`userId`、`bizType`(场景)、`type`(1充值/2调账/3消费/4欠款)、`date`(yyyy-MM-dd)
|
||||||
|
- 返回:`Result<PageData<AiPointsTxnVO>>`,行含 `userId / bizType / type / changePoints / inputTokens / outputTokens / model / messageId / freeFlag / descr / createDate`
|
||||||
|
|
||||||
|
### 1.3 AI 使用黑名单(✅ 后端已实现,前端可直接对接)
|
||||||
|
|
||||||
|
拉黑期间禁止用户使用 AI 平台能力(对话三助手 + 对阵导入)。生效口径:`status=1` 且(未设到期时间或未到期);**解除不物理删**(status 置 0 保留历史),再次拉黑原地置回。拉黑**即时生效**(入口逐请求查库,无缓存)。
|
||||||
|
|
||||||
|
#### ⑪ 黑名单分页
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/blacklist/list`
|
||||||
|
- 权限:`ai.blacklist.view`
|
||||||
|
- Query:`page`(默认1)、`size`(默认10)、`userId`(可选)、`status`(可选 1/0)
|
||||||
|
- 返回:`Result<PageData<AiBlacklistVO>>`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"userId": 123,
|
||||||
|
"reason": "恶意刷 AI(敏感信息预警 3 次)",
|
||||||
|
"status": 1,
|
||||||
|
"expireDate": "2026-11-15 00:00:00", // null=永久
|
||||||
|
"operatorId": 88,
|
||||||
|
"createDate": "...",
|
||||||
|
"updateDate": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⑫ 拉黑
|
||||||
|
|
||||||
|
- `POST /admin/manager/ai/blacklist/add`
|
||||||
|
- 权限:`ai.blacklist.edit`(留痕)
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"userId": 123, // 必填
|
||||||
|
"reason": "恶意刷 AI", // 必填(审计要求)
|
||||||
|
"expireDate": "2026-11-15 00:00:00" // 可选(yyyy-MM-dd[ HH:mm:ss]);空=永久;必须晚于当前
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 返回:`Result<String>`("已拉黑(对话与导入入口即时拦截)")
|
||||||
|
- 前端建议同调点:userId 用**用户选择器**选中回填,不要裸文本输入
|
||||||
|
|
||||||
|
#### ⑬ 解除拉黑
|
||||||
|
|
||||||
|
- `POST /admin/manager/ai/blacklist/remove`
|
||||||
|
- 权限:`ai.blacklist.edit`(留痕)
|
||||||
|
- Body:`{"userId": 123}`;幂等(无拉黑记录也返回成功)
|
||||||
|
- 返回:`Result<String>`("已解除拉黑")
|
||||||
|
|
||||||
|
**被拦截用户看到的表现**(前端可据此定制文案):
|
||||||
|
|
||||||
|
- 创建/续接会话:HTTP 403 JSON(`Result.error`,msg="您已被限制使用 AI 功能,如有疑问请联系管理员")
|
||||||
|
- 对话 SSE:`error` 事件 code=403,同文案
|
||||||
|
- 导入(建任务/接管/提取/矩阵学习/提交):HTTP 500 JSON 业务异常,同文案
|
||||||
|
|
||||||
|
### 1.4 报表
|
||||||
|
|
||||||
|
#### ⑥ 计费报表(按日按场景汇总)
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/billing/report`
|
||||||
|
- 权限:`ai.points.view`
|
||||||
|
- Query:`startDate`、`endDate`(均可选,yyyy-MM-dd)
|
||||||
|
- 返回行:`{ date, bizType, messageCount, totalTokens, totalPoints }`(按日期降序;消费+欠款流水聚合)
|
||||||
|
|
||||||
|
#### ⑦ 直解格式统计报表
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/freeform/report?top=50`
|
||||||
|
- 权限:`ai.points.view`
|
||||||
|
- 返回行:`{ fingerprint, docNature, signalSet, headerSignature, useCount, lastUsedAt, highFrequency }`
|
||||||
|
(`highFrequency`=月频次≥5 的文档指纹,确定性解析器需求评估依据)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据字典 ai_agent_model(🚧 运营平台**未对接**,当前直接改库)
|
||||||
|
|
||||||
|
### 2.1 现状(重要)
|
||||||
|
|
||||||
|
模型/网关/Key 的运行时热切换依赖**系统数据字典** `sys_dict_type` / `sys_dict_data`:
|
||||||
|
|
||||||
|
- `sys_dict_type.dict_type = 'ai_agent_model'`(取第一条该类型的 id)
|
||||||
|
- `sys_dict_data`:`dict_type_id` 关联上表,`dict_label → dict_value` 键值对,同 label 按 `sort` 升序取第一条
|
||||||
|
- **后端无任何字典维护接口**(全仓库无 SysDictType/DictData Controller),运营平台也没维护页——目前只能 DBA 直接改表
|
||||||
|
- 生效机制:后端本地 **30s TTL 缓存**,改库后最长 30s 生效,无需重启
|
||||||
|
|
||||||
|
### 2.2 字典键值契约(维护页按此渲染表单)
|
||||||
|
|
||||||
|
label 即配置键,value 即配置值。三个用途组,组内未配置的维度按「用途组 → 全局默认字典 → 配置文件 cpms.ai.*」回落:
|
||||||
|
|
||||||
|
| label(dict_label) | value(dict_value) | 说明 |
|
||||||
|
| ------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `model` | 如 `deepseek-flash` | **全局默认模型**(对话/排赛/导入助手+流式:未单独配置用途组的助手均回落此项) |
|
||||||
|
| `baseUrl` | OpenAI 兼容网关地址 | 全局默认网关;缺省时按模型取内置映射(deepseek-flash→api.deepseek.com、qwen3.7-flash→maas.qianwenaiapi.com),再回落配置文件 |
|
||||||
|
| `apiKey` | sk-xxx | 全局默认 API Key(**敏感**,前端展示需脱敏/改写场景) |
|
||||||
|
| `thinking` | on / off / default | 全局思考模式(按厂商自动映射参数,见模型配置指南 §5);default=跟随网关默认 |
|
||||||
|
| `steward.model` | 同上 | 赛事管家模型(2026-10-16 分离:管家以查询问答为主,可配轻量高并发模型);整组未配置时回落全局默认 |
|
||||||
|
| `steward.baseUrl` | 同上 | 赛事管家网关 |
|
||||||
|
| `steward.apiKey` | 同上 | 赛事管家 Key |
|
||||||
|
| `steward.thinking` | 同 thinking | 管家思考模式;未配置回落全局 `thinking` |
|
||||||
|
| `extract.model` | 同上 | 提取组模型(导入直解提取+诊断);整组未配置时回落全局默认 |
|
||||||
|
| `extract.baseUrl` | 同上 | 提取组网关 |
|
||||||
|
| `extract.apiKey` | 同上 | 提取组 Key |
|
||||||
|
| `extract.thinking` | 同 thinking | 提取组思考模式;未配置回落全局 `thinking` |
|
||||||
|
| `failover.model` | 同上 | 备用网关模型;**未配置=故障转移功能整体关闭** |
|
||||||
|
| `failover.baseUrl` | 同上 | 备用网关地址 |
|
||||||
|
| `failover.apiKey` | 同上 | 备用网关 Key |
|
||||||
|
| `failover.thinking` | 同 thinking | 备用网关思考模式;未配置回落全局 `thinking` |
|
||||||
|
|
||||||
|
其余运行参数(temperature/超时/maxTokens/maxToolHops/流式开关等)在配置文件 `cpms.ai.*`,不在字典范围。
|
||||||
|
|
||||||
|
### 2.3 接口(✅ 后端已实现,前端可直接对接)
|
||||||
|
|
||||||
|
#### ⑧ 模型字典-读当前配置
|
||||||
|
|
||||||
|
- `GET /admin/manager/ai/model/dict`
|
||||||
|
- 权限:`ai.model.view`
|
||||||
|
- 参数:无。白名单 16 键**全量返回**(未配置的键 value=null,前端按此渲染完整表单)
|
||||||
|
- 返回:`Result<List<AiModelDictVO>>`
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "extract.apiKey",
|
||||||
|
"value": "sk-****ab3f", // 敏感键脱敏(前3+****+后4);未配置 null
|
||||||
|
"sensitive": true, // true=apiKey 类:编辑时"留空=保留原值",禁止回传掩码
|
||||||
|
"purpose": "extract", // chat=对话/工具(全局默认) extract=导入提取+诊断 failover=备用网关
|
||||||
|
"remark": "提取组 API Key(敏感)" // 表单提示文案
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ⑨ 模型字典-整组保存(upsert)
|
||||||
|
|
||||||
|
- `POST /admin/manager/ai/model/dict`
|
||||||
|
- 权限:`ai.model.edit`(留痕;事务性)
|
||||||
|
- Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{ "label": "model", "value": "deepseek-flash" },
|
||||||
|
{ "label": "extract.apiKey", "value": "" } // 敏感键传空=保留原值
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 校验:label 必须在白名单 16 键内;`*model` 必填(要回落请用重置);`*baseUrl` 须 http(s) 开头;`*thinking` 取值 on/off/default;**`*apiKey` 留空或回传脱敏掩码(含 `****`)均视为"未修改、保留原值"**——前端可整表单原样提交,无需清空未编辑的 Key 字段
|
||||||
|
- 返回:`Result<String>`("已保存(最长 30 秒生效)")
|
||||||
|
|
||||||
|
#### ⑩ 模型字典-重置指定键
|
||||||
|
|
||||||
|
- `POST /admin/manager/ai/model/dict/reset`
|
||||||
|
- 权限:`ai.model.edit`(留痕)
|
||||||
|
- Body:`{"labels": ["extract.model", "failover.apiKey"]}`(须在白名单内)
|
||||||
|
- 语义:删除字典行,该键回落后一级(用途组 → 全局默认字典 → 配置文件);`failover.model` 重置=故障转移整体关闭
|
||||||
|
- 返回:`Result<String>`("已重置 N 项(最长 30 秒生效)")
|
||||||
|
|
||||||
|
**生效机制**:后端本地 30s TTL 缓存,保存/重置后最长 30 秒生效、无需重启。页面保存成功文案建议提示这一点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 其它后端缺口(🚧 排期后补充对接)
|
||||||
|
|
||||||
|
### 3.1 AI 审计查询(数据已落库、无运营查询接口)
|
||||||
|
|
||||||
|
运营排查客诉/审计需要,建议新增(只读,权限 `ai.audit.view`):
|
||||||
|
|
||||||
|
| 数据表 | 内容 | 建议接口 |
|
||||||
|
| -------------------- | ---------------------------------------------------------------------- | ------------------------------------------------- |
|
||||||
|
| `ai_chat_session` | 会话:userId/bizType/activeAgent/tournamentId/status/model/totalTokens | `GET /admin/manager/ai/chat/sessions`(分页筛选) |
|
||||||
|
| `ai_chat_message` | 消息审计:sessionId/role/agentCode/content(已脱敏)/tokens | `GET /admin/manager/ai/chat/messages?sessionId=` |
|
||||||
|
| `ai_chat_card_event` | 卡片事件:cardType/refId/payload/version/delivered | 可并入消息详情或独立查询 |
|
||||||
|
|
||||||
|
### 3.2 场景配置新增/下线
|
||||||
|
|
||||||
|
`ai_agent_config` 的 bizType 由代码注册(matchImport / tournamentSteward / scheduleAgent 等),目前运营只能改不能增删。若要页面化新增,需后端补 `agent/add` + 启动期注册校验。
|
||||||
|
|
||||||
|
### 3.3 模型单价维护(✅ 已上线:进数据字典 + 通用字典页维护)
|
||||||
|
|
||||||
|
单价已从配置文件升级为**数据字典热切换**(与模型配置同模式):
|
||||||
|
|
||||||
|
- 字典类型 `ai_billing_price`,label 契约:`{model}.input` / `{model}.output`(单价=点/千 token),以及兜底默认价 `default.input` / `default.output`
|
||||||
|
- **生效价回落链**:字典 → 配置文件 `cpms.ai.billing.models.{model}.*` → 内置默认(0.8/2.0);改字典最长 30s 生效
|
||||||
|
- 读接口:`GET /admin/manager/ai/billing/prices`(权限 `ai.points.view`)——模型集合=字典已配 ∪ 计费流水出现过的模型,返回 `[{model, inputPrice, outputPrice}]`(生效价);漏配模型也在列,便于发现
|
||||||
|
- 维护入口:**通用字典管理页**(§2.4)编辑 `ai_billing_price` 类型的数据行即可,无需 AI 专用页面
|
||||||
|
|
||||||
|
### 2.4 通用字典管理(✅ 已上线:全部 sys_dict 类型可维护,不限 AI)
|
||||||
|
|
||||||
|
独立控制器 `admin/manager/dict/*`,权限 `sys.dict.view` / `sys.dict.edit`(coo 角色):
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
| --------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET types` | 类型全量列表(含每类型数据行数,sort 升序) |
|
||||||
|
| `POST type/save` | `{id?, dictType, dictName, remark, sort}`;id 空=新建(dictType 必填且唯一);**dictType 建后不可改** |
|
||||||
|
| `POST type/delete` | `{id}`;级联删除该类型全部数据行(删 AI 字典=回落配置文件语义) |
|
||||||
|
| `GET data/list?dictTypeId=` | 该类型数据行(sort 升序) |
|
||||||
|
| `POST data/save` | `{id?, dictTypeId, dictLabel, dictValue, sort, remark}`;同 type 下 label 唯一(更新排除自身);更新不换 type |
|
||||||
|
| `POST data/delete` | `{id}`;删除后该键回落消费方下一级配置 |
|
||||||
|
|
||||||
|
返回行结构(VO):类型 `{id, dictType, dictName, remark, sort, dataCount}`;数据 `{id, dictTypeId, dictLabel, dictValue, sort, remark}`。
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
|
||||||
|
- 消费方(模型字典/单价/系统其它字典)均有 30s TTL 缓存,保存/删除后最长 30s 生效,页面提示沿用
|
||||||
|
- `ai_agent_model` 的 apiKey 类行在本页**明文展示**——敏感场景建议运营走 AI 专用页(`/admin/manager/ai/model/dict`,有脱敏与"留空=保留"保护);通用页保留明文是通用字典的语义(其他系统字典值无敏感问题)
|
||||||
|
|
||||||
|
### 3.4 调试工具(低优先级)
|
||||||
|
|
||||||
|
`/ai/debug/*`(赛程快照/校验/文件下载),仅在 `cpms.ai.debug-tools.enabled=true` 的环境暴露,带 secret 校验,暂无需前端页面。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 前端菜单与权限配置
|
||||||
|
|
||||||
|
| 菜单建议 | 权限标识 | 说明 |
|
||||||
|
| -------------------------- | ----------------------------------------- | -------------------------------------------------------- |
|
||||||
|
| AI 场景配置 | `ai.agent.view` / `ai.agent.edit` | 已有后端权限点 |
|
||||||
|
| 积分管理 | `ai.points.view` / `ai.points.adjust` | 已有后端权限点 |
|
||||||
|
| 计费报表 | `ai.points.view` | 复用 |
|
||||||
|
| 模型单价(✅ 已上线) | `ai.points.view` | `GET billing/prices`;编辑走通用字典页 |
|
||||||
|
| 直解报表 | `ai.points.view` | 复用 |
|
||||||
|
| AI 使用黑名单(✅ 已上线) | `ai.blacklist.view` / `ai.blacklist.edit` | 权限点需在运营平台权限表配置 |
|
||||||
|
| 通用字典管理(✅ 已上线) | `sys.dict.view` / `sys.dict.edit` | 覆盖全部 sys_dict 类型(含 AI 模型/单价字典) |
|
||||||
|
| 模型字典(✅ 已上线) | `ai.model.view` / `ai.model.edit` | AI 专用聚合视图(白名单校验/掩码);权限点需在权限表配置 |
|
||||||
|
| AI 审计(待后端) | `ai.audit.view` | 需后端随接口一并加 |
|
||||||
|
|
||||||
|
角色:以上接口全部要求 `coo` 角色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 参考:用户端 AI 接口(非运营平台,前端勿混用)
|
||||||
|
|
||||||
|
用户端(小程序/管理后台用户侧)AI 能力走 `cpms-admin` 的 `/ai/*`:会话 `/ai/session`、对话 SSE `/ai/chat`、历史回放 `/ai/session/messages`、点数 `/ai/points/balance|transactions`、拍板卡 `/ai/draft/*`、转接 `/ai/session/transfer`、对阵导入 `/ai/import/*`。运营平台页面不调用这组接口。
|
||||||
@@ -4,6 +4,32 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
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,可换行
|
// 页面筛选区域:横向 flex,可换行
|
||||||
.filter {
|
.filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -173,6 +173,15 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
|
|||||||
icon: 'AiOutlineSetting',
|
icon: 'AiOutlineSetting',
|
||||||
permission: 'system.params.view',
|
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',
|
id: 'system_logs',
|
||||||
name: '操作日志',
|
name: '操作日志',
|
||||||
|
|||||||
@@ -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,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,117 @@
|
|||||||
|
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 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>
|
||||||
|
</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,352 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** 单价字典类型标识 */
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AiOutlineMoneyCollect,
|
AiOutlineMoneyCollect,
|
||||||
AiOutlinePayCircle,
|
AiOutlinePayCircle,
|
||||||
AiOutlinePicture,
|
AiOutlinePicture,
|
||||||
|
AiOutlineRobot,
|
||||||
AiOutlineSafetyCertificate,
|
AiOutlineSafetyCertificate,
|
||||||
AiOutlineSetting,
|
AiOutlineSetting,
|
||||||
AiOutlineShoppingCart,
|
AiOutlineShoppingCart,
|
||||||
@@ -35,6 +36,7 @@ const ROUTE_ICON_MAP: Record<string, IconType> = {
|
|||||||
AiOutlineUser,
|
AiOutlineUser,
|
||||||
AiOutlineSafetyCertificate,
|
AiOutlineSafetyCertificate,
|
||||||
AiOutlineAudit,
|
AiOutlineAudit,
|
||||||
|
AiOutlineRobot,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;
|
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;
|
||||||
|
|||||||
Reference in New Issue
Block a user