diff --git a/.env.development b/.env.development
index 03e2369..ee70122 100644
--- a/.env.development
+++ b/.env.development
@@ -1,6 +1,5 @@
# 开发环境配置
# 基础API地址
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
-
# 是否开启本地调试
LOCAL_DEBUG=true
diff --git a/package.json b/package.json
index 710375f..3f0af62 100644
--- a/package.json
+++ b/package.json
@@ -7,8 +7,8 @@
"scripts": {
"serve": "vite",
"serve:prod": "vite --mode production",
- "build:dev": "vue-tsc --noEmit && vite build --mode development",
- "build:test": "vue-tsc --noEmit && vite build --mode test",
+ "build:dev": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build --mode development",
+ "build:test": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build --mode test",
"build:prod": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build --mode production",
"test": "vitest",
"test:run": "vitest run",
@@ -23,16 +23,11 @@
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
- "@types/big.js": "^7.0.0",
"@yp-component/root": "^0.0.48",
"ant-design-vue": "^4.0.0",
- "big.js": "^7.0.1",
"china-area-data": "^5.0.1",
"dayjs": "^1.11.21",
"echarts": "^5.6.0",
- "express": "^5.2.1",
- "html2canvas": "^1.4.1",
- "qs": "^6.15.0",
"vue": "^3.3.0",
"vue-icons-plus": "^0.1.8",
"vue-router": "^4.2.0"
@@ -42,7 +37,6 @@
"@commitlint/config-conventional": "^20.4.3",
"@rollup/plugin-strip": "^3.0.4",
"@types/node": "^20.10.0",
- "@types/qs": "^6.15.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-vue": "^4.5.0",
diff --git a/src/assets/styles/pageLayout.module.less b/src/assets/styles/pageLayout.module.less
index b5ba3fb..16637dc 100644
--- a/src/assets/styles/pageLayout.module.less
+++ b/src/assets/styles/pageLayout.module.less
@@ -4,6 +4,32 @@
flex-direction: column;
height: 100%;
+ // Tabs 容器:撑满高度
+ .tabs {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+
+ :global {
+ .ant-tabs-content-holder {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .ant-tabs-content {
+ height: 100%;
+ }
+
+ .ant-tabs-tabpane {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+ }
+ }
+
// 页面筛选区域:横向 flex,可换行
.filter {
display: flex;
diff --git a/src/config/fallbackRoutes.ts b/src/config/fallbackRoutes.ts
index c74f954..af5d3a6 100644
--- a/src/config/fallbackRoutes.ts
+++ b/src/config/fallbackRoutes.ts
@@ -184,6 +184,15 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
icon: 'AiOutlineSetting',
permission: 'system.params.view',
},
+ {
+ id: 'system_ai_agent',
+ name: 'AI 场景配置',
+ path: 'system/ai-agent',
+ component: 'system/ai-agent',
+ componentName: 'SystemAiAgent',
+ icon: 'AiOutlineRobot',
+ permission: 'system.ai.view',
+ },
{
id: 'system_logs',
name: '操作日志',
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/login/index.tsx b/src/pages/login/index.tsx
index cb7f965..96fd597 100644
--- a/src/pages/login/index.tsx
+++ b/src/pages/login/index.tsx
@@ -57,8 +57,8 @@ export default defineComponent({
六个羽友赛事运营平台
diff --git a/src/pages/system/ai-agent/components/AiAgentEditModal.module.less b/src/pages/system/ai-agent/components/AiAgentEditModal.module.less
new file mode 100644
index 0000000..42082e6
--- /dev/null
+++ b/src/pages/system/ai-agent/components/AiAgentEditModal.module.less
@@ -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;
+}
diff --git a/src/pages/system/ai-agent/components/AiAgentEditModal.tsx b/src/pages/system/ai-agent/components/AiAgentEditModal.tsx
new file mode 100644
index 0000000..25f9429
--- /dev/null
+++ b/src/pages/system/ai-agent/components/AiAgentEditModal.tsx
@@ -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 () => (
+
+
+
+
+
+
+
+
+ {/* 业务开关 */}
+
+ (formState.enabled = !!checked)}
+ checkedChildren="开启"
+ unCheckedChildren="关闭"
+ />
+
+
+ {/* 计费开关 */}
+
+
+ (formState.billingEnabled = !!checked)
+ }
+ checkedChildren="计费"
+ unCheckedChildren="免费"
+ />
+
+
+ {/* 计费倍率 */}
+
+
+ (formState.billingMultiplier = typeof value === 'number' ? value : 1)
+ }
+ min={MIN_MULTIPLIER}
+ step={0.1}
+ precision={2}
+ style={{ width: '100%' }}
+ />
+ 倍率必须大于 0,如 1.0 = 原价,1.5 = 1.5 倍计费
+
+
+ {/* 每日免费额度 */}
+
+
+ (formState.dailyFreePoints = typeof value === 'number' ? value : 0)
+ }
+ min={0}
+ max={MAX_FREE_POINTS}
+ step={1}
+ precision={0}
+ style={{ width: '100%' }}
+ />
+ 每日免费结算条数,0 = 无免费额度
+
+
+ {/* 备注 */}
+
+
+
+ {/* 底部按钮 */}
+
+
+
+
+
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/components/AiModelDict.module.less b/src/pages/system/ai-agent/components/AiModelDict.module.less
new file mode 100644
index 0000000..bdfb0c2
--- /dev/null
+++ b/src/pages/system/ai-agent/components/AiModelDict.module.less
@@ -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;
+}
diff --git a/src/pages/system/ai-agent/components/AiModelDict.tsx b/src/pages/system/ai-agent/components/AiModelDict.tsx
new file mode 100644
index 0000000..011c449
--- /dev/null
+++ b/src/pages/system/ai-agent/components/AiModelDict.tsx
@@ -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 = {
+ chat: '全局默认',
+ steward: '赛事管家',
+ extract: '导入提取',
+ failover: '备用网关',
+};
+
+// 用途组竖线颜色
+const PURPOSE_BAR_COLOR: Record = {
+ chat: '#1677ff',
+ steward: '#52c41a',
+ extract: '#fa8c16',
+ failover: '#ff4d4f',
+};
+
+// 用途组描述
+const PURPOSE_DESC: Record = {
+ 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([]);
+ const formMap = reactive>({});
+
+ // ===== 加载数据 =====
+ 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 = {};
+ 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('已重置(最长 30 秒生效)');
+ fetchDict();
+ } else {
+ message.error(res.msg || '重置失败');
+ }
+ } catch {
+ message.error('重置失败');
+ }
+ },
+ });
+ };
+
+ const priceLoading = ref(false);
+ const priceSaving = ref(false);
+ /** 生效价列表(来自 billing/prices,仅展示用) */
+ const priceList = ref([]);
+ /** 字典数据行列表(来自 dict/data/list,含 id,用于保存/删除) */
+ const priceDictRows = ref([]);
+ /** ai_billing_price 的 dictTypeId */
+ const priceDictTypeId = ref(0);
+ /** 编辑态:key = model 名,value = { inputPrice, outputPrice } */
+ const priceFormMap = reactive>({});
+ /** 新增模型弹窗 */
+ 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();
+ 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('已重置(最长 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 (
+
+
+ {loading.value ? (
+
+
+
+ ) : !hasData.value ? (
+
+
+
+ ) : (
+ <>
+
+
+
+ 模型/网关/Key 运行时热切换配置,共 16 个白名单键。敏感键留空 = 保留原值;
+ model 类必填;保存后最长 30 秒生效。
+
+
+
+ {purposeOrder.map((purpose) => {
+ const items = groups[purpose];
+ if (!items || items.length === 0) return null;
+ const barColor = PURPOSE_BAR_COLOR[purpose] || '#1677ff';
+
+ return (
+
+ {/* 分组头部 */}
+
+
+ {/* 表格容器 */}
+
+ {/* 表头 */}
+
+
+ {/* 表行 */}
+ {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 (
+
+ );
+ })}
+
+
+ );
+ })}
+ >
+ )}
+
+
+
+
+
+ {/* 表头 */}
+
+
模型
+
输入单价(点/千token)
+
输出单价(点/千token)
+
操作
+
+
+ {/* 数据行 */}
+ {priceLoading.value ? (
+
+
+
+ ) : sortedModels.value.length === 0 ? (
+
+
+
+ ) : (
+ sortedModels.value.map((modelName) => {
+ const isDefault = modelName === DEFAULT_MODEL;
+ return (
+
+ );
+ })
+ )}
+
+
+
+ 生效价回落链:字典 → 配置文件 → 内置默认(输入 0.8 / 输出 2.0);改字典最长 30s 生效
+
+
+
+
+
(addModalVisible.value = false)}
+ title="新增模型单价"
+ centered
+ destroyOnClose
+ width={420}
+ footer={null}
+ >
+
+ (newModelName.value = val ?? '')}
+ placeholder="如 deepseek-flash"
+ onPressEnter={handleAddModel}
+ />
+
+ 新增后将初始化输入/输出单价为 0,可稍后编辑。default 模型为兜底默认价。
+
+
+
+
+
+
+
+
+ {hasData.value && !loading.value && (
+
+ )}
+
+ );
+ };
+ },
+});
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' && (
+
+ )}
+
+ >
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/components/BlacklistAddModal.module.less b/src/pages/system/ai-agent/components/BlacklistAddModal.module.less
new file mode 100644
index 0000000..42082e6
--- /dev/null
+++ b/src/pages/system/ai-agent/components/BlacklistAddModal.module.less
@@ -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;
+}
diff --git a/src/pages/system/ai-agent/components/BlacklistAddModal.tsx b/src/pages/system/ai-agent/components/BlacklistAddModal.tsx
new file mode 100644
index 0000000..0a61369
--- /dev/null
+++ b/src/pages/system/ai-agent/components/BlacklistAddModal.tsx
@@ -0,0 +1,177 @@
+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;
+ /** 预设用户信息(从列表行「再次拉黑」带入时使用) */
+ presetUser?: { userId: string; phone?: string; nickname?: string };
+ 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 },
+ presetUser: { type: Object, default: undefined },
+ onClose: { type: Function, default: null },
+ onSubmit: { type: Function, default: null },
+ },
+ setup(props: BlacklistAddModalProps) {
+ const form = reactive({
+ userId: undefined as string | undefined,
+ reason: '',
+ expireDate: null as Dayjs | null,
+ });
+
+ // 用户搜索
+ const userSearch = useUserSearch();
+
+ // 弹窗打开时重置表单 / 回填预设用户
+ // immediate: true 配合 destroyOnClose,组件重建后 visible 已为 true 时也能立即回填
+ watch(
+ () => props.visible,
+ (val) => {
+ if (val) {
+ form.reason = '';
+ form.expireDate = null;
+ userSearch.reset();
+ if (props.presetUser) {
+ // 从列表行「再次拉黑」带入:回填 userId 并锁定
+ form.userId = props.presetUser.userId;
+ userSearch.setPresetUser({
+ userId: props.presetUser.userId,
+ phone: props.presetUser.phone,
+ nickname: props.presetUser.nickname,
+ });
+ } else {
+ form.userId = undefined;
+ }
+ }
+ },
+ { immediate: true },
+ );
+
+ const handleSubmit = () => {
+ if (!form.userId) {
+ 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 () => (
+
+
+
+
+
+ (form.reason = val ?? '')}
+ placeholder="请输入拉黑原因(审计要求必填)"
+ rows={3}
+ maxlength={200}
+ showCount
+ />
+
+
+
+ (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'))}
+ />
+ 留空表示永久拉黑;选择日期则到指定时间自动解除
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/components/BlacklistTab.tsx b/src/pages/system/ai-agent/components/BlacklistTab.tsx
new file mode 100644
index 0000000..3a9a787
--- /dev/null
+++ b/src/pages/system/ai-agent/components/BlacklistTab.tsx
@@ -0,0 +1,169 @@
+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,
+ presetUser,
+ handleSearch,
+ handleReset,
+ handlePageChange,
+ openAddModal,
+ openAddModalWithRecord,
+ 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 (
+
+ );
+ }
+ return (
+
+ );
+ };
+
+ /** 完整表格列 */
+ const tableColumns = [
+ ...columns,
+ {
+ title: '操作',
+ key: 'action',
+ width: 120,
+ fixed: 'right' as const,
+ align: 'center' as const,
+ },
+ ];
+
+ return () => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ bodyCell: (args: { column: { key: string }; record: AiBlacklistVO }) => {
+ if (args.column.key === 'action') {
+ return renderAction(args.record);
+ }
+ return undefined;
+ },
+ }}
+
+
+
+
+
+ {addModalVisible.value && (
+
+ )}
+ >
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/components/PointsAdjustModal.module.less b/src/pages/system/ai-agent/components/PointsAdjustModal.module.less
new file mode 100644
index 0000000..42082e6
--- /dev/null
+++ b/src/pages/system/ai-agent/components/PointsAdjustModal.module.less
@@ -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;
+}
diff --git a/src/pages/system/ai-agent/components/PointsAdjustModal.tsx b/src/pages/system/ai-agent/components/PointsAdjustModal.tsx
new file mode 100644
index 0000000..68ad959
--- /dev/null
+++ b/src/pages/system/ai-agent/components/PointsAdjustModal.tsx
@@ -0,0 +1,171 @@
+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: string; 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 string | 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) {
+ 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 () => (
+
+
+ {
+ form.userId = val ? String(val) : undefined;
+ }}
+ allowClear
+ notFoundContent={
+ userSearch.searching.value
+ ? '搜索中...'
+ : userSearch.keyword.value
+ ? '无匹配用户'
+ : '请输入手机号或用户ID搜索'
+ }
+ />
+
+ 搜索手机号或用户ID选择用户,展示手机号,传用户ID给后端;目标用户无账户时系统自动建户
+
+
+
+
+ (form.points = typeof val === 'number' ? val : undefined)}
+ placeholder="正数=充/赠,负数=扣"
+ precision={0}
+ min={-10000000}
+ max={10000000}
+ style={{ width: '100%' }}
+ />
+
+ 正数 = 充值/赠送;负数 = 扣减;不能为 0;绝对值最大 10000000
+
+
+
+
+ (form.type = val ?? 1)}
+ />
+
+
+
+ (form.descr = val ?? '')}
+ placeholder="可选,如:客诉补偿"
+ rows={3}
+ maxlength={200}
+ showCount
+ />
+
+
+
+
+
+
+
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/components/PointsTab.tsx b/src/pages/system/ai-agent/components/PointsTab.tsx
new file mode 100644
index 0000000..97d4ae8
--- /dev/null
+++ b/src/pages/system/ai-agent/components/PointsTab.tsx
@@ -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 ? '无匹配用户' : '输入手机号或用户ID搜索';
+
+/** 分页组件公共配置 */
+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) => (
+
+
+
+
+ );
+
+ return () => (
+ <>
+ {/* ===== 工具栏:子视图切换 + 操作按钮 ===== */}
+
+
(subView.value = val)}
+ optionType="button"
+ buttonStyle="solid"
+ >
+ 积分账户
+ 积分流水
+
+
+
+ {subView.value === 'account' && (
+
+ )}
+
+
+
+
+ {subView.value === 'account' ? (
+
+ {
+ accountFilter.userId = val ?? '';
+ }}
+ allowClear
+ notFoundContent={renderNotFound(
+ accountSearch.searching.value,
+ accountSearch.keyword.value,
+ )}
+ />
+
+
+
+ ) : (
+
+ {
+ txnFilter.userId = val ?? '';
+ }}
+ allowClear
+ notFoundContent={renderNotFound(txnSearch.searching.value, txnSearch.keyword.value)}
+ />
+ (txnFilter.bizType = val ?? '')}
+ placeholder="业务场景"
+ style={{ width: '200px' }}
+ options={agentOptions.value as any}
+ allowClear
+ />
+ (txnFilter.type = val ?? '')}
+ placeholder="流水类型"
+ style={{ width: '140px' }}
+ options={POINTS_TXN_TYPE_OPTIONS as any}
+ />
+ {
+ txnFilter.date = val ? val.format('YYYY-MM-DD') : '';
+ }}
+ format="YYYY-MM-DD"
+ placeholder="日期"
+ style={{ width: 160 }}
+ allowClear
+ />
+
+
+
+ )}
+
+
+
+
+ {subView.value === 'account' ? (
+
+ {{
+ bodyCell: (args: { column: { key: string }; record: AiPointsAccountVO }) => {
+ if (args.column.key === 'action') {
+ return renderAccountAction(args.record);
+ }
+ return undefined;
+ },
+ }}
+
+ ) : (
+
+ `${record.userId}_${record.messageId}_${record.createDate}`
+ }
+ scroll={{ x: 'max-content', y: height.value }}
+ pagination={false}
+ />
+ )}
+
+
+
+
+ {adjustModalVisible.value && (
+
+ )}
+ >
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/hooks/useUserSearch.ts b/src/pages/system/ai-agent/hooks/useUserSearch.ts
new file mode 100644
index 0000000..8a4e5f5
--- /dev/null
+++ b/src/pages/system/ai-agent/hooks/useUserSearch.ts
@@ -0,0 +1,144 @@
+import { ref } from 'vue';
+import { getUserInfo, type UserInfoVO } from '../model/services';
+
+export interface UserSearchOption {
+ /** userId 作为 value(传给后端) */
+ value: string;
+ /** 展示文案:手机号(昵称) */
+ label: string;
+ /** 原始用户数据 */
+ raw: UserInfoVO;
+}
+
+export interface PresetUserInfo {
+ userId: string;
+ phone?: string;
+ nickname?: string;
+}
+
+/**
+ * 判断输入是否为手机号:
+ * 中国大陆手机号 — 以 1 开头的 11 位数字
+ */
+const PHONE_REGEX = /^1[3-9]\d{9}$/;
+
+/**
+ * 判断输入是否为雪花 ID:
+ * 雪花算法 ID 通常为 18~19 位纯数字
+ */
+const SNOWFLAKE_ID_REGEX = /^\d{17,20}$/;
+
+/** 判断输入类型:手机号 / 雪花ID / 无效 */
+export type SearchInputType = 'phone' | 'id' | 'unknown';
+
+export function detectInputType(input: string): SearchInputType {
+ const trimmed = input.trim();
+ if (!trimmed) return 'unknown';
+ if (PHONE_REGEX.test(trimmed)) return 'phone';
+ if (SNOWFLAKE_ID_REGEX.test(trimmed)) return 'id';
+ return 'unknown';
+}
+
+export function useUserSearch(delay = 300) {
+ /** 搜索关键字 */
+ const keyword = ref('');
+ /** Select 选项列表 */
+ const options = ref([]);
+ /** 搜索中状态 */
+ const searching = ref(false);
+
+ let timer: ReturnType | null = null;
+
+ const doSearch = async (val: string) => {
+ const trimmed = val.trim();
+ if (!trimmed) {
+ options.value = [];
+ return;
+ }
+
+ const inputType = detectInputType(trimmed);
+ if (inputType === 'unknown') {
+ options.value = [];
+ return;
+ }
+
+ searching.value = true;
+ try {
+ const params = inputType === 'phone' ? { phone: trimmed } : { id: trimmed };
+
+ const res = await getUserInfo(params);
+ if (res.code == 200 && res.data) {
+ const user = res.data;
+ options.value = [
+ {
+ 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): string | undefined => {
+ return val ? String(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),
+ phone: user.phone || '',
+ nickname: user.nickname || '',
+ status: '',
+ },
+ },
+ ];
+ };
+
+ /** 重置:清除 keyword + options(供筛选栏重置按钮调用) */
+ const reset = () => {
+ keyword.value = '';
+ options.value = [];
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+
+ return {
+ keyword,
+ options,
+ searching,
+ onSearch,
+ onChange,
+ doSearch,
+ setPresetUser,
+ reset,
+ };
+}
diff --git a/src/pages/system/ai-agent/index.tsx b/src/pages/system/ai-agent/index.tsx
new file mode 100644
index 0000000..c5237ad
--- /dev/null
+++ b/src/pages/system/ai-agent/index.tsx
@@ -0,0 +1,122 @@
+import { defineComponent, ref } from 'vue';
+import { Button, Table, Space, Tabs, TabPane } from 'ant-design-vue';
+import { useAiAgentModel } from './model/useAiAgentModel';
+import { useAiAgentColumns } from './model/useAiAgentColumns';
+import { useContainerSize } from '@/hooks';
+import pageStyles from '@/assets/styles/pageLayout.module.less';
+import AiAgentEditModal from './components/AiAgentEditModal';
+import AiModelDict from './components/AiModelDict';
+import BlacklistTab from './components/BlacklistTab';
+import PointsTab from './components/PointsTab';
+import BillingReportTab from './components/BillingReportTab';
+import type { AiAgentConfigVO } from './model/services';
+
+export default defineComponent({
+ name: 'SystemAiAgent',
+ setup() {
+ const activeTab = ref('agent');
+
+ const {
+ loading,
+ dataSource,
+ editModalVisible,
+ currentRecord,
+ submitting,
+ handleSearch,
+ openEditModal,
+ closeEditModal,
+ handleSubmitEdit,
+ } = useAiAgentModel();
+
+ const { columns } = useAiAgentColumns();
+ const { containerRef, height } = useContainerSize({ headerOffset: 55 });
+
+ /** 渲染操作列 */
+ const renderAction = (record: AiAgentConfigVO) => (
+
+ );
+
+ /** 完整表格列 */
+ const tableColumns = [
+ ...columns,
+ {
+ title: '操作',
+ key: 'action',
+ width: 80,
+ fixed: 'right' as const,
+ align: 'center' as const,
+ },
+ ];
+
+ return () => (
+
+
(activeTab.value = String(val))}
+ class={pageStyles.tabs}
+ destroyInactiveTabPane
+ >
+
+
+
+
+
+
+
+
+
+
+ {{
+ bodyCell: (args: { column: { key: string }; record: AiAgentConfigVO }) => {
+ if (args.column.key === 'action') {
+ return renderAction(args.record);
+ }
+ return undefined;
+ },
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {editModalVisible.value && (
+
+ )}
+
+ );
+ },
+});
diff --git a/src/pages/system/ai-agent/model/config.ts b/src/pages/system/ai-agent/model/config.ts
new file mode 100644
index 0000000..0a0bcd9
--- /dev/null
+++ b/src/pages/system/ai-agent/model/config.ts
@@ -0,0 +1,59 @@
+/**
+ * AI 场景配置 - 配置文件
+ * 集中管理选项配置和状态映射
+ */
+
+import type { StatusTagTone } from '@/components';
+
+/** 业务开关映射 */
+export const ENABLED_MAP: Record = {
+ '1': { label: '开启', tone: 'success' },
+ '0': { label: '关闭', tone: 'danger' },
+};
+
+/** 计费开关映射 */
+export const BILLING_ENABLED_MAP: Record = {
+ '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 = {
+ 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 = {
+ 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;
diff --git a/src/pages/system/ai-agent/model/services.ts b/src/pages/system/ai-agent/model/services.ts
new file mode 100644
index 0000000..404bb21
--- /dev/null
+++ b/src/pages/system/ai-agent/model/services.ts
@@ -0,0 +1,439 @@
+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: string;
+ /** 昵称 */
+ nickname?: string;
+ /** 手机号 */
+ phone?: string;
+ /** 当前余额(可为负=欠款) */
+ points: number;
+ /** 累计充值 */
+ totalRecharged: number;
+ /** 累计消费 */
+ totalConsumed: number;
+}
+
+/** AI 积分流水 VO */
+export interface AiPointsTxnVO {
+ /** 用户 ID */
+ userId: string;
+ /** 昵称 */
+ 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?: string;
+}
+
+/** 积分流水分页查询参数 */
+export interface AiPointsTxnQueryParams {
+ page: number;
+ size: number;
+ /** 可选:用户 ID 筛选 */
+ userId?: string;
+ /** 可选:业务场景筛选 */
+ bizType?: string;
+ /** 可选:流水类型 1充值/2调账/3消费/4欠款 */
+ type?: number;
+ /** 可选:日期筛选 yyyy-MM-dd */
+ date?: string;
+}
+
+/** 手工调点参数 */
+export interface AiPointsAdjustParams {
+ /** 必填:用户 ID */
+ userId: string;
+ /** 必填:积分数(非0;正=充/赠,负=扣) */
+ points: number;
+ /** 可选 1=充值/赠送 2=调账;缺省按正负推导 */
+ type?: number;
+ /** 可选:审计备注 */
+ descr?: string;
+}
+
+/** AI 黑名单 VO */
+export interface AiBlacklistVO {
+ id: string;
+ /** 用户 ID */
+ userId: string;
+ /** 昵称 */
+ nickname?: string;
+ /** 手机号 */
+ phone?: string;
+ /** 拉黑原因(审计要求) */
+ reason: string;
+ /** 状态:1=拉黑中 0=已解除 */
+ status: number;
+ /** 到期时间(null=永久;yyyy-MM-dd HH:mm:ss) */
+ expireDate: string | null;
+ /** 操作人 ID */
+ operatorId: string;
+ /** 创建时间 */
+ createDate: string;
+ /** 更新时间 */
+ updateDate: string;
+}
+
+/** 黑名单分页查询参数 */
+export interface AiBlacklistQueryParams {
+ page: number;
+ size: number;
+ /** 可选:用户 ID 筛选 */
+ userId?: string;
+ /** 可选:状态筛选 1=拉黑中 0=已解除 */
+ status?: number;
+}
+
+/** 拉黑参数 */
+export interface AiBlacklistAddParams {
+ /** 必填:用户 ID */
+ userId: string;
+ /** 必填:拉黑原因 */
+ reason: string;
+ /** 可选:到期时间(yyyy-MM-dd[ HH:mm:ss];空=永久) */
+ expireDate?: string;
+}
+
+/** 解除拉黑参数 */
+export interface AiBlacklistRemoveParams {
+ userId: string;
+}
+
+/** 分页数据 */
+export interface PageData {
+ total: number;
+ list: T[];
+}
+
+/** 接口通用返回结构 */
+export interface ApiResult {
+ code: number;
+ msg: string;
+ data: T;
+}
+
+/** 计费单价 VO(文档 §3.3,GET /admin/manager/ai/billing/prices) */
+export interface AiBillingPriceVO {
+ /** 模型标识 */
+ model: string;
+ /** 输入单价(点/千 token) */
+ inputPrice: number;
+ /** 输出单价(点/千 token) */
+ outputPrice: number;
+}
+
+/** 通用字典类型 VO(§2.4 GET types) */
+export interface SysDictTypeVO {
+ id: number;
+ dictType: string;
+ dictName: string;
+ remark: string;
+ sort: number;
+ dataCount: number;
+}
+
+/** 通用字典数据行 VO(§2.4 GET data/list) */
+export interface SysDictDataVO {
+ id: number;
+ dictTypeId: number;
+ dictLabel: string;
+ dictValue: string;
+ sort: number;
+ remark: string;
+}
+
+/** 通用字典 data/save 参数(§2.4) */
+export interface DictDataSaveParams {
+ /** 数据行 id;空=新建 */
+ id?: number;
+ /** 关联类型 id */
+ dictTypeId: number;
+ /** 字典 label,如 deepseek-flash.input / default.output */
+ dictLabel: string;
+ /** 字典 value,单价值 */
+ dictValue: string;
+ /** 排序 */
+ sort?: number;
+ /** 备注 */
+ remark?: string;
+}
+
+/** 通用字典 data/delete 参数(§2.4,按 id 删除) */
+export interface DictDataDeleteParams {
+ id: number;
+}
+
+// ===== 报表相关(文档 §1.4) =====
+
+/** 计费报表行 VO(§1.4 ⑥ GET /admin/manager/ai/billing/report) */
+export interface AiBillingReportVO {
+ /** 日期 yyyy-MM-dd */
+ date: string;
+ /** 业务场景 */
+ bizType: string;
+ /** 消息数 */
+ messageCount: number;
+ /** 总 token 数 */
+ totalTokens: number;
+ /** 总消耗积分 */
+ totalPoints: number;
+}
+
+/** 直解格式统计报表行 VO(§1.4 ⑦ GET /admin/manager/ai/freeform/report) */
+export interface AiFreeformReportVO {
+ /** 文档指纹 */
+ fingerprint: string;
+ /** 文档性质 */
+ docNature: string;
+ /** 信号集 */
+ signalSet: string;
+ /** 表头签名 */
+ headerSignature: string;
+ /** 使用次数 */
+ useCount: number;
+ /** 最后使用时间 */
+ lastUsedAt: string;
+ /** 是否高频(月频次≥5) */
+ highFrequency: boolean;
+}
+
+/** 计费报表查询参数(§1.4 ⑥) */
+export interface AiBillingReportQueryParams {
+ /** 可选:开始日期 yyyy-MM-dd */
+ startDate?: string;
+ /** 可选:结束日期 yyyy-MM-dd */
+ endDate?: string;
+}
+
+/** 直解报表查询参数(§1.4 ⑦) */
+export interface AiFreeformReportQueryParams {
+ /** 返回行数,默认 50 */
+ top?: number;
+}
+
+const AI_AGENT_LIST = '/admin/manager/ai/agent/list';
+const AI_AGENT_UPDATE = '/admin/manager/ai/agent/update';
+const AI_MODEL_DICT_GET = '/admin/manager/ai/model/dict';
+const AI_MODEL_DICT_SAVE = '/admin/manager/ai/model/dict';
+const AI_MODEL_DICT_RESET = '/admin/manager/ai/model/dict/reset';
+const AI_BLACKLIST_LIST = '/admin/manager/ai/blacklist/list';
+const AI_BLACKLIST_ADD = '/admin/manager/ai/blacklist/add';
+const AI_BLACKLIST_REMOVE = '/admin/manager/ai/blacklist/remove';
+const AI_POINTS_ACCOUNTS = '/admin/manager/ai/points/accounts';
+const AI_POINTS_ADJUST = '/admin/manager/ai/points/adjust';
+const AI_POINTS_TRANSACTIONS = '/admin/manager/ai/points/transactions';
+const AI_BILLING_PRICES = '/admin/manager/ai/billing/prices';
+const DICT_TYPES = '/admin/manager/dict/types';
+const DICT_DATA_LIST = '/admin/manager/dict/data/list';
+const DICT_DATA_SAVE = '/admin/manager/dict/data/save';
+const DICT_DATA_DELETE = '/admin/manager/dict/data/delete';
+const AI_BILLING_REPORT = '/admin/manager/ai/billing/report';
+const AI_FREEFORM_REPORT = '/admin/manager/ai/freeform/report';
+const USER_INFO = '/admin/manager/user/info';
+
+/** 单价字典类型标识 */
+export const AI_BILLING_PRICE_DICT_TYPE = 'ai_billing_price';
+
+/** 场景配置列表 */
+export function getAiAgentList(): Promise> {
+ return get(AI_AGENT_LIST, {});
+}
+
+/** 更新场景配置 */
+export function updateAiAgent(params: AiAgentUpdateParams): Promise> {
+ return post(AI_AGENT_UPDATE, params);
+}
+
+/** 读取模型字典当前配置(白名单 16 键全量返回) */
+export function getAiModelDict(): Promise> {
+ return get(AI_MODEL_DICT_GET, {});
+}
+
+/** 保存模型字典(整组 upsert) */
+export function saveAiModelDict(params: AiModelDictSaveParams): Promise> {
+ return post(AI_MODEL_DICT_SAVE, params);
+}
+
+/** 重置模型字典指定键(回落后一级) */
+export function resetAiModelDict(params: AiModelDictResetParams): Promise> {
+ return post(AI_MODEL_DICT_RESET, params);
+}
+
+/** 积分账户分页列表 */
+export function getAiPointsAccounts(
+ params: AiPointsAccountQueryParams,
+): Promise>> {
+ return get(AI_POINTS_ACCOUNTS, params as unknown as Record);
+}
+
+/** 手工调点(充值/纠错) */
+export function adjustAiPoints(params: AiPointsAdjustParams): Promise> {
+ return post(AI_POINTS_ADJUST, params);
+}
+
+/** 积分流水分页列表 */
+export function getAiPointsTransactions(
+ params: AiPointsTxnQueryParams,
+): Promise>> {
+ return get(AI_POINTS_TRANSACTIONS, params as unknown as Record);
+}
+
+/** 黑名单分页列表 */
+export function getAiBlacklistList(
+ params: AiBlacklistQueryParams,
+): Promise>> {
+ return get(AI_BLACKLIST_LIST, params as unknown as Record);
+}
+
+/** 拉黑 */
+export function addAiBlacklist(params: AiBlacklistAddParams): Promise> {
+ return post(AI_BLACKLIST_ADD, params);
+}
+
+/** 解除拉黑 */
+export function removeAiBlacklist(params: AiBlacklistRemoveParams): Promise> {
+ return post(AI_BLACKLIST_REMOVE, params);
+}
+
+/** 读取模型计费单价列表(生效价,§3.3) */
+export function getAiBillingPrices(): Promise> {
+ return get(AI_BILLING_PRICES, {});
+}
+
+/** 通用字典 — 获取全部类型(§2.4 GET types) */
+export function getDictTypes(): Promise> {
+ return get(DICT_TYPES, {});
+}
+
+/** 通用字典 — 获取某类型下数据行(§2.4 GET data/list) */
+export function getDictDataList(dictTypeId: number): Promise> {
+ return get(DICT_DATA_LIST, { dictTypeId });
+}
+
+/** 通用字典 — 保存/更新数据行(§2.4 POST data/save) */
+export function saveDictData(params: DictDataSaveParams): Promise> {
+ return post(DICT_DATA_SAVE, params);
+}
+
+/** 通用字典 — 删除数据行(§2.4 POST data/delete,按 id 删除) */
+export function deleteDictData(id: number): 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);
+}
+
+/** 用户信息查询参数(GET /admin/manager/user/info) */
+export interface UserInfoQueryParams {
+ /** 雪花 ID */
+ id?: string;
+ /** 手机号 */
+ phone?: string;
+}
+
+/** 用户信息返回 */
+export interface UserInfoVO {
+ id: string;
+ phone: string;
+ nickname: string;
+ status: string;
+ [key: string]: unknown;
+}
+
+/** 按 id 或手机号查询单个用户信息 */
+export function getUserInfo(params: UserInfoQueryParams): Promise> {
+ return get(USER_INFO, params as Record);
+}
diff --git a/src/pages/system/ai-agent/model/useAiAgentColumns.ts b/src/pages/system/ai-agent/model/useAiAgentColumns.ts
new file mode 100644
index 0000000..3990b1b
--- /dev/null
+++ b/src/pages/system/ai-agent/model/useAiAgentColumns.ts
@@ -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,
+): 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 };
+}
diff --git a/src/pages/system/ai-agent/model/useAiAgentModel.ts b/src/pages/system/ai-agent/model/useAiAgentModel.ts
new file mode 100644
index 0000000..e96731a
--- /dev/null
+++ b/src/pages/system/ai-agent/model/useAiAgentModel.ts
@@ -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(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const {
+ data,
+ loading,
+ run: fetchList,
+ } = useRequest>(() => getAiAgentList(), {
+ refreshDeps: [],
+ formatResult: (res) => res,
+ });
+
+ // 提取列表数据
+ const dataSource = computed(() => {
+ 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,
+ };
+}
diff --git a/src/pages/system/ai-agent/model/useBlacklistColumns.ts b/src/pages/system/ai-agent/model/useBlacklistColumns.ts
new file mode 100644
index 0000000..d27b4fb
--- /dev/null
+++ b/src/pages/system/ai-agent/model/useBlacklistColumns.ts
@@ -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 };
+}
diff --git a/src/pages/system/ai-agent/model/useBlacklistModel.ts b/src/pages/system/ai-agent/model/useBlacklistModel.ts
new file mode 100644
index 0000000..73dacf5
--- /dev/null
+++ b/src/pages/system/ai-agent/model/useBlacklistModel.ts
@@ -0,0 +1,180 @@
+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,
+ 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) {
+ params.userId = String(uid);
+ }
+ if (filterForm.status !== '') {
+ params.status = Number(filterForm.status);
+ }
+ return params;
+ };
+
+ const {
+ data,
+ loading,
+ run: fetchList,
+ } = useRequest>>(() => getAiBlacklistList(buildQueryParams()), {
+ refreshDeps: [],
+ formatResult: (res) => res,
+ });
+
+ const dataSource = computed(() => {
+ const res = data.value;
+ return res ? (res as any).data?.list || [] : [];
+ });
+
+ useEffect(() => {
+ syncPaginationTotal(pagination, (data.value as any)?.data?.total);
+ }, [data]);
+
+ const addModalVisible = ref(false);
+ const submitting = ref(false);
+ /** 预设用户信息(从列表「再次拉黑」带入) */
+ const presetUser = ref<{ userId: string; phone?: string; nickname?: string } | undefined>(
+ undefined,
+ );
+
+ /** 搜索 */
+ 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 = () => {
+ presetUser.value = undefined;
+ addModalVisible.value = true;
+ };
+
+ /** 打开拉黑弹窗(带预设用户信息,用于「再次拉黑」) */
+ const openAddModalWithRecord = (record: AiBlacklistVO) => {
+ presetUser.value = {
+ userId: record.userId,
+ phone: record.phone,
+ nickname: record.nickname,
+ };
+ addModalVisible.value = true;
+ };
+
+ /** 关闭拉黑弹窗 */
+ const closeAddModal = () => {
+ addModalVisible.value = false;
+ presetUser.value = undefined;
+ };
+
+ /** 提交拉黑 */
+ const handleSubmitAdd = async (params: AiBlacklistAddParams) => {
+ submitting.value = true;
+ try {
+ const res = await addAiBlacklist(params);
+ if (res.code == 200) {
+ message.success('拉黑成功');
+ 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('已解除拉黑');
+ fetchList();
+ } else {
+ message.error(res.msg || '解除失败');
+ }
+ } catch {
+ message.error('解除失败');
+ }
+ },
+ });
+ }, 500);
+
+ return {
+ filterForm,
+ loading,
+ dataSource,
+ pagination,
+ addModalVisible,
+ submitting,
+ presetUser,
+ handleSearch,
+ handleReset,
+ handlePageChange,
+ openAddModal,
+ openAddModalWithRecord,
+ closeAddModal,
+ handleSubmitAdd,
+ handleRemove,
+ };
+}
diff --git a/src/pages/system/ai-agent/model/usePointsColumns.ts b/src/pages/system/ai-agent/model/usePointsColumns.ts
new file mode 100644
index 0000000..6ae7091
--- /dev/null
+++ b/src/pages/system/ai-agent/model/usePointsColumns.ts
@@ -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 };
+}
diff --git a/src/pages/system/ai-agent/model/usePointsModel.ts b/src/pages/system/ai-agent/model/usePointsModel.ts
new file mode 100644
index 0000000..55c1b89
--- /dev/null
+++ b/src/pages/system/ai-agent/model/usePointsModel.ts
@@ -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,
+ });
+
+ const txnFilter = reactive({
+ userId: '' as string,
+ 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) {
+ params.userId = String(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) {
+ params.userId = String(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>>(
+ () => getAiPointsAccounts(buildAccountQueryParams()),
+ { refreshDeps: [], formatResult: (res) => res },
+ );
+
+ const accountDataSource = computed(() => {
+ 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>>(
+ () => getAiPointsTransactions(buildTxnQueryParams()),
+ { refreshDeps: [], formatResult: (res) => res },
+ );
+
+ const txnDataSource = computed(() => {
+ 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: string; 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: string; 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('调点成功');
+ 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,
+ };
+}
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;
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 4773303..592e2cd 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -43,7 +43,11 @@ export function uniqueId(prefix = ''): string {
* 简易深拷贝(基于 JSON,适用于纯数据对象)
*/
export function deepClone(value: T): T {
- return JSON.parse(JSON.stringify(value));
+ try {
+ return JSON.parse(JSON.stringify(value));
+ } catch {
+ return { ...value };
+ }
}
/**
diff --git a/src/utils/request.ts b/src/utils/request.ts
index f89036f..7c9fad8 100644
--- a/src/utils/request.ts
+++ b/src/utils/request.ts
@@ -92,7 +92,7 @@ const request = (
return res.blob();
}
- const data = await res.json();
+ const data = await res.json().catch(() => ({}));
if (data.code == 401) {
handleUnauthorized(data.msg);
diff --git a/src/utils/routeIcon.tsx b/src/utils/routeIcon.tsx
index d9b87e1..272ddbf 100644
--- a/src/utils/routeIcon.tsx
+++ b/src/utils/routeIcon.tsx
@@ -7,6 +7,7 @@ import {
AiOutlineMoneyCollect,
AiOutlinePayCircle,
AiOutlinePicture,
+ AiOutlineRobot,
AiOutlineSafetyCertificate,
AiOutlineSetting,
AiOutlineShoppingCart,
@@ -37,6 +38,7 @@ const ROUTE_ICON_MAP: Record = {
AiOutlineSafetyCertificate,
AiOutlineAudit,
AiOutlineSound,
+ AiOutlineRobot,
};
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;
diff --git a/vite.config.ts b/vite.config.ts
index 5654169..1b52cc5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,12 +7,6 @@ import { resolve } from 'path';
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
- // 增大 Node 内存限制,防止构建时 OOM
- const v8Flags = '--max-old-space-size=4096';
- if (!process.env.NODE_OPTIONS?.includes('max-old-space-size')) {
- process.env.NODE_OPTIONS = [process.env.NODE_OPTIONS, v8Flags].filter(Boolean).join(' ');
- }
-
const env = loadEnv(mode, process.cwd(), '');
const isProd = mode === 'production';
@@ -69,18 +63,22 @@ export default defineConfig(({ mode }) => {
if (id.includes('ant-design-vue') || id.includes('@ant-design')) {
return 'antd';
}
- if (id.includes('vue') || id.includes('@vue') || id.includes('vue-router')) {
+ if (
+ id.includes('/node_modules/vue/') ||
+ id.includes('/node_modules/@vue/') ||
+ id.includes('/node_modules/vue-router/')
+ ) {
return 'vue-vendor';
}
if (id.includes('echarts')) {
return 'echarts';
}
+ if (id.includes('vue-icons-plus')) {
+ return 'icons';
+ }
if (id.includes('china-area-data')) {
return 'area-data';
}
- if (id.includes('big.js')) {
- return 'bigjs';
- }
return 'vendor';
}
},