From e67731a7cd728e18aefe45fc9449a2647940065a Mon Sep 17 00:00:00 2001 From: Disciple_C Date: Thu, 22 Oct 2026 11:03:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ai=20=E6=8A=A5=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AI平台运营对接接口清单.md | 328 ---------------- src/main.ts | 2 +- .../components/BillingReportTab.module.less | 25 ++ .../ai-agent/components/BillingReportTab.tsx | 368 ++++++++++++++++++ src/pages/system/ai-agent/index.tsx | 5 + src/pages/system/ai-agent/model/services.ts | 64 +++ src/styles.less | 33 ++ 7 files changed, 496 insertions(+), 329 deletions(-) delete mode 100644 AI平台运营对接接口清单.md create mode 100644 src/pages/system/ai-agent/components/BillingReportTab.module.less create mode 100644 src/pages/system/ai-agent/components/BillingReportTab.tsx create mode 100644 src/styles.less diff --git a/AI平台运营对接接口清单.md b/AI平台运营对接接口清单.md deleted file mode 100644 index baa6928..0000000 --- a/AI平台运营对接接口清单.md +++ /dev/null @@ -1,328 +0,0 @@ -# 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>` - -```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`("配置已更新") -- 🚧 注意:**目前没有"新增/删除场景配置"接口**(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>`,行含 `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`("调点成功") - -#### ⑤ 流水分页 - -- `GET /admin/manager/ai/points/transactions` -- 权限:`ai.points.view` -- Query:`page`、`size`、`userId`、`bizType`(场景)、`type`(1充值/2调账/3消费/4欠款)、`date`(yyyy-MM-dd) -- 返回:`Result>`,行含 `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>` - -```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`("已拉黑(对话与导入入口即时拦截)") -- 前端建议同调点:userId 用**用户选择器**选中回填,不要裸文本输入 - -#### ⑬ 解除拉黑 - -- `POST /admin/manager/ai/blacklist/remove` -- 权限:`ai.blacklist.edit`(留痕) -- Body:`{"userId": 123}`;幂等(无拉黑记录也返回成功) -- 返回:`Result`("已解除拉黑") - -**被拦截用户看到的表现**(前端可据此定制文案): - -- 创建/续接会话: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>` - -```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`("已保存(最长 30 秒生效)") - -#### ⑩ 模型字典-重置指定键 - -- `POST /admin/manager/ai/model/dict/reset` -- 权限:`ai.model.edit`(留痕) -- Body:`{"labels": ["extract.model", "failover.apiKey"]}`(须在白名单内) -- 语义:删除字典行,该键回落后一级(用途组 → 全局默认字典 → 配置文件);`failover.model` 重置=故障转移整体关闭 -- 返回:`Result`("已重置 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/*`。运营平台页面不调用这组接口。 diff --git a/src/main.ts b/src/main.ts index 2c16dae..c2a3140 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { createApp } from 'vue'; import Antd from 'ant-design-vue'; import 'ant-design-vue/dist/reset.css'; - +import './styles.less'; import App from './App'; import router from './router'; import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载 diff --git a/src/pages/system/ai-agent/components/BillingReportTab.module.less b/src/pages/system/ai-agent/components/BillingReportTab.module.less new file mode 100644 index 0000000..067001c --- /dev/null +++ b/src/pages/system/ai-agent/components/BillingReportTab.module.less @@ -0,0 +1,25 @@ +// ===== 高频标记 ===== +.highFreqTag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + background: #fff1f0; + color: #ff4d4f; + border: 1px solid #ffccc7; +} + +.normalTag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background: #f0f0f0; + color: rgba(0, 0, 0, 0.45); + border: 1px solid #d9d9d9; +} diff --git a/src/pages/system/ai-agent/components/BillingReportTab.tsx b/src/pages/system/ai-agent/components/BillingReportTab.tsx new file mode 100644 index 0000000..50ed9ad --- /dev/null +++ b/src/pages/system/ai-agent/components/BillingReportTab.tsx @@ -0,0 +1,368 @@ +import { defineComponent, reactive, ref, computed } from 'vue'; +import { Button, Table, Space, DatePicker, InputNumber, Radio, Pagination } from 'ant-design-vue'; +import { type Dayjs } from 'dayjs'; +import { useEffect } from '@/hooks/useEffect'; +import { useContainerSize } from '@/hooks'; +import { + getAiBillingReport, + getAiFreeformReport, + getAiAgentList, + type AiBillingReportVO, + type AiFreeformReportVO, + type AiAgentConfigVO, +} from '../model/services'; +import styles from './BillingReportTab.module.less'; +import pageStyles from '@/assets/styles/pageLayout.module.less'; + +// ===== 工具函数 ===== + +/** 格式化数字(千分位),兼容后端返回字符串类型 */ +const formatNum = (val: unknown): string => { + const n = Number(val); + if (val == null || Number.isNaN(n)) return '-'; + return n.toLocaleString('zh-CN'); +}; + +/** 安全的文本渲染:null/undefined/空 → '-' */ +const renderText = (text: unknown): string => (text != null && text !== '' ? String(text) : '-'); + +/** 等宽字体渲染(用于指纹、签名等哈希值) */ +const renderMono = (text: string) => ( + + {renderText(text)} + +); + +// ===== 场景名称映射 ===== + +const useBizTypeMap = () => { + const map = ref>({}); + useEffect(() => { + (async () => { + try { + const res = await getAiAgentList(); + if (res.code == 200 && res.data) { + const m: Record = {}; + res.data.forEach((item: AiAgentConfigVO) => { + m[item.bizType] = item.name; + }); + map.value = m; + } + } catch { + // ignore + } + })(); + }, []); + return map; +}; + +// ===== 组件 ===== + +export default defineComponent({ + name: 'BillingReportTab', + setup() { + type SubView = 'billing' | 'freeform'; + const subView = ref('billing'); + const bizTypeMap = useBizTypeMap(); + const { containerRef, height } = useContainerSize({ headerOffset: 55 }); + + // ===== 计费报表(前端分页) ===== + const billingLoading = ref(false); + const billingData = ref([]); + const billingFilter = reactive({ + startDate: null as Dayjs | null, + endDate: null as Dayjs | null, + }); + const billingPage = reactive({ current: 1, pageSize: 20 }); + const billingPagedData = computed(() => { + const start = (billingPage.current - 1) * billingPage.pageSize; + return billingData.value.slice(start, start + billingPage.pageSize); + }); + + const fetchBilling = async () => { + billingLoading.value = true; + try { + const params: Record = {}; + if (billingFilter.startDate) + params.startDate = billingFilter.startDate.format('YYYY-MM-DD'); + if (billingFilter.endDate) params.endDate = billingFilter.endDate.format('YYYY-MM-DD'); + const res = await getAiBillingReport(params); + billingData.value = res.code == 200 && res.data ? res.data : []; + } catch { + billingData.value = []; + } finally { + billingLoading.value = false; + } + }; + + // 组件初始化时立即加载 + fetchBilling(); + + const onBillingReset = () => { + billingFilter.startDate = null; + billingFilter.endDate = null; + billingPage.current = 1; + fetchBilling(); + }; + + const onBillingPageChange = (page: number, pageSize: number) => { + billingPage.current = page; + billingPage.pageSize = pageSize; + }; + + const billingColumns = [ + { + title: '日期', + dataIndex: 'date', + key: 'date', + width: 130, + align: 'center' as const, + sorter: (a: AiBillingReportVO, b: AiBillingReportVO) => + (a.date || '').localeCompare(b.date || ''), + defaultSortOrder: 'descend' as const, + }, + { + title: '业务场景', + dataIndex: 'bizType', + key: 'bizType', + width: 160, + align: 'center' as const, + customRender: ({ text }: { text: string }) => bizTypeMap.value[text] || text || '-', + }, + { + title: '消息数', + dataIndex: 'messageCount', + key: 'messageCount', + width: 120, + align: 'center' as const, + sorter: (a: AiBillingReportVO, b: AiBillingReportVO) => + Number(a.messageCount) - Number(b.messageCount), + customRender: ({ text }: { text: number }) => formatNum(text), + }, + { + title: '总 Token', + dataIndex: 'totalTokens', + key: 'totalTokens', + width: 140, + align: 'center' as const, + sorter: (a: AiBillingReportVO, b: AiBillingReportVO) => + Number(a.totalTokens) - Number(b.totalTokens), + customRender: ({ text }: { text: number }) => formatNum(text), + }, + { + title: '消耗积分', + dataIndex: 'totalPoints', + key: 'totalPoints', + width: 140, + align: 'center' as const, + sorter: (a: AiBillingReportVO, b: AiBillingReportVO) => + Number(a.totalPoints) - Number(b.totalPoints), + customRender: ({ text }: { text: number }) => ( + {formatNum(text)} + ), + }, + ]; + + // ===== 直解报表 ===== + const freeformLoading = ref(false); + const freeformData = ref([]); + const freeformTop = ref(50); + const freeformLoaded = ref(false); + + const fetchFreeform = async () => { + freeformLoading.value = true; + try { + const res = await getAiFreeformReport({ top: freeformTop.value }); + freeformData.value = res.code == 200 && res.data ? res.data : []; + } catch { + freeformData.value = []; + } finally { + freeformLoading.value = false; + freeformLoaded.value = true; + } + }; + + // 切换到直解格式统计时自动加载(首次) + useEffect(() => { + if (subView.value === 'freeform' && !freeformLoaded.value) { + fetchFreeform(); + } + }, [subView]); + + const onFreeformReset = () => { + freeformTop.value = 50; + fetchFreeform(); + }; + + const freeformColumns = [ + { + title: '文档指纹', + dataIndex: 'fingerprint', + key: 'fingerprint', + width: 200, + align: 'left' as const, + ellipsis: true, + customRender: ({ text }: { text: string }) => renderMono(text), + }, + { + title: '文档性质', + dataIndex: 'docNature', + key: 'docNature', + width: 120, + align: 'center' as const, + customRender: ({ text }: { text: string }) => renderText(text), + }, + { + title: '信号集', + dataIndex: 'signalSet', + key: 'signalSet', + width: 120, + align: 'center' as const, + customRender: ({ text }: { text: string }) => renderText(text), + }, + { + title: '表头签名', + dataIndex: 'headerSignature', + key: 'headerSignature', + width: 180, + align: 'left' as const, + ellipsis: true, + customRender: ({ text }: { text: string }) => renderMono(text), + }, + { + title: '使用次数', + dataIndex: 'useCount', + key: 'useCount', + width: 100, + align: 'center' as const, + sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) => + Number(a.useCount) - Number(b.useCount), + defaultSortOrder: 'descend' as const, + customRender: ({ text }: { text: number }) => formatNum(text), + }, + { + title: '最后使用', + dataIndex: 'lastUsedAt', + key: 'lastUsedAt', + width: 180, + align: 'center' as const, + }, + { + title: '频次', + dataIndex: 'highFrequency', + key: 'highFrequency', + width: 100, + align: 'center' as const, + sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) => + Number(a.highFrequency) - Number(b.highFrequency), + customRender: ({ text }: { text: boolean }) => + text ? ( + 高频 + ) : ( + 普通 + ), + }, + ]; + + return () => ( + <> + {/* 筛选栏 */} +
+ (subView.value = val)} + optionType="button" + buttonStyle="solid" + > + 计费报表 + 直解格式统计 + + + {subView.value === 'billing' ? ( + + (billingFilter.startDate = val ?? null)} + format="YYYY-MM-DD" + placeholder="开始日期" + allowClear + /> + (billingFilter.endDate = val ?? null)} + format="YYYY-MM-DD" + placeholder="结束日期" + allowClear + /> + + + + ) : ( + + 返回行数: + + (freeformTop.value = typeof val === 'number' ? val : 50) + } + min={1} + max={500} + step={10} + precision={0} + placeholder="50" + style={{ width: 100 }} + /> + + + + )} +
+ + {/* 表格区 */} +
+
+ {subView.value === 'billing' ? ( + `${record.date}_${record.bizType}`} + scroll={{ x: 'max-content', y: height.value }} + pagination={false} + size="middle" + /> + ) : ( +
+ )} + + {subView.value === 'billing' && ( +
+ `共 ${total} 条`} + pageSizeOptions={['10', '20', '50', '100']} + onChange={onBillingPageChange} + /> +
+ )} + + + ); + }, +}); diff --git a/src/pages/system/ai-agent/index.tsx b/src/pages/system/ai-agent/index.tsx index b22f9cd..c5237ad 100644 --- a/src/pages/system/ai-agent/index.tsx +++ b/src/pages/system/ai-agent/index.tsx @@ -8,6 +8,7 @@ import AiAgentEditModal from './components/AiAgentEditModal'; import AiModelDict from './components/AiModelDict'; import BlacklistTab from './components/BlacklistTab'; import PointsTab from './components/PointsTab'; +import BillingReportTab from './components/BillingReportTab'; import type { AiAgentConfigVO } from './model/services'; export default defineComponent({ @@ -100,6 +101,10 @@ export default defineComponent({ + + + + {editModalVisible.value && ( diff --git a/src/pages/system/ai-agent/model/services.ts b/src/pages/system/ai-agent/model/services.ts index 96213bb..1c7042f 100644 --- a/src/pages/system/ai-agent/model/services.ts +++ b/src/pages/system/ai-agent/model/services.ts @@ -245,6 +245,54 @@ export interface DictDataDeleteParams { id: number; } +// ===== 报表相关(文档 §1.4) ===== + +/** 计费报表行 VO(§1.4 ⑥ GET /admin/manager/ai/billing/report) */ +export interface AiBillingReportVO { + /** 日期 yyyy-MM-dd */ + date: string; + /** 业务场景 */ + bizType: string; + /** 消息数 */ + messageCount: number; + /** 总 token 数 */ + totalTokens: number; + /** 总消耗积分 */ + totalPoints: number; +} + +/** 直解格式统计报表行 VO(§1.4 ⑦ GET /admin/manager/ai/freeform/report) */ +export interface AiFreeformReportVO { + /** 文档指纹 */ + fingerprint: string; + /** 文档性质 */ + docNature: string; + /** 信号集 */ + signalSet: string; + /** 表头签名 */ + headerSignature: string; + /** 使用次数 */ + useCount: number; + /** 最后使用时间 */ + lastUsedAt: string; + /** 是否高频(月频次≥5) */ + highFrequency: boolean; +} + +/** 计费报表查询参数(§1.4 ⑥) */ +export interface AiBillingReportQueryParams { + /** 可选:开始日期 yyyy-MM-dd */ + startDate?: string; + /** 可选:结束日期 yyyy-MM-dd */ + endDate?: string; +} + +/** 直解报表查询参数(§1.4 ⑦) */ +export interface AiFreeformReportQueryParams { + /** 返回行数,默认 50 */ + top?: number; +} + const AI_AGENT_LIST = '/admin/manager/ai/agent/list'; const AI_AGENT_UPDATE = '/admin/manager/ai/agent/update'; const AI_MODEL_DICT_GET = '/admin/manager/ai/model/dict'; @@ -261,6 +309,8 @@ const DICT_TYPES = '/admin/manager/dict/types'; const DICT_DATA_LIST = '/admin/manager/dict/data/list'; const DICT_DATA_SAVE = '/admin/manager/dict/data/save'; const DICT_DATA_DELETE = '/admin/manager/dict/data/delete'; +const AI_BILLING_REPORT = '/admin/manager/ai/billing/report'; +const AI_FREEFORM_REPORT = '/admin/manager/ai/freeform/report'; /** 单价字典类型标识 */ export const AI_BILLING_PRICE_DICT_TYPE = 'ai_billing_price'; @@ -350,3 +400,17 @@ export function saveDictData(params: DictDataSaveParams): Promise> { return post(DICT_DATA_DELETE, { id }); } + +/** 计费报表(§1.4 ⑥,按日按场景汇总) */ +export function getAiBillingReport( + params: AiBillingReportQueryParams, +): Promise> { + return get(AI_BILLING_REPORT, params as Record); +} + +/** 直解格式统计报表(§1.4 ⑦) */ +export function getAiFreeformReport( + params: AiFreeformReportQueryParams, +): Promise> { + return get(AI_FREEFORM_REPORT, params as Record); +} diff --git a/src/styles.less b/src/styles.less new file mode 100644 index 0000000..123438f --- /dev/null +++ b/src/styles.less @@ -0,0 +1,33 @@ +@import url('//at.alicdn.com/t/c/font_5142813_uugy4s5x1d.css'); + +/* 全局样式 */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + overflow-x: hidden; + max-width: 100%; + overscroll-behavior-y: none; + touch-action: pan-x; + height: 100%; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', + 'Microsoft YaHei', Arial, sans-serif; + background-color: #f0f2f5; +} + +#app { + height: 100vh; +} + +img { + -webkit-user-drag: none; + user-drag: none; +}