Compare commits

..

2 Commits

Author SHA1 Message Date
chenzhen e67731a7cd feat: ai
报表
2026-10-22 11:03:01 +08:00
chenzhen 9e0cb3f27a feat: 运营平台 ai 口子调整 2026-10-21 17:27:10 +08:00
28 changed files with 4175 additions and 3 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# 开发环境配置
# 基础API地址
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
# VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
VITE_API_BASE_URL=http://192.168.2.166:8080/cpms
# 是否开启本地调试
LOCAL_DEBUG=true
+26
View File
@@ -4,6 +4,32 @@
flex-direction: column;
height: 100%;
// Tabs 容器:撑满高度
.tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
:global {
.ant-tabs-content-holder {
flex: 1;
min-height: 0;
overflow: hidden;
}
.ant-tabs-content {
height: 100%;
}
.ant-tabs-tabpane {
height: 100%;
display: flex;
flex-direction: column;
}
}
}
// 页面筛选区域:横向 flex,可换行
.filter {
display: flex;
+9
View File
@@ -173,6 +173,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: '操作日志',
+1 -1
View File
@@ -1,7 +1,7 @@
import { createApp } from 'vue';
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
import './styles.less';
import App from './App';
import router from './router';
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
@@ -0,0 +1,21 @@
.form {
padding-top: 8px;
:global(.ant-form-item) {
margin-bottom: 16px;
}
}
.tip {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 0;
}
@@ -0,0 +1,187 @@
import { defineComponent, reactive } from 'vue';
import { Modal, Form, Input, InputNumber, Switch, Button, message } from 'ant-design-vue';
import { useEffect } from '@/hooks/useEffect';
import type { AiAgentConfigVO, AiAgentUpdateParams } from '../model/services';
import { MIN_MULTIPLIER, MAX_FREE_POINTS } from '../model/config';
import styles from './AiAgentEditModal.module.less';
const { TextArea } = Input;
interface AiAgentEditModalProps {
visible: boolean;
record: AiAgentConfigVO | null;
submitting: boolean;
onClose: () => void;
onSubmit: (params: AiAgentUpdateParams) => void;
}
const DEFAULT_FORM = {
enabled: false,
billingEnabled: false,
billingMultiplier: 1,
dailyFreePoints: 0,
descr: '',
};
/**
* AI 场景配置编辑弹窗
*
* 字段:业务开关 / 计费开关 / 计费倍率 / 每日免费额度 / 备注
* 注意:场景名称和业务类型不可编辑(由代码注册)
*/
export default defineComponent({
name: 'AiAgentEditModal',
props: {
visible: { type: Boolean, default: false },
record: { type: Object, default: null },
submitting: { type: Boolean, default: false },
onClose: { type: Function, default: null },
onSubmit: { type: Function, default: null },
},
setup(props: AiAgentEditModalProps) {
const formState = reactive({ ...DEFAULT_FORM });
/** 根据 record 初始化表单(与项目其他弹窗保持一致的写法) */
const initFormFromRecord = (record: AiAgentConfigVO | null) => {
if (record) {
Object.assign(formState, {
enabled: String(record.enabled) === '1',
billingEnabled: String(record.billingEnabled) === '1',
billingMultiplier: Number(record.billingMultiplier) || 1,
dailyFreePoints: Number(record.dailyFreePoints) || 0,
descr: record.descr || '',
});
} else {
Object.assign(formState, DEFAULT_FORM);
}
};
// 弹窗打开时回填表单(useEffect 内置 immediate,首次挂载也会执行)
useEffect(() => {
if (props.visible) {
initFormFromRecord(props.record);
}
}, [() => props.visible]);
const handleSubmit = () => {
// 校验
if (formState.billingMultiplier < MIN_MULTIPLIER) {
message.warning(`计费倍率必须大于 ${MIN_MULTIPLIER}`);
return;
}
if (formState.dailyFreePoints < 0 || formState.dailyFreePoints > MAX_FREE_POINTS) {
message.warning(`每日免费额度范围为 0 ~ ${MAX_FREE_POINTS}`);
return;
}
// 构造提交参数(对齐文档 POST /admin/manager/ai/agent/update Body 格式)
const params: AiAgentUpdateParams = {
id: props.record!.id,
enabled: formState.enabled ? 1 : 0,
billingEnabled: formState.billingEnabled ? 1 : 0,
billingMultiplier: formState.billingMultiplier,
dailyFreePoints: formState.dailyFreePoints,
descr: formState.descr.trim(),
};
props.onSubmit?.(params);
};
return () => (
<Modal
visible={props.visible}
onCancel={props.onClose}
title="编辑场景配置"
width={520}
centered
destroyOnClose
footer={null}
>
<Form layout="vertical" class={styles.form}>
{/* 场景信息(只读) */}
<Form.Item label="场景名称">
<Input value={props.record?.name || ''} disabled />
</Form.Item>
<Form.Item label="业务类型">
<Input value={props.record?.bizType || ''} disabled />
</Form.Item>
{/* 业务开关 */}
<Form.Item label="业务开关">
<Switch
checked={formState.enabled}
onChange={(checked: boolean | string | number) => (formState.enabled = !!checked)}
checkedChildren="开启"
unCheckedChildren="关闭"
/>
</Form.Item>
{/* 计费开关 */}
<Form.Item label="计费开关">
<Switch
checked={formState.billingEnabled}
onChange={(checked: boolean | string | number) =>
(formState.billingEnabled = !!checked)
}
checkedChildren="计费"
unCheckedChildren="免费"
/>
</Form.Item>
{/* 计费倍率 */}
<Form.Item label="计费倍率" required>
<InputNumber
value={formState.billingMultiplier}
onChange={(value: any) =>
(formState.billingMultiplier = typeof value === 'number' ? value : 1)
}
min={MIN_MULTIPLIER}
step={0.1}
precision={2}
style={{ width: '100%' }}
/>
<div class={styles.tip}>倍率必须大于 0,如 1.0 = 原价,1.5 = 1.5 倍计费</div>
</Form.Item>
{/* 每日免费额度 */}
<Form.Item label="每日免费额度">
<InputNumber
value={formState.dailyFreePoints}
onChange={(value: any) =>
(formState.dailyFreePoints = typeof value === 'number' ? value : 0)
}
min={0}
max={MAX_FREE_POINTS}
step={1}
precision={0}
style={{ width: '100%' }}
/>
<div class={styles.tip}>每日免费结算条数,0 = 无免费额度</div>
</Form.Item>
{/* 备注 */}
<Form.Item label="运营备注">
<TextArea
value={formState.descr}
onUpdate:value={(val: any) => (formState.descr = val?.target?.value ?? val ?? '')}
placeholder="请输入备注"
rows={3}
maxlength={200}
showCount
/>
</Form.Item>
{/* 底部按钮 */}
<Form.Item>
<div class={styles.footer}>
<Button onClick={props.onClose}>取消</Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
保存
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
},
});
@@ -0,0 +1,277 @@
// 外层容器:撑满 Tab 面板,纵向 flex
.container {
display: flex;
flex-direction: column;
height: 100%;
background: #fff;
}
// 内容滚动区
.wrapper {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 24px 16px;
background: #fff;
}
// ===== 顶部说明栏 =====
.infoBar {
display: flex;
align-items: flex-start;
gap: 8px;
flex-shrink: 0;
margin-bottom: 24px;
padding: 12px 16px;
background: #f0f5ff;
border-radius: 8px;
border-left: 3px solid #1677ff;
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.6;
}
.infoIcon {
flex-shrink: 0;
margin-top: 2px;
color: #1677ff;
font-size: 15px;
}
// ===== 分组容器 =====
.group {
margin-bottom: 28px;
}
.groupHeader {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 0;
padding: 12px 16px;
background: #fafafa;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #f0f0f0;
}
// 左侧彩色竖线
.groupBar {
width: 3px;
height: 16px;
border-radius: 2px;
flex-shrink: 0;
}
.groupName {
font-size: 14px;
font-weight: 600;
color: rgba(0, 0, 0, 0.85);
white-space: nowrap;
}
.groupDesc {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
flex: 1;
min-width: 0;
}
.hint {
font-size: 12px;
color: #ff4d4f;
white-space: nowrap;
}
// ===== 表单项表格 =====
.itemTable {
border: 1px solid #f0f0f0;
border-top: none;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
// 表头
.itemHead {
display: flex;
align-items: center;
padding: 0 16px;
height: 40px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
font-weight: 500;
}
.headLabel {
width: 200px;
flex-shrink: 0;
padding-left: 4px;
}
.headValue {
flex: 1;
min-width: 0;
}
.headAction {
width: 60px;
flex-shrink: 0;
text-align: center;
}
// 表行
.formItem {
display: flex;
align-items: center;
padding: 10px 16px;
min-height: 56px;
border-bottom: 1px solid #f5f5f5;
transition: background 0.2s ease;
&:last-child {
border-bottom: none;
}
&:hover {
background: #fafcff;
}
}
// 左侧:label + 标签
.itemLeft {
flex-shrink: 0;
width: 200px;
display: flex;
align-items: center;
gap: 6px;
padding-left: 4px;
}
.required {
color: #ff4d4f;
font-size: 14px;
font-weight: bold;
line-height: 1;
flex-shrink: 0;
}
.itemLabel {
font-family: 'SF Mono', 'Courier New', Consolas, monospace;
font-size: 13px;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 中间:输入控件
.itemRight {
flex: 1;
min-width: 0;
padding-right: 16px;
}
.input {
width: 100%;
max-width: 480px;
}
.itemRemark {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
line-height: 1.4;
max-width: 480px;
}
.requiredTip {
color: #ff4d4f;
}
// 右侧:操作
.itemAction {
flex-shrink: 0;
width: 60px;
text-align: center;
}
// ===== 底部操作栏(固定占满底部宽度) =====
.footer {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 16px;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
.tip {
font-size: 12px;
color: rgba(0, 0, 0, 0.4);
margin-right: auto;
line-height: 1.5;
}
// ===== 空状态 =====
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 0;
}
// ===== 模型单价区域 =====
.priceSection {
margin-bottom: 28px;
}
.priceHeader {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #fafafa;
border-radius: 8px 8px 0 0;
border-bottom: 1px solid #f0f0f0;
}
.priceBar {
width: 3px;
height: 16px;
border-radius: 2px;
flex-shrink: 0;
}
.priceCell {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
}
.priceLoadingRow,
.priceEmptyRow {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.priceTip {
margin-top: 8px;
padding: 8px 12px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
background: #f6f0ff;
border-radius: 0 0 8px 8px;
border: 1px solid #f0f0f0;
border-top: none;
}
@@ -0,0 +1,791 @@
import { defineComponent, reactive, ref, computed } from 'vue';
import {
Input,
InputNumber,
Form,
Button,
Spin,
Tag,
Select,
Empty,
message,
Modal,
} from 'ant-design-vue';
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
import { useEffect } from '@/hooks';
import {
getAiModelDict,
saveAiModelDict,
resetAiModelDict,
getAiBillingPrices,
getDictTypes,
getDictDataList,
saveDictData,
deleteDictData,
AI_BILLING_PRICE_DICT_TYPE,
type AiModelDictVO,
type AiBillingPriceVO,
type SysDictDataVO,
type ApiResult,
} from '../model/services';
import styles from './AiModelDict.module.less';
// 用途组中文名
const PURPOSE_MAP: Record<string, string> = {
chat: '全局默认',
steward: '赛事管家',
extract: '导入提取',
failover: '备用网关',
};
// 用途组竖线颜色
const PURPOSE_BAR_COLOR: Record<string, string> = {
chat: '#1677ff',
steward: '#52c41a',
extract: '#fa8c16',
failover: '#ff4d4f',
};
// 用途组描述
const PURPOSE_DESC: Record<string, string> = {
chat: '对话/工具(全局默认),未单独配置的助手均回落此项',
steward: '赛事管家模型,以查询问答为主,可配轻量高并发模型',
extract: '导入提取+诊断,整组未配置时回落全局默认',
failover: '备用网关,未配置 = 故障转移功能整体关闭',
};
/** label 后缀 → 校验类型 */
const MODEL_SUFFIX = '.model';
const BASEURL_SUFFIX = '.baseUrl';
const THINKING_SUFFIX = '.thinking';
const APIKEY_SUFFIX = '.apiKey';
/** 判断是否为 model 类键(必填) */
const isModelKey = (label: string) => label === 'model' || label.endsWith(MODEL_SUFFIX);
/** 判断是否为 baseUrl 类键(须 http(s) 开头) */
const isBaseUrlKey = (label: string) => label === 'baseUrl' || label.endsWith(BASEURL_SUFFIX);
/** 判断是否为 thinking 类键(取值 on/off/default) */
const isThinkingKey = (label: string) => label === 'thinking' || label.endsWith(THINKING_SUFFIX);
/** 判断是否为 apiKey 类键(敏感,留空=保留原值) */
const isApiKeyKey = (label: string) => label === 'apiKey' || label.endsWith(APIKEY_SUFFIX);
/** thinking 合法取值 */
const THINKING_OPTIONS = ['on', 'off', 'default'];
/** 获取 label 的中文用途描述,用于校验提示 */
const getLabelDesc = (label: string, remark?: string) => remark || label;
/** 单价 label 后缀 */
const INPUT_SUFFIX = '.input';
const OUTPUT_SUFFIX = '.output';
/** 兜底默认模型名 */
const DEFAULT_MODEL = 'default';
export default defineComponent({
name: 'AiModelDict',
setup() {
const loading = ref(false);
const saving = ref(false);
const dictList = ref<AiModelDictVO[]>([]);
const formMap = reactive<Record<string, string>>({});
// ===== 加载数据 =====
const fetchDict = async () => {
loading.value = true;
try {
const res = await getAiModelDict();
if (res.code == 200) {
dictList.value = res.data || [];
// 初始化表单值
dictList.value.forEach((item) => {
formMap[item.label] = item.value || '';
});
}
} catch {
// 网络层已提示
} finally {
loading.value = false;
}
};
useEffect(() => {
fetchDict();
}, []);
const groupedData = computed(() => {
const groups: Record<string, AiModelDictVO[]> = {};
dictList.value.forEach((item) => {
const key = item.purpose || 'chat';
if (!groups[key]) groups[key] = [];
groups[key].push(item);
});
return groups;
});
// purpose 排序
const purposeOrder = ['chat', 'steward', 'extract', 'failover'];
// 是否有数据
const hasData = computed(() => dictList.value.length > 0);
const validateForm = (): string[] => {
const errors: string[] = [];
dictList.value.forEach((item) => {
const label = item.label;
const val = (formMap[label] ?? '').trim();
const desc = getLabelDesc(label, item.remark);
// 1. *model 必填
if (isModelKey(label) && !val) {
errors.push(`${desc}不能为空(模型标识必填;如需回落配置文件请用重置)`);
}
// 2. *baseUrl 须 http(s) 开头(填了才校验)
if (isBaseUrlKey(label) && val && !/^https?:\/\//i.test(val)) {
errors.push(`${desc}须以 http:// 或 https:// 开头`);
}
// 3. *thinking 取值 on/off/default(填了才校验)
if (isThinkingKey(label) && val && !THINKING_OPTIONS.includes(val)) {
errors.push(`${desc}取值只能为 on / off / default`);
}
});
return errors;
};
const handleSave = async () => {
// 前端校验
const errors = validateForm();
if (errors.length > 0) {
// 逐条提示,重点展示第一个错误
message.warning(errors[0]);
return;
}
saving.value = true;
try {
// 构造提交参数:apiKey 类含掩码 **** 的传空=保留原值,其余原样提交
const items = dictList.value.map((item) => {
const val = formMap[item.label] ?? '';
// 敏感键:如果值包含掩码 ****,说明未修改,传空让后端保留原值
if (isApiKeyKey(item.label) && val.includes('****')) {
return { label: item.label, value: '' };
}
return { label: item.label, value: val };
});
const res = await saveAiModelDict({ items });
if (res && res.code == 200) {
message.success('已保存(最长 30 秒生效)');
fetchDict();
} else {
message.error(res?.msg || '保存失败');
}
} catch (err) {
console.error('[AiModelDict] saveAiModelDict error:', err);
message.error('保存失败');
} finally {
saving.value = false;
}
};
const handleReset = (label: string, remark: string) => {
Modal.confirm({
title: '确认重置',
content: `确认重置"${remark || label}"吗?重置后该键将回落后一级配置。`,
okText: '确认重置',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const res = await resetAiModelDict({ labels: [label] });
if (res.code == 200) {
message.success(res.msg || '已重置(最长 30 秒生效)');
fetchDict();
} else {
message.error(res.msg || '重置失败');
}
} catch {
message.error('重置失败');
}
},
});
};
const priceLoading = ref(false);
const priceSaving = ref(false);
/** 生效价列表(来自 billing/prices,仅展示用) */
const priceList = ref<AiBillingPriceVO[]>([]);
/** 字典数据行列表(来自 dict/data/list,含 id,用于保存/删除) */
const priceDictRows = ref<SysDictDataVO[]>([]);
/** ai_billing_price 的 dictTypeId */
const priceDictTypeId = ref<number>(0);
/** 编辑态:key = model 名,value = { inputPrice, outputPrice } */
const priceFormMap = reactive<Record<string, { inputPrice: string; outputPrice: string }>>({});
/** 新增模型弹窗 */
const addModalVisible = ref(false);
const newModelName = ref('');
/** 从 dictLabel 提取 model 名(去掉 .input / .output 后缀) */
const extractModelName = (dictLabel: string): string => {
if (dictLabel.endsWith(INPUT_SUFFIX)) return dictLabel.slice(0, -INPUT_SUFFIX.length);
if (dictLabel.endsWith(OUTPUT_SUFFIX)) return dictLabel.slice(0, -OUTPUT_SUFFIX.length);
return dictLabel;
};
/** 加载单价列表:先查 types 拿 dictTypeId,再查 data/list 拿完整行,最后查 billing/prices 拿生效价 */
const fetchPrices = async () => {
priceLoading.value = true;
try {
// 1. 查字典类型,找到 ai_billing_price 的 dictTypeId
const typesRes = await getDictTypes();
if (typesRes.code != 200 || !typesRes.data) {
priceLoading.value = false;
return;
}
const dictType = typesRes.data.find((t) => t.dictType === AI_BILLING_PRICE_DICT_TYPE);
if (!dictType) {
message.warning(`未找到字典类型 ${AI_BILLING_PRICE_DICT_TYPE},请先在通用字典页创建`);
priceLoading.value = false;
return;
}
priceDictTypeId.value = dictType.id;
// 2. 查该类型下全部数据行(含 id)
const dataRes = await getDictDataList(dictType.id);
if (dataRes.code == 200 && dataRes.data) {
priceDictRows.value = dataRes.data;
}
// 3. 查生效价(展示用)
const priceRes = await getAiBillingPrices();
if (priceRes.code == 200 && priceRes.data) {
priceList.value = priceRes.data;
}
// 4. 构建编辑表单:以字典数据行为主,提取 model 名和单价
const modelSet = new Set<string>();
priceDictRows.value.forEach((row) => {
const modelName = extractModelName(row.dictLabel);
modelSet.add(modelName);
});
// 也把生效价中出现但字典没有的模型纳入(漏配模型)
priceList.value.forEach((item) => modelSet.add(item.model));
// 清空旧表单数据
Object.keys(priceFormMap).forEach((key) => delete priceFormMap[key]);
// 构建表单:优先用生效价,其次用字典行
modelSet.forEach((modelName) => {
const inputRow = priceDictRows.value.find(
(r) => r.dictLabel === `${modelName}${INPUT_SUFFIX}`,
);
const outputRow = priceDictRows.value.find(
(r) => r.dictLabel === `${modelName}${OUTPUT_SUFFIX}`,
);
const priceItem = priceList.value.find((p) => p.model === modelName);
priceFormMap[modelName] = {
inputPrice: inputRow
? inputRow.dictValue
: priceItem
? String(priceItem.inputPrice ?? '')
: '',
outputPrice: outputRow
? outputRow.dictValue
: priceItem
? String(priceItem.outputPrice ?? '')
: '',
};
});
} catch {
// 网络层已提示
} finally {
priceLoading.value = false;
}
};
useEffect(() => {
fetchPrices();
}, []);
/** 排序后的模型列表(default 排最前) */
const sortedModels = computed(() => {
const models = Object.keys(priceFormMap);
return models.sort((a, b) => {
if (a === DEFAULT_MODEL) return -1;
if (b === DEFAULT_MODEL) return 1;
return a.localeCompare(b);
});
});
/** 保存某模型的输入单价 */
const handleSaveInputPrice = async (model: string) => {
const val = priceFormMap[model]?.inputPrice ?? '';
const num = Number(val);
if (val === '' || Number.isNaN(num) || num < 0) {
message.warning('请输入有效的单价(≥0)');
return;
}
priceSaving.value = true;
try {
const existingRow = priceDictRows.value.find(
(r) => r.dictLabel === `${model}${INPUT_SUFFIX}`,
);
const res = await saveDictData({
id: existingRow?.id,
dictTypeId: priceDictTypeId.value,
dictLabel: `${model}${INPUT_SUFFIX}`,
dictValue: val,
sort: existingRow?.sort ?? 0,
remark: existingRow?.remark ?? '',
});
if (res.code == 200) {
message.success(`${model} 输入单价已保存(最长 30 秒生效)`);
fetchPrices();
} else {
message.error(res.msg || '保存失败');
}
} catch {
message.error('保存失败');
} finally {
priceSaving.value = false;
}
};
/** 保存某模型的输出单价 */
const handleSaveOutputPrice = async (model: string) => {
const val = priceFormMap[model]?.outputPrice ?? '';
const num = Number(val);
if (val === '' || Number.isNaN(num) || num < 0) {
message.warning('请输入有效的单价(≥0)');
return;
}
priceSaving.value = true;
try {
const existingRow = priceDictRows.value.find(
(r) => r.dictLabel === `${model}${OUTPUT_SUFFIX}`,
);
const res = await saveDictData({
id: existingRow?.id,
dictTypeId: priceDictTypeId.value,
dictLabel: `${model}${OUTPUT_SUFFIX}`,
dictValue: val,
sort: existingRow?.sort ?? 0,
remark: existingRow?.remark ?? '',
});
if (res.code == 200) {
message.success(`${model} 输出单价已保存(最长 30 秒生效)`);
fetchPrices();
} else {
message.error(res.msg || '保存失败');
}
} catch {
message.error('保存失败');
} finally {
priceSaving.value = false;
}
};
/** 删除某模型的单条单价(按数据行 id 删除) */
const handleDeletePrice = (model: string, type: 'input' | 'output') => {
const suffix = type === 'input' ? INPUT_SUFFIX : OUTPUT_SUFFIX;
const row = priceDictRows.value.find((r) => r.dictLabel === `${model}${suffix}`);
if (!row) {
message.warning(`未找到 ${model}${suffix} 的字典数据行,可能尚未配置`);
return;
}
Modal.confirm({
title: '确认重置',
content: `确认重置 ${model} 的${type === 'input' ? '输入' : '输出'}单价吗?重置后回落后一级配置。`,
okText: '确认重置',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const res = await deleteDictData(row.id);
if (res.code == 200) {
message.success(res.msg || '已重置(最长 30 秒生效)');
fetchPrices();
} else {
message.error(res.msg || '重置失败');
}
} catch {
message.error('重置失败');
}
},
});
};
/** 打开新增模型弹窗 */
const openAddModal = () => {
newModelName.value = '';
addModalVisible.value = true;
};
/** 确认新增模型单价 */
const handleAddModel = async () => {
const name = newModelName.value.trim();
if (!name) {
message.warning('请输入模型名称');
return;
}
// 检查是否已存在
if (Object.keys(priceFormMap).includes(name)) {
message.warning(`模型 ${name} 已存在`);
return;
}
if (!priceDictTypeId.value) {
message.error('字典类型未就绪,请刷新页面重试');
return;
}
// 初始化输入/输出单价为 0(通过字典 data/save 写入)
priceSaving.value = true;
try {
const [inputRes, outputRes] = await Promise.all([
saveDictData({
dictTypeId: priceDictTypeId.value,
dictLabel: `${name}${INPUT_SUFFIX}`,
dictValue: '0',
}),
saveDictData({
dictTypeId: priceDictTypeId.value,
dictLabel: `${name}${OUTPUT_SUFFIX}`,
dictValue: '0',
}),
]);
if (inputRes.code == 200 && outputRes.code == 200) {
message.success(`已新增模型 ${name} 单价(最长 30 秒生效)`);
addModalVisible.value = false;
fetchPrices();
} else {
message.error(inputRes.msg || outputRes.msg || '新增失败');
}
} catch {
message.error('新增失败');
} finally {
priceSaving.value = false;
}
};
return () => {
const groups = groupedData.value;
return (
<div class={styles.container}>
<div class={styles.wrapper}>
{loading.value ? (
<div class={styles.emptyState}>
<Spin size="large" />
</div>
) : !hasData.value ? (
<div class={styles.emptyState}>
<Empty description="暂无字典数据" />
</div>
) : (
<>
<div class={styles.infoBar}>
<InfoCircleOutlined class={styles.infoIcon} />
<span>
模型/网关/Key 运行时热切换配置,共 16 个白名单键。敏感键留空 = 保留原值;
<span style={{ color: '#ff4d4f' }}>model 类必填</span>;保存后最长 30 秒生效。
</span>
</div>
{purposeOrder.map((purpose) => {
const items = groups[purpose];
if (!items || items.length === 0) return null;
const barColor = PURPOSE_BAR_COLOR[purpose] || '#1677ff';
return (
<div key={purpose} class={styles.group}>
{/* 分组头部 */}
<div class={styles.groupHeader}>
<span class={styles.groupBar} style={{ backgroundColor: barColor }} />
<span class={styles.groupName}>{PURPOSE_MAP[purpose] || purpose}</span>
<span class={styles.groupDesc}>{PURPOSE_DESC[purpose] || ''}</span>
{purpose === 'failover' && (
<span class={styles.hint}>未配置 = 整体关闭</span>
)}
</div>
{/* 表格容器 */}
<div class={styles.itemTable}>
{/* 表头 */}
<div class={styles.itemHead}>
<div class={styles.headLabel}>配置键</div>
<div class={styles.headValue}>配置值</div>
<div class={styles.headAction}>操作</div>
</div>
{/* 表行 */}
{items.map((item) => {
const isModel = isModelKey(item.label);
const isThinking = isThinkingKey(item.label);
const originalVal = item.value || '';
const currentVal = formMap[item.label] ?? '';
const isModified =
currentVal !== originalVal &&
!(isApiKeyKey(item.label) && currentVal.includes('****'));
return (
<div key={item.label} class={styles.formItem}>
{/* 左侧:label + 标签 */}
<div class={styles.itemLeft}>
{isModel && <span class={styles.required}>*</span>}
<span class={styles.itemLabel} title={item.label}>
{item.label}
</span>
{item.sensitive && <Tag color="red">敏感</Tag>}
{isModified && <Tag color="blue">已改</Tag>}
</div>
{/* 中间:输入控件 */}
<div class={styles.itemRight}>
{item.sensitive ? (
<Input.Password
value={formMap[item.label]}
onUpdate:value={(val: string) =>
(formMap[item.label] = val ?? '')
}
placeholder="留空 = 保留原值"
visibilityToggle={true}
class={styles.input}
allowClear
/>
) : isThinking ? (
<Select
value={formMap[item.label] || undefined}
onUpdate:value={(val: any) => (formMap[item.label] = val ?? '')}
placeholder="未配置(跟随全局默认)"
class={styles.input}
allowClear
options={THINKING_OPTIONS.map((v) => ({
value: v,
label: v,
}))}
/>
) : (
<Input
value={formMap[item.label]}
onUpdate:value={(val: string) =>
(formMap[item.label] = val ?? '')
}
placeholder={isModel ? '必填,如 deepseek-flash' : '未配置'}
class={styles.input}
allowClear
/>
)}
<div class={styles.itemRemark}>
{item.remark}
{isModel && (
<span class={styles.requiredTip}> (必填,回落请用重置)</span>
)}
</div>
</div>
{/* 右侧:重置 */}
<div class={styles.itemAction}>
<Button
type="link"
size="small"
danger
onClick={() => handleReset(item.label, item.remark)}
>
重置
</Button>
</div>
</div>
);
})}
</div>
</div>
);
})}
</>
)}
<div class={styles.priceSection}>
<div class={styles.priceHeader}>
<span class={styles.priceBar} style={{ backgroundColor: '#722ed1' }} />
<span class={styles.groupName}>模型计费单价</span>
<span class={styles.groupDesc}>
单价 = 点/千 token;字典类型 ai_billing_price,label 契约:{'{model}.input'} /{' '}
{'{model}.output'}
</span>
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={openAddModal}
style={{ marginLeft: 'auto' }}
>
新增模型
</Button>
</div>
<div class={styles.itemTable}>
{/* 表头 */}
<div class={styles.itemHead}>
<div class={styles.headLabel}>模型</div>
<div class={styles.headValue}>输入单价(点/千token)</div>
<div class={styles.headValue}>输出单价(点/千token)</div>
<div class={styles.headAction}>操作</div>
</div>
{/* 数据行 */}
{priceLoading.value ? (
<div class={styles.priceLoadingRow}>
<Spin />
</div>
) : sortedModels.value.length === 0 ? (
<div class={styles.priceEmptyRow}>
<Empty description="暂无单价数据" imageStyle={{ height: '48px' }} />
</div>
) : (
sortedModels.value.map((modelName) => {
const isDefault = modelName === DEFAULT_MODEL;
return (
<div key={modelName} class={styles.formItem}>
{/* 模型名 */}
<div class={styles.itemLeft}>
<span class={styles.itemLabel} title={modelName}>
{modelName}
</span>
{isDefault && <Tag color="purple">兜底</Tag>}
</div>
{/* 输入单价 */}
<div class={styles.priceCell}>
<InputNumber
value={priceFormMap[modelName]?.inputPrice}
onChange={(val: any) => {
if (!priceFormMap[modelName]) {
priceFormMap[modelName] = { inputPrice: '', outputPrice: '' };
}
priceFormMap[modelName].inputPrice =
typeof val === 'number' ? String(val) : '';
}}
min={0}
step={0.1}
precision={4}
placeholder="0"
style={{ width: '100%' }}
/>
<Button
type="link"
size="small"
onClick={() => handleSaveInputPrice(modelName)}
loading={priceSaving.value}
>
保存
</Button>
<Button
type="link"
size="small"
danger
onClick={() => handleDeletePrice(modelName, 'input')}
>
重置
</Button>
</div>
{/* 输出单价 */}
<div class={styles.priceCell}>
<InputNumber
value={priceFormMap[modelName]?.outputPrice}
onChange={(val: any) => {
if (!priceFormMap[modelName]) {
priceFormMap[modelName] = { inputPrice: '', outputPrice: '' };
}
priceFormMap[modelName].outputPrice =
typeof val === 'number' ? String(val) : '';
}}
min={0}
step={0.1}
precision={4}
placeholder="0"
style={{ width: '100%' }}
/>
<Button
type="link"
size="small"
onClick={() => handleSaveOutputPrice(modelName)}
loading={priceSaving.value}
>
保存
</Button>
<Button
type="link"
size="small"
danger
onClick={() => handleDeletePrice(modelName, 'output')}
>
重置
</Button>
</div>
{/* 操作列占位 */}
<div class={styles.itemAction} />
</div>
);
})
)}
</div>
<div class={styles.priceTip}>
生效价回落链:字典 → 配置文件 → 内置默认(输入 0.8 / 输出 2.0);改字典最长 30s 生效
</div>
</div>
</div>
<Modal
visible={addModalVisible.value}
onCancel={() => (addModalVisible.value = false)}
title="新增模型单价"
centered
destroyOnClose
width={420}
footer={null}
>
<Form layout="vertical">
<Form.Item label="模型名称" required>
<Input
value={newModelName.value}
onUpdate:value={(val: string) => (newModelName.value = val ?? '')}
placeholder="如 deepseek-flash"
onPressEnter={handleAddModel}
/>
<div class={styles.tip}>
新增后将初始化输入/输出单价为 0,可稍后编辑。default 模型为兜底默认价。
</div>
</Form.Item>
<Form.Item>
<div class={styles.footer}>
<Button onClick={() => (addModalVisible.value = false)}>取消</Button>
<Button type="primary" loading={priceSaving.value} onClick={handleAddModel}>
确认新增
</Button>
</div>
</Form.Item>
</Form>
</Modal>
{hasData.value && !loading.value && (
<div class={styles.footer}>
<span class={styles.tip}>保存后最长 30 秒生效,无需重启</span>
<Button type="primary" size="middle" onClick={handleSave} loading={saving.value}>
保存配置
</Button>
</div>
)}
</div>
);
};
},
});
@@ -0,0 +1,25 @@
// ===== 高频标记 =====
.highFreqTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: #fff1f0;
color: #ff4d4f;
border: 1px solid #ffccc7;
}
.normalTag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background: #f0f0f0;
color: rgba(0, 0, 0, 0.45);
border: 1px solid #d9d9d9;
}
@@ -0,0 +1,368 @@
import { defineComponent, reactive, ref, computed } from 'vue';
import { Button, Table, Space, DatePicker, InputNumber, Radio, Pagination } from 'ant-design-vue';
import { type Dayjs } from 'dayjs';
import { useEffect } from '@/hooks/useEffect';
import { useContainerSize } from '@/hooks';
import {
getAiBillingReport,
getAiFreeformReport,
getAiAgentList,
type AiBillingReportVO,
type AiFreeformReportVO,
type AiAgentConfigVO,
} from '../model/services';
import styles from './BillingReportTab.module.less';
import pageStyles from '@/assets/styles/pageLayout.module.less';
// ===== 工具函数 =====
/** 格式化数字(千分位),兼容后端返回字符串类型 */
const formatNum = (val: unknown): string => {
const n = Number(val);
if (val == null || Number.isNaN(n)) return '-';
return n.toLocaleString('zh-CN');
};
/** 安全的文本渲染:null/undefined/空 → '-' */
const renderText = (text: unknown): string => (text != null && text !== '' ? String(text) : '-');
/** 等宽字体渲染(用于指纹、签名等哈希值) */
const renderMono = (text: string) => (
<span style={{ fontFamily: "'SF Mono', 'Courier New', Consolas, monospace", fontSize: '12px' }}>
{renderText(text)}
</span>
);
// ===== 场景名称映射 =====
const useBizTypeMap = () => {
const map = ref<Record<string, string>>({});
useEffect(() => {
(async () => {
try {
const res = await getAiAgentList();
if (res.code == 200 && res.data) {
const m: Record<string, string> = {};
res.data.forEach((item: AiAgentConfigVO) => {
m[item.bizType] = item.name;
});
map.value = m;
}
} catch {
// ignore
}
})();
}, []);
return map;
};
// ===== 组件 =====
export default defineComponent({
name: 'BillingReportTab',
setup() {
type SubView = 'billing' | 'freeform';
const subView = ref<SubView>('billing');
const bizTypeMap = useBizTypeMap();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// ===== 计费报表(前端分页) =====
const billingLoading = ref(false);
const billingData = ref<AiBillingReportVO[]>([]);
const billingFilter = reactive({
startDate: null as Dayjs | null,
endDate: null as Dayjs | null,
});
const billingPage = reactive({ current: 1, pageSize: 20 });
const billingPagedData = computed(() => {
const start = (billingPage.current - 1) * billingPage.pageSize;
return billingData.value.slice(start, start + billingPage.pageSize);
});
const fetchBilling = async () => {
billingLoading.value = true;
try {
const params: Record<string, string> = {};
if (billingFilter.startDate)
params.startDate = billingFilter.startDate.format('YYYY-MM-DD');
if (billingFilter.endDate) params.endDate = billingFilter.endDate.format('YYYY-MM-DD');
const res = await getAiBillingReport(params);
billingData.value = res.code == 200 && res.data ? res.data : [];
} catch {
billingData.value = [];
} finally {
billingLoading.value = false;
}
};
// 组件初始化时立即加载
fetchBilling();
const onBillingReset = () => {
billingFilter.startDate = null;
billingFilter.endDate = null;
billingPage.current = 1;
fetchBilling();
};
const onBillingPageChange = (page: number, pageSize: number) => {
billingPage.current = page;
billingPage.pageSize = pageSize;
};
const billingColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 130,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
(a.date || '').localeCompare(b.date || ''),
defaultSortOrder: 'descend' as const,
},
{
title: '业务场景',
dataIndex: 'bizType',
key: 'bizType',
width: 160,
align: 'center' as const,
customRender: ({ text }: { text: string }) => bizTypeMap.value[text] || text || '-',
},
{
title: '消息数',
dataIndex: 'messageCount',
key: 'messageCount',
width: 120,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
Number(a.messageCount) - Number(b.messageCount),
customRender: ({ text }: { text: number }) => formatNum(text),
},
{
title: '总 Token',
dataIndex: 'totalTokens',
key: 'totalTokens',
width: 140,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
Number(a.totalTokens) - Number(b.totalTokens),
customRender: ({ text }: { text: number }) => formatNum(text),
},
{
title: '消耗积分',
dataIndex: 'totalPoints',
key: 'totalPoints',
width: 140,
align: 'center' as const,
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
Number(a.totalPoints) - Number(b.totalPoints),
customRender: ({ text }: { text: number }) => (
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>{formatNum(text)}</span>
),
},
];
// ===== 直解报表 =====
const freeformLoading = ref(false);
const freeformData = ref<AiFreeformReportVO[]>([]);
const freeformTop = ref(50);
const freeformLoaded = ref(false);
const fetchFreeform = async () => {
freeformLoading.value = true;
try {
const res = await getAiFreeformReport({ top: freeformTop.value });
freeformData.value = res.code == 200 && res.data ? res.data : [];
} catch {
freeformData.value = [];
} finally {
freeformLoading.value = false;
freeformLoaded.value = true;
}
};
// 切换到直解格式统计时自动加载(首次)
useEffect(() => {
if (subView.value === 'freeform' && !freeformLoaded.value) {
fetchFreeform();
}
}, [subView]);
const onFreeformReset = () => {
freeformTop.value = 50;
fetchFreeform();
};
const freeformColumns = [
{
title: '文档指纹',
dataIndex: 'fingerprint',
key: 'fingerprint',
width: 200,
align: 'left' as const,
ellipsis: true,
customRender: ({ text }: { text: string }) => renderMono(text),
},
{
title: '文档性质',
dataIndex: 'docNature',
key: 'docNature',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string }) => renderText(text),
},
{
title: '信号集',
dataIndex: 'signalSet',
key: 'signalSet',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string }) => renderText(text),
},
{
title: '表头签名',
dataIndex: 'headerSignature',
key: 'headerSignature',
width: 180,
align: 'left' as const,
ellipsis: true,
customRender: ({ text }: { text: string }) => renderMono(text),
},
{
title: '使用次数',
dataIndex: 'useCount',
key: 'useCount',
width: 100,
align: 'center' as const,
sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) =>
Number(a.useCount) - Number(b.useCount),
defaultSortOrder: 'descend' as const,
customRender: ({ text }: { text: number }) => formatNum(text),
},
{
title: '最后使用',
dataIndex: 'lastUsedAt',
key: 'lastUsedAt',
width: 180,
align: 'center' as const,
},
{
title: '频次',
dataIndex: 'highFrequency',
key: 'highFrequency',
width: 100,
align: 'center' as const,
sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) =>
Number(a.highFrequency) - Number(b.highFrequency),
customRender: ({ text }: { text: boolean }) =>
text ? (
<span class={styles.highFreqTag}>高频</span>
) : (
<span class={styles.normalTag}>普通</span>
),
},
];
return () => (
<>
{/* 筛选栏 */}
<div class={pageStyles.filter}>
<Radio.Group
value={subView.value}
onUpdate:value={(val: SubView) => (subView.value = val)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="billing">计费报表</Radio.Button>
<Radio.Button value="freeform">直解格式统计</Radio.Button>
</Radio.Group>
{subView.value === 'billing' ? (
<Space>
<DatePicker
value={billingFilter.startDate ?? undefined}
onUpdate:value={(val: any) => (billingFilter.startDate = val ?? null)}
format="YYYY-MM-DD"
placeholder="开始日期"
allowClear
/>
<DatePicker
value={billingFilter.endDate ?? undefined}
onUpdate:value={(val: any) => (billingFilter.endDate = val ?? null)}
format="YYYY-MM-DD"
placeholder="结束日期"
allowClear
/>
<Button type="primary" onClick={fetchBilling} loading={billingLoading.value}>
查询
</Button>
<Button onClick={onBillingReset}>重置</Button>
</Space>
) : (
<Space>
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.65)' }}>返回行数:</span>
<InputNumber
value={freeformTop.value}
onUpdate:value={(val: any) =>
(freeformTop.value = typeof val === 'number' ? val : 50)
}
min={1}
max={500}
step={10}
precision={0}
placeholder="50"
style={{ width: 100 }}
/>
<Button type="primary" onClick={fetchFreeform} loading={freeformLoading.value}>
查询
</Button>
<Button onClick={onFreeformReset}>重置</Button>
</Space>
)}
</div>
{/* 表格区 */}
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
{subView.value === 'billing' ? (
<Table
columns={billingColumns as any}
dataSource={billingPagedData.value}
loading={billingLoading.value}
rowKey={(record: AiBillingReportVO) => `${record.date}_${record.bizType}`}
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
size="middle"
/>
) : (
<Table
columns={freeformColumns as any}
dataSource={freeformData.value}
loading={freeformLoading.value}
rowKey="fingerprint"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
size="middle"
/>
)}
</div>
{subView.value === 'billing' && (
<div class={pageStyles.pagination}>
<Pagination
current={billingPage.current}
pageSize={billingPage.pageSize}
total={billingData.value.length}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `共 ${total} 条`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={onBillingPageChange}
/>
</div>
)}
</div>
</>
);
},
});
@@ -0,0 +1,21 @@
.form {
padding-top: 8px;
:global(.ant-form-item) {
margin-bottom: 16px;
}
}
.tip {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 0;
}
@@ -0,0 +1,159 @@
import { defineComponent, reactive, watch } from 'vue';
import { Modal, Form, Input, Select, DatePicker, Button, message } from 'ant-design-vue';
import dayjs, { type Dayjs } from 'dayjs';
import { useUserSearch } from '../hooks/useUserSearch';
import type { AiBlacklistAddParams } from '../model/services';
import styles from './BlacklistAddModal.module.less';
interface BlacklistAddModalProps {
visible: boolean;
submitting: boolean;
onClose: () => void;
onSubmit: (params: AiBlacklistAddParams) => void;
}
/**
* 拉黑弹窗
*
* 字段:用户ID / 拉黑原因 / 到期时间(可选,空=永久)
*
* 注意(文档 §1.3 ⑫):
* - userId 必填,建议用用户选择器(当前简化为输入)
* - reason 必填(审计要求)
* - expireDate 可选,空=永久;必须晚于当前时间
*/
export default defineComponent({
name: 'BlacklistAddModal',
props: {
visible: { type: Boolean, default: false },
submitting: { type: Boolean, default: false },
onClose: { type: Function, default: null },
onSubmit: { type: Function, default: null },
},
setup(props: BlacklistAddModalProps) {
const form = reactive({
userId: undefined as number | undefined,
reason: '',
expireDate: null as Dayjs | null,
});
// 用户搜索
const userSearch = useUserSearch();
// 弹窗打开时重置表单
watch(
() => props.visible,
(val) => {
if (val) {
form.userId = undefined;
form.reason = '';
form.expireDate = null;
userSearch.reset();
}
},
);
const handleSubmit = () => {
if (!form.userId || form.userId <= 0) {
message.warning('请搜索并选择用户');
return;
}
if (!form.reason.trim()) {
message.warning('请输入拉黑原因');
return;
}
// 校验到期时间必须晚于当前
if (form.expireDate) {
const now = dayjs();
if (form.expireDate.isBefore(now)) {
message.warning('到期时间必须晚于当前时间');
return;
}
}
const params: AiBlacklistAddParams = {
userId: form.userId,
reason: form.reason.trim(),
};
if (form.expireDate) {
params.expireDate = form.expireDate.format('YYYY-MM-DD HH:mm:ss');
}
props.onSubmit?.(params);
};
return () => (
<Modal
visible={props.visible}
onCancel={props.onClose}
title="拉黑用户"
width={520}
centered
destroyOnClose
footer={null}
>
<Form layout="vertical" class={styles.form}>
<Form.Item label="用户" required>
<Select
showSearch
value={form.userId ? String(form.userId) : undefined}
placeholder="输入手机号搜索用户"
filterOption={false}
loading={userSearch.searching.value}
options={userSearch.options.value as any}
style={{ width: '100%' }}
onSearch={userSearch.onSearch}
onChange={(val: any) => {
form.userId = val ? Number(val) : undefined;
}}
allowClear
notFoundContent={
userSearch.searching.value
? '搜索中...'
: userSearch.keyword.value
? '无匹配用户'
: '请输入手机号搜索'
}
/>
<div class={styles.tip}>搜索手机号选择需要拉黑的用户,展示手机号,传用户ID给后端</div>
</Form.Item>
<Form.Item label="拉黑原因" required>
<Input.TextArea
value={form.reason}
onUpdate:value={(val: string) => (form.reason = val ?? '')}
placeholder="请输入拉黑原因(审计要求必填)"
rows={3}
maxlength={200}
showCount
/>
</Form.Item>
<Form.Item label="到期时间">
<DatePicker
value={form.expireDate ?? undefined}
onUpdate:value={(val: any) => (form.expireDate = val ?? null)}
showTime
format="YYYY-MM-DD HH:mm:ss"
placeholder="留空 = 永久拉黑"
style={{ width: '100%' }}
disabledDate={(current: Dayjs) => current && current.isBefore(dayjs().startOf('day'))}
/>
<div class={styles.tip}>留空表示永久拉黑;选择日期则到指定时间自动解除</div>
</Form.Item>
<Form.Item>
<div class={styles.footer}>
<Button onClick={props.onClose}>取消</Button>
<Button type="primary" danger loading={props.submitting} onClick={handleSubmit}>
确认拉黑
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
},
});
@@ -0,0 +1,162 @@
import { defineComponent } from 'vue';
import { Button, Table, Space, Select, Pagination } from 'ant-design-vue';
import { useBlacklistModel } from '../model/useBlacklistModel';
import { useBlacklistColumns } from '../model/useBlacklistColumns';
import { BLACKLIST_STATUS_OPTIONS } from '../model/config';
import { useContainerSize } from '@/hooks';
import { useUserSearch } from '../hooks/useUserSearch';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import BlacklistAddModal from './BlacklistAddModal';
import type { AiBlacklistVO } from '../model/services';
/**
* AI 黑名单 Tab 组件
*
* 对接文档 §1.3:
* - 黑名单分页列表
* - 拉黑 / 解除拉黑
*/
export default defineComponent({
name: 'BlacklistTab',
setup() {
const {
filterForm,
loading,
dataSource,
pagination,
addModalVisible,
submitting,
handleSearch,
handleReset,
handlePageChange,
openAddModal,
closeAddModal,
handleSubmitAdd,
handleRemove,
} = useBlacklistModel();
const { columns } = useBlacklistColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// 用户搜索
const userSearch = useUserSearch();
/** 重置(包装:同时清除搜索状态) */
const onReset = () => {
userSearch.reset();
handleReset();
};
/** 渲染操作列 */
const renderAction = (record: AiBlacklistVO) => {
if (record.status === 1) {
return (
<Button type="link" size="small" danger onClick={() => handleRemove(record)}>
解除拉黑
</Button>
);
}
return <span style={{ color: '#999' }}>-</span>;
};
/** 完整表格列 */
const tableColumns = [
...columns,
{
title: '操作',
key: 'action',
width: 100,
fixed: 'right' as const,
align: 'center' as const,
},
];
return () => (
<>
<div class={pageStyles.filter}>
<Space>
<Select
showSearch
value={filterForm.userId ? String(filterForm.userId) : undefined}
placeholder="手机号搜索用户"
filterOption={false}
loading={userSearch.searching.value}
options={userSearch.options.value as any}
style={{ width: '220px' }}
onSearch={userSearch.onSearch}
onChange={(val: any) => {
filterForm.userId = val ?? '';
}}
allowClear
notFoundContent={
userSearch.searching.value
? '搜索中...'
: userSearch.keyword.value
? '无匹配用户'
: '输入手机号搜索'
}
/>
<Select
value={filterForm.status}
onUpdate:value={(val: any) => (filterForm.status = val ?? '')}
style={{ width: '200px' }}
options={BLACKLIST_STATUS_OPTIONS as any}
/>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
查询
</Button>
<Button onClick={onReset}>重置</Button>
</Space>
<div class={pageStyles.filterActions}>
<Button type="primary" danger onClick={openAddModal}>
拉黑用户
</Button>
</div>
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
columns={tableColumns}
dataSource={dataSource.value}
loading={loading.value}
rowKey="id"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: { column: { key: string }; record: AiBlacklistVO }) => {
if (args.column.key === 'action') {
return renderAction(args.record);
}
return undefined;
},
}}
</Table>
</div>
<div class={pageStyles.pagination}>
<Pagination
current={(pagination as any).current.value}
pageSize={(pagination as any).pageSize.value}
total={(pagination as any).total.value}
showSizeChanger
showQuickJumper
showTotal={(total: number) => `共 ${total} 条`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={handlePageChange}
/>
</div>
</div>
{addModalVisible.value && (
<BlacklistAddModal
visible={addModalVisible.value}
submitting={submitting.value}
onClose={closeAddModal}
onSubmit={handleSubmitAdd}
/>
)}
</>
);
},
});
@@ -0,0 +1,21 @@
.form {
padding-top: 8px;
:global(.ant-form-item) {
margin-bottom: 16px;
}
}
.tip {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 0;
}
@@ -0,0 +1,167 @@
import { defineComponent, reactive } from 'vue';
import { Modal, Form, InputNumber, Input, Select, Button, message } from 'ant-design-vue';
import { useEffect } from '@/hooks/useEffect';
import { POINTS_ADJUST_TYPE_OPTIONS } from '../model/config';
import { useUserSearch } from '../hooks/useUserSearch';
import type { AiPointsAdjustParams } from '../model/services';
import styles from './PointsAdjustModal.module.less';
interface PointsAdjustModalProps {
visible: boolean;
submitting: boolean;
/** 预设用户信息(从账户行点击调点时带入) */
presetUser?: { userId: number; phone?: string; nickname?: string };
onClose: () => void;
onSubmit: (params: AiPointsAdjustParams) => void;
}
/**
* 手工调点弹窗(充值/纠错)
*
* 对接文档 §1.2 ④:
* - userId 必填,后端不校验真实性,前端建议用用户选择器(当前简化为输入)
* - points 必填,非 0;正=充/赠,负=扣
* - type 可选 1=充值/赠送 2=调账;缺省按正负推导
* - descr 可选,审计备注
*
* ⚠️ 后端自动建户:目标用户无账户时自动创建(0 余额起户后原子加点)
*/
export default defineComponent({
name: 'PointsAdjustModal',
props: {
visible: { type: Boolean, default: false },
submitting: { type: Boolean, default: false },
presetUser: { type: Object, default: undefined },
onClose: { type: Function, default: null },
onSubmit: { type: Function, default: null },
},
setup(props: PointsAdjustModalProps) {
const form = reactive({
userId: undefined as number | undefined,
points: undefined as number | undefined,
type: 1 as number,
descr: '',
});
// 用户搜索
const userSearch = useUserSearch();
useEffect(() => {
if (props.visible) {
form.userId = props.presetUser?.userId ?? undefined;
form.points = undefined;
form.type = 1;
form.descr = '';
userSearch.reset();
if (props.presetUser) {
userSearch.setPresetUser(props.presetUser);
}
}
}, [() => props.visible]);
const handleSubmit = () => {
if (!form.userId || form.userId <= 0) {
message.warning('请搜索并选择用户');
return;
}
if (form.points == null || form.points === 0) {
message.warning('变更积分不能为 0');
return;
}
const params: AiPointsAdjustParams = {
userId: form.userId,
points: form.points,
type: form.type,
};
if (form.descr.trim()) {
params.descr = form.descr.trim();
}
props.onSubmit?.(params);
};
return () => (
<Modal
visible={props.visible}
onCancel={props.onClose}
title="手工调点"
width={520}
centered
destroyOnClose
footer={null}
>
<Form layout="vertical" class={styles.form}>
<Form.Item label="用户" required>
<Select
showSearch
value={form.userId ? String(form.userId) : undefined}
placeholder="输入手机号搜索用户"
filterOption={false}
loading={userSearch.searching.value}
options={userSearch.options.value as any}
disabled={!!props.presetUser}
style={{ width: '100%' }}
onSearch={userSearch.onSearch}
onChange={(val: any) => {
form.userId = val ? Number(val) : undefined;
}}
allowClear
notFoundContent={
userSearch.searching.value
? '搜索中...'
: userSearch.keyword.value
? '无匹配用户'
: '请输入手机号搜索'
}
/>
<div class={styles.tip}>
搜索手机号选择用户,展示手机号,传用户ID给后端;目标用户无账户时系统自动建户
</div>
</Form.Item>
<Form.Item label="变更积分" required>
<InputNumber
value={form.points}
onChange={(val: any) => (form.points = typeof val === 'number' ? val : undefined)}
placeholder="正数=充/赠,负数=扣"
precision={0}
style={{ width: '100%' }}
/>
<div class={styles.tip}>正数 = 充值/赠送;负数 = 扣减;不能为 0</div>
</Form.Item>
<Form.Item label="调点类型" name="type">
<Select
value={form.type}
options={POINTS_ADJUST_TYPE_OPTIONS as any}
style={{ width: '100%' }}
onUpdate:value={(val: any) => (form.type = val ?? 1)}
/>
</Form.Item>
<Form.Item label="审计备注">
<Input.TextArea
value={form.descr}
onUpdate:value={(val: string) => (form.descr = val ?? '')}
placeholder="可选,如:客诉补偿"
rows={3}
maxlength={200}
showCount
/>
</Form.Item>
<Form.Item>
<div class={styles.footer}>
<Button onClick={props.onClose}>取消</Button>
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
确认调点
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
},
});
@@ -0,0 +1,308 @@
import { defineComponent, onMounted, ref } from 'vue';
import { Button, Table, Space, Select, DatePicker, Pagination, Radio } from 'ant-design-vue';
import dayjs from 'dayjs';
import { usePointsModel } from '../model/usePointsModel';
import { usePointsAccountColumns, usePointsTxnColumns } from '../model/usePointsColumns';
import { POINTS_TXN_TYPE_OPTIONS } from '../model/config';
import { useContainerSize } from '@/hooks';
import { useUserSearch } from '../hooks/useUserSearch';
import {
getAiAgentList,
type AiAgentConfigVO,
type AiPointsAccountVO,
type AiPointsTxnVO,
} from '../model/services';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import PointsAdjustModal from './PointsAdjustModal';
/** 用户搜索 Select 的 notFoundContent 公共渲染 */
const renderNotFound = (searching: boolean, keyword: string) =>
searching ? '搜索中...' : keyword ? '无匹配用户' : '输入手机号搜索';
/** 分页组件公共配置 */
const paginationProps = {
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total: number) => `共 ${total} 条`,
pageSizeOptions: ['10', '20', '50', '100'],
};
/**
* AI 积分账户与流水 Tab 组件
*
* 对接文档 §1.2:
* - 账户分页:GET /admin/manager/ai/points/accounts
* - 手工调点:POST /admin/manager/ai/points/adjust
* - 流水分页:GET /admin/manager/ai/points/transactions
*
* 子视图切换:
* - account:积分账户列表(支持调点 / 查看流水)
* - txn:积分流水列表(支持多维筛选)
*/
export default defineComponent({
name: 'PointsTab',
setup() {
const {
subView,
// 账户
accountFilter,
accountLoading,
accountDataSource,
accountPagination,
handleAccountSearch,
handleAccountReset,
handleAccountPageChange,
handleViewTxn,
// 流水
txnFilter,
txnLoading,
txnDataSource,
txnPagination,
handleTxnSearch,
handleTxnReset,
handleTxnPageChange,
// 调点
adjustModalVisible,
adjusting,
presetUser,
openAdjustModal,
closeAdjustModal,
handleSubmitAdjust,
} = usePointsModel();
const { columns: accountColumns } = usePointsAccountColumns();
const { columns: txnColumns } = usePointsTxnColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
// 用户搜索实例
const accountSearch = useUserSearch();
const txnSearch = useUserSearch();
// 场景配置下拉选项(流水筛选用)
const agentOptions = ref<{ value: string; label: string }[]>([]);
onMounted(async () => {
try {
const res = await getAiAgentList();
if (res.code == 200 && res.data) {
agentOptions.value = res.data.map((item: AiAgentConfigVO) => ({
value: item.bizType,
label: item.name,
}));
}
} catch {
// 网络层已提示
}
});
/** 账户重置 */
const onAccountReset = () => {
accountSearch.reset();
handleAccountReset();
};
/** 流水重置 */
const onTxnReset = () => {
txnSearch.reset();
handleTxnReset();
};
/** 账户表格列(追加操作列) */
const accountTableColumns = [
...accountColumns,
{
title: '操作',
key: 'action',
width: 140,
fixed: 'right' as const,
align: 'center' as const,
},
];
/** 渲染账户操作列 */
const renderAccountAction = (record: AiPointsAccountVO) => (
<Space size={0}>
<Button
type="link"
size="small"
onClick={() =>
openAdjustModal({
userId: record.userId,
phone: record.phone,
nickname: record.nickname,
})
}
>
调点
</Button>
<Button type="link" size="small" onClick={() => handleViewTxn(record)}>
流水
</Button>
</Space>
);
return () => (
<>
{/* ===== 工具栏:子视图切换 + 操作按钮 ===== */}
<div class={pageStyles.filter}>
<Radio.Group
value={subView.value}
onUpdate:value={(val: 'account' | 'txn') => (subView.value = val)}
optionType="button"
buttonStyle="solid"
>
<Radio.Button value="account">积分账户</Radio.Button>
<Radio.Button value="txn">积分流水</Radio.Button>
</Radio.Group>
<div class={pageStyles.filterActions} style={{ marginLeft: 'auto' }}>
{subView.value === 'account' && (
<Button type="primary" onClick={() => openAdjustModal()}>
手工调点
</Button>
)}
</div>
</div>
<div class={pageStyles.filter} style={{ marginTop: '12px' }}>
{subView.value === 'account' ? (
<Space>
<Select
showSearch
value={accountFilter.userId ? String(accountFilter.userId) : undefined}
placeholder="手机号搜索用户"
filterOption={false}
loading={accountSearch.searching.value}
options={accountSearch.options.value as any}
style={{ width: '220px' }}
onSearch={accountSearch.onSearch}
onChange={(val: any) => {
accountFilter.userId = val ?? '';
}}
allowClear
notFoundContent={renderNotFound(
accountSearch.searching.value,
accountSearch.keyword.value,
)}
/>
<Button type="primary" onClick={handleAccountSearch} loading={accountLoading.value}>
查询
</Button>
<Button onClick={onAccountReset}>重置</Button>
</Space>
) : (
<Space wrap>
<Select
showSearch
value={txnFilter.userId ? String(txnFilter.userId) : undefined}
placeholder="手机号搜索用户"
filterOption={false}
loading={txnSearch.searching.value}
options={txnSearch.options.value as any}
style={{ width: '200px' }}
onSearch={txnSearch.onSearch}
onChange={(val: any) => {
txnFilter.userId = val ?? '';
}}
allowClear
notFoundContent={renderNotFound(txnSearch.searching.value, txnSearch.keyword.value)}
/>
<Select
value={txnFilter.bizType || undefined}
onUpdate:value={(val: any) => (txnFilter.bizType = val ?? '')}
placeholder="业务场景"
style={{ width: '200px' }}
options={agentOptions.value as any}
allowClear
/>
<Select
value={txnFilter.type}
onUpdate:value={(val: any) => (txnFilter.type = val ?? '')}
placeholder="流水类型"
style={{ width: '140px' }}
options={POINTS_TXN_TYPE_OPTIONS as any}
/>
<DatePicker
value={txnFilter.date ? dayjs(txnFilter.date) : undefined}
onUpdate:value={(val: any) => {
txnFilter.date = val ? val.format('YYYY-MM-DD') : '';
}}
format="YYYY-MM-DD"
placeholder="日期"
style={{ width: 160 }}
allowClear
/>
<Button type="primary" onClick={handleTxnSearch} loading={txnLoading.value}>
查询
</Button>
<Button onClick={onTxnReset}>重置</Button>
</Space>
)}
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
{subView.value === 'account' ? (
<Table
columns={accountTableColumns}
dataSource={accountDataSource.value}
loading={accountLoading.value}
rowKey="userId"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: { column: { key: string }; record: AiPointsAccountVO }) => {
if (args.column.key === 'action') {
return renderAccountAction(args.record);
}
return undefined;
},
}}
</Table>
) : (
<Table
columns={txnColumns}
dataSource={txnDataSource.value}
loading={txnLoading.value}
rowKey={(record: AiPointsTxnVO) =>
`${record.userId}_${record.messageId}_${record.createDate}`
}
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
/>
)}
</div>
<div class={pageStyles.pagination}>
{subView.value === 'account' ? (
<Pagination
{...paginationProps}
current={(accountPagination as any).current.value}
pageSize={(accountPagination as any).pageSize.value}
total={(accountPagination as any).total.value}
onChange={handleAccountPageChange}
/>
) : (
<Pagination
{...paginationProps}
current={(txnPagination as any).current.value}
pageSize={(txnPagination as any).pageSize.value}
total={(txnPagination as any).total.value}
onChange={handleTxnPageChange}
/>
)}
</div>
</div>
{adjustModalVisible.value && (
<PointsAdjustModal
visible={adjustModalVisible.value}
submitting={adjusting.value}
presetUser={presetUser.value}
onClose={closeAdjustModal}
onSubmit={handleSubmitAdjust}
/>
)}
</>
);
},
});
@@ -0,0 +1,119 @@
import { ref } from 'vue';
import { getUserList, type EventUserVO } from '@/pages/events/users/model/services';
export interface UserSearchOption {
/** userId 作为 value(传给后端) */
value: string;
/** 展示文案:手机号(昵称) */
label: string;
/** 原始用户数据 */
raw: EventUserVO;
}
export interface PresetUserInfo {
userId: number | string;
phone?: string;
nickname?: string;
}
export function useUserSearch(delay = 300) {
/** 搜索关键字(内部管理,调用方无需关心) */
const keyword = ref('');
/** Select 选项列表 */
const options = ref<UserSearchOption[]>([]);
/** 搜索中状态 */
const searching = ref(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const doSearch = async (val: string) => {
const trimmed = val.trim();
if (!trimmed) {
options.value = [];
return;
}
searching.value = true;
try {
const res = await getUserList({
page: '1',
limit: '20',
phone: trimmed,
});
if (res.code == 200 && res.data?.list) {
options.value = res.data.list.map((user) => ({
value: user.id,
label: `${user.phone}${user.nickname ? `(${user.nickname})` : ''}`,
raw: user,
}));
} else {
options.value = [];
}
} catch {
options.value = [];
} finally {
searching.value = false;
}
};
/** Select onSearch 回调(带防抖) */
const onSearch = (val: string) => {
keyword.value = val;
if (timer) clearTimeout(timer);
if (!val || !val.trim()) {
options.value = [];
return;
}
timer = setTimeout(() => doSearch(val), delay);
};
/** Select onChange 回调:返回 userId(数字),清空时返回 undefined */
const onChange = (val: any): number | undefined => {
return val ? Number(val) : undefined;
};
/** 注入预设用户选项(从表格行点击带入时使用) */
const setPresetUser = (user: PresetUserInfo) => {
const label = user.phone
? `${user.phone}${user.nickname ? `(${user.nickname})` : ''}`
: `用户ID: ${user.userId}`;
options.value = [
{
value: String(user.userId),
label,
raw: {
id: String(user.userId),
nickname: user.nickname || '',
phone: user.phone || '',
realName: '',
idCard: '',
idCardImg: '',
idCardImgList: [],
gender: '',
status: '',
createTime: '',
},
},
];
};
/** 重置:清除 keyword + options(供筛选栏重置按钮调用) */
const reset = () => {
keyword.value = '';
options.value = [];
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return {
keyword,
options,
searching,
onSearch,
onChange,
doSearch,
setPresetUser,
reset,
};
}
+122
View File
@@ -0,0 +1,122 @@
import { defineComponent, ref } from 'vue';
import { Button, Table, Space, Tabs, TabPane } from 'ant-design-vue';
import { useAiAgentModel } from './model/useAiAgentModel';
import { useAiAgentColumns } from './model/useAiAgentColumns';
import { useContainerSize } from '@/hooks';
import pageStyles from '@/assets/styles/pageLayout.module.less';
import AiAgentEditModal from './components/AiAgentEditModal';
import AiModelDict from './components/AiModelDict';
import BlacklistTab from './components/BlacklistTab';
import PointsTab from './components/PointsTab';
import BillingReportTab from './components/BillingReportTab';
import type { AiAgentConfigVO } from './model/services';
export default defineComponent({
name: 'SystemAiAgent',
setup() {
const activeTab = ref('agent');
const {
loading,
dataSource,
editModalVisible,
currentRecord,
submitting,
handleSearch,
openEditModal,
closeEditModal,
handleSubmitEdit,
} = useAiAgentModel();
const { columns } = useAiAgentColumns();
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
/** 渲染操作列 */
const renderAction = (record: AiAgentConfigVO) => (
<Button type="link" size="small" onClick={() => openEditModal(record)}>
编辑
</Button>
);
/** 完整表格列 */
const tableColumns = [
...columns,
{
title: '操作',
key: 'action',
width: 80,
fixed: 'right' as const,
align: 'center' as const,
},
];
return () => (
<div class={pageStyles.containerMain}>
<Tabs
activeKey={activeTab.value}
onUpdate:activeKey={(val: string | number) => (activeTab.value = String(val))}
class={pageStyles.tabs}
destroyInactiveTabPane
>
<TabPane key="agent" tab="场景配置">
<div class={pageStyles.filter}>
<Space>
<Button type="primary" onClick={handleSearch} loading={loading.value}>
刷新
</Button>
</Space>
</div>
<div class={pageStyles.table}>
<div ref={containerRef} class={pageStyles.tableBody}>
<Table
columns={tableColumns}
dataSource={dataSource.value}
loading={loading.value}
rowKey="id"
scroll={{ x: 'max-content', y: height.value }}
pagination={false}
>
{{
bodyCell: (args: { column: { key: string }; record: AiAgentConfigVO }) => {
if (args.column.key === 'action') {
return renderAction(args.record);
}
return undefined;
},
}}
</Table>
</div>
</div>
</TabPane>
<TabPane key="model" tab="模型字典">
<AiModelDict />
</TabPane>
<TabPane key="points" tab="积分账户">
<PointsTab />
</TabPane>
<TabPane key="blacklist" tab="黑名单管理">
<BlacklistTab />
</TabPane>
<TabPane key="report" tab="报表">
<BillingReportTab />
</TabPane>
</Tabs>
{editModalVisible.value && (
<AiAgentEditModal
visible={editModalVisible.value}
record={currentRecord.value as any}
submitting={submitting.value}
onClose={closeEditModal}
onSubmit={handleSubmitEdit}
/>
)}
</div>
);
},
});
+59
View File
@@ -0,0 +1,59 @@
/**
* AI 场景配置 - 配置文件
* 集中管理选项配置和状态映射
*/
import type { StatusTagTone } from '@/components';
/** 业务开关映射 */
export const ENABLED_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '开启', tone: 'success' },
'0': { label: '关闭', tone: 'danger' },
};
/** 计费开关映射 */
export const BILLING_ENABLED_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
'1': { label: '计费', tone: 'primary' },
'0': { label: '免费', tone: 'default' },
};
/** 倍率最小值 */
export const MIN_MULTIPLIER = 0.01;
/** 免费额度最大值 */
export const MAX_FREE_POINTS = 999999;
/** 黑名单状态映射 */
export const BLACKLIST_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
1: { label: '拉黑中', tone: 'danger' },
0: { label: '已解除', tone: 'default' },
};
/** 黑名单状态筛选选项 */
export const BLACKLIST_STATUS_OPTIONS = [
{ value: '' as '' | number, label: '全部' },
{ value: 1, label: '拉黑中' },
{ value: 0, label: '已解除' },
] as const;
/** 流水类型映射 */
export const POINTS_TXN_TYPE_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
1: { label: '充值/赠送', tone: 'success' },
2: { label: '调账', tone: 'primary' },
3: { label: '消费', tone: 'warning' },
4: { label: '欠款', tone: 'danger' },
};
/** 流水类型筛选选项 */
export const POINTS_TXN_TYPE_OPTIONS = [
{ value: '' as '' | number, label: '全部' },
{ value: 1, label: '充值/赠送' },
{ value: 2, label: '调账' },
{ value: 3, label: '消费' },
{ value: 4, label: '欠款' },
] as const;
/** 调点类型选项 */
export const POINTS_ADJUST_TYPE_OPTIONS = [
{ value: 1, label: '充值/赠送' },
{ value: 2, label: '调账' },
] as const;
+416
View File
@@ -0,0 +1,416 @@
import { get, post } from '@/utils/request';
/** AI 场景配置 VO */
export interface AiAgentConfigVO {
id: string | number;
/** 业务类型(唯一键) */
bizType: string;
/** 展示名 */
name: string;
/** 业务开关 1/0(0=前端隐藏入口、会话创建被拒) */
enabled: string | number;
/** 计费开关 1/0(0=该场景免费) */
billingEnabled: string | number;
/** 计费倍率(>0,促销可调) */
billingMultiplier: string | number;
/** 每日免费额度(口径=每日免费结算条数;0=无免费额) */
dailyFreePoints: string | number;
/** 运营备注 */
descr: string;
}
/** 更新场景配置参数 */
export interface AiAgentUpdateParams {
id: string | number;
enabled?: number;
billingEnabled?: number;
billingMultiplier?: number;
dailyFreePoints?: number;
descr?: string;
}
/** 模型字典 VO */
export interface AiModelDictVO {
/** 配置键(如 model / extract.apiKey) */
label: string;
/** 配置值(敏感键脱敏;未配置 null) */
value: string | null;
/** 是否敏感(apiKey 类:留空=保留原值) */
sensitive: boolean;
/** 用途组:chat=对话/工具(全局默认) extract=导入提取 failover=备用网关 */
purpose: string;
/** 表单提示文案 */
remark: string;
}
/** 模型字典保存参数 */
export interface AiModelDictSaveParams {
items: { label: string; value: string }[];
}
/** 模型字典重置参数 */
export interface AiModelDictResetParams {
labels: string[];
}
/** AI 积分账户 VO */
export interface AiPointsAccountVO {
/** 用户 ID */
userId: number;
/** 昵称 */
nickname?: string;
/** 手机号 */
phone?: string;
/** 当前余额(可为负=欠款) */
points: number;
/** 累计充值 */
totalRecharged: number;
/** 累计消费 */
totalConsumed: number;
}
/** AI 积分流水 VO */
export interface AiPointsTxnVO {
/** 用户 ID */
userId: number;
/** 昵称 */
nickname?: string;
/** 手机号 */
phone?: string;
/** 业务场景(如 matchImport / chat 等) */
bizType: string;
/** 流水类型 1=充值/赠送 2=调账 3=消费 4=欠款 */
type: number;
/** 变更积分数(正=充/赠/调增,负=扣/消费) */
changePoints: number;
/** 输入 token 数 */
inputTokens: number;
/** 输出 token 数 */
outputTokens: number;
/** 模型标识 */
model: string;
/** 消息 ID */
messageId: string;
/** 是否免费 1=免费 0=付费 */
freeFlag: number;
/** 审计备注 */
descr: string;
/** 创建时间 */
createDate: string;
}
/** 积分账户分页查询参数 */
export interface AiPointsAccountQueryParams {
page: number;
size: number;
/** 可选:用户 ID 筛选 */
userId?: number;
}
/** 积分流水分页查询参数 */
export interface AiPointsTxnQueryParams {
page: number;
size: number;
/** 可选:用户 ID 筛选 */
userId?: number;
/** 可选:业务场景筛选 */
bizType?: string;
/** 可选:流水类型 1充值/2调账/3消费/4欠款 */
type?: number;
/** 可选:日期筛选 yyyy-MM-dd */
date?: string;
}
/** 手工调点参数 */
export interface AiPointsAdjustParams {
/** 必填:用户 ID */
userId: number;
/** 必填:积分数(非0;正=充/赠,负=扣) */
points: number;
/** 可选 1=充值/赠送 2=调账;缺省按正负推导 */
type?: number;
/** 可选:审计备注 */
descr?: string;
}
/** AI 黑名单 VO */
export interface AiBlacklistVO {
id: number;
/** 用户 ID */
userId: number;
/** 昵称 */
nickname?: string;
/** 手机号 */
phone?: string;
/** 拉黑原因(审计要求) */
reason: string;
/** 状态:1=拉黑中 0=已解除 */
status: number;
/** 到期时间(null=永久;yyyy-MM-dd HH:mm:ss) */
expireDate: string | null;
/** 操作人 ID */
operatorId: number;
/** 创建时间 */
createDate: string;
/** 更新时间 */
updateDate: string;
}
/** 黑名单分页查询参数 */
export interface AiBlacklistQueryParams {
page: number;
size: number;
/** 可选:用户 ID 筛选 */
userId?: number;
/** 可选:状态筛选 1=拉黑中 0=已解除 */
status?: number;
}
/** 拉黑参数 */
export interface AiBlacklistAddParams {
/** 必填:用户 ID */
userId: number;
/** 必填:拉黑原因 */
reason: string;
/** 可选:到期时间(yyyy-MM-dd[ HH:mm:ss];空=永久) */
expireDate?: string;
}
/** 解除拉黑参数 */
export interface AiBlacklistRemoveParams {
userId: number;
}
/** 分页数据 */
export interface PageData<T> {
total: number;
list: T[];
}
/** 接口通用返回结构 */
export interface ApiResult<T> {
code: number;
msg: string;
data: T;
}
/** 计费单价 VO(文档 §3.3,GET /admin/manager/ai/billing/prices) */
export interface AiBillingPriceVO {
/** 模型标识 */
model: string;
/** 输入单价(点/千 token) */
inputPrice: number;
/** 输出单价(点/千 token) */
outputPrice: number;
}
/** 通用字典类型 VO(§2.4 GET types) */
export interface SysDictTypeVO {
id: number;
dictType: string;
dictName: string;
remark: string;
sort: number;
dataCount: number;
}
/** 通用字典数据行 VO(§2.4 GET data/list) */
export interface SysDictDataVO {
id: number;
dictTypeId: number;
dictLabel: string;
dictValue: string;
sort: number;
remark: string;
}
/** 通用字典 data/save 参数(§2.4) */
export interface DictDataSaveParams {
/** 数据行 id;空=新建 */
id?: number;
/** 关联类型 id */
dictTypeId: number;
/** 字典 label,如 deepseek-flash.input / default.output */
dictLabel: string;
/** 字典 value,单价值 */
dictValue: string;
/** 排序 */
sort?: number;
/** 备注 */
remark?: string;
}
/** 通用字典 data/delete 参数(§2.4,按 id 删除) */
export interface DictDataDeleteParams {
id: number;
}
// ===== 报表相关(文档 §1.4) =====
/** 计费报表行 VO(§1.4 ⑥ GET /admin/manager/ai/billing/report) */
export interface AiBillingReportVO {
/** 日期 yyyy-MM-dd */
date: string;
/** 业务场景 */
bizType: string;
/** 消息数 */
messageCount: number;
/** 总 token 数 */
totalTokens: number;
/** 总消耗积分 */
totalPoints: number;
}
/** 直解格式统计报表行 VO(§1.4 ⑦ GET /admin/manager/ai/freeform/report) */
export interface AiFreeformReportVO {
/** 文档指纹 */
fingerprint: string;
/** 文档性质 */
docNature: string;
/** 信号集 */
signalSet: string;
/** 表头签名 */
headerSignature: string;
/** 使用次数 */
useCount: number;
/** 最后使用时间 */
lastUsedAt: string;
/** 是否高频(月频次≥5) */
highFrequency: boolean;
}
/** 计费报表查询参数(§1.4 ⑥) */
export interface AiBillingReportQueryParams {
/** 可选:开始日期 yyyy-MM-dd */
startDate?: string;
/** 可选:结束日期 yyyy-MM-dd */
endDate?: string;
}
/** 直解报表查询参数(§1.4 ⑦) */
export interface AiFreeformReportQueryParams {
/** 返回行数,默认 50 */
top?: number;
}
const AI_AGENT_LIST = '/admin/manager/ai/agent/list';
const AI_AGENT_UPDATE = '/admin/manager/ai/agent/update';
const AI_MODEL_DICT_GET = '/admin/manager/ai/model/dict';
const AI_MODEL_DICT_SAVE = '/admin/manager/ai/model/dict';
const AI_MODEL_DICT_RESET = '/admin/manager/ai/model/dict/reset';
const AI_BLACKLIST_LIST = '/admin/manager/ai/blacklist/list';
const AI_BLACKLIST_ADD = '/admin/manager/ai/blacklist/add';
const AI_BLACKLIST_REMOVE = '/admin/manager/ai/blacklist/remove';
const AI_POINTS_ACCOUNTS = '/admin/manager/ai/points/accounts';
const AI_POINTS_ADJUST = '/admin/manager/ai/points/adjust';
const AI_POINTS_TRANSACTIONS = '/admin/manager/ai/points/transactions';
const AI_BILLING_PRICES = '/admin/manager/ai/billing/prices';
const DICT_TYPES = '/admin/manager/dict/types';
const DICT_DATA_LIST = '/admin/manager/dict/data/list';
const DICT_DATA_SAVE = '/admin/manager/dict/data/save';
const DICT_DATA_DELETE = '/admin/manager/dict/data/delete';
const AI_BILLING_REPORT = '/admin/manager/ai/billing/report';
const AI_FREEFORM_REPORT = '/admin/manager/ai/freeform/report';
/** 单价字典类型标识 */
export const AI_BILLING_PRICE_DICT_TYPE = 'ai_billing_price';
/** 场景配置列表 */
export function getAiAgentList(): Promise<ApiResult<AiAgentConfigVO[]>> {
return get(AI_AGENT_LIST, {});
}
/** 更新场景配置 */
export function updateAiAgent(params: AiAgentUpdateParams): Promise<ApiResult<string>> {
return post(AI_AGENT_UPDATE, params);
}
/** 读取模型字典当前配置(白名单 16 键全量返回) */
export function getAiModelDict(): Promise<ApiResult<AiModelDictVO[]>> {
return get(AI_MODEL_DICT_GET, {});
}
/** 保存模型字典(整组 upsert) */
export function saveAiModelDict(params: AiModelDictSaveParams): Promise<ApiResult<string>> {
return post(AI_MODEL_DICT_SAVE, params);
}
/** 重置模型字典指定键(回落后一级) */
export function resetAiModelDict(params: AiModelDictResetParams): Promise<ApiResult<string>> {
return post(AI_MODEL_DICT_RESET, params);
}
/** 积分账户分页列表 */
export function getAiPointsAccounts(
params: AiPointsAccountQueryParams,
): Promise<ApiResult<PageData<AiPointsAccountVO>>> {
return get(AI_POINTS_ACCOUNTS, params as unknown as Record<string, unknown>);
}
/** 手工调点(充值/纠错) */
export function adjustAiPoints(params: AiPointsAdjustParams): Promise<ApiResult<string>> {
return post(AI_POINTS_ADJUST, params);
}
/** 积分流水分页列表 */
export function getAiPointsTransactions(
params: AiPointsTxnQueryParams,
): Promise<ApiResult<PageData<AiPointsTxnVO>>> {
return get(AI_POINTS_TRANSACTIONS, params as unknown as Record<string, unknown>);
}
/** 黑名单分页列表 */
export function getAiBlacklistList(
params: AiBlacklistQueryParams,
): Promise<ApiResult<PageData<AiBlacklistVO>>> {
return get(AI_BLACKLIST_LIST, params as unknown as Record<string, unknown>);
}
/** 拉黑 */
export function addAiBlacklist(params: AiBlacklistAddParams): Promise<ApiResult<string>> {
return post(AI_BLACKLIST_ADD, params);
}
/** 解除拉黑 */
export function removeAiBlacklist(params: AiBlacklistRemoveParams): Promise<ApiResult<string>> {
return post(AI_BLACKLIST_REMOVE, params);
}
/** 读取模型计费单价列表(生效价,§3.3) */
export function getAiBillingPrices(): Promise<ApiResult<AiBillingPriceVO[]>> {
return get(AI_BILLING_PRICES, {});
}
/** 通用字典 — 获取全部类型(§2.4 GET types) */
export function getDictTypes(): Promise<ApiResult<SysDictTypeVO[]>> {
return get(DICT_TYPES, {});
}
/** 通用字典 — 获取某类型下数据行(§2.4 GET data/list) */
export function getDictDataList(dictTypeId: number): Promise<ApiResult<SysDictDataVO[]>> {
return get(DICT_DATA_LIST, { dictTypeId });
}
/** 通用字典 — 保存/更新数据行(§2.4 POST data/save) */
export function saveDictData(params: DictDataSaveParams): Promise<ApiResult<string>> {
return post(DICT_DATA_SAVE, params);
}
/** 通用字典 — 删除数据行(§2.4 POST data/delete,按 id 删除) */
export function deleteDictData(id: number): Promise<ApiResult<string>> {
return post(DICT_DATA_DELETE, { id });
}
/** 计费报表(§1.4 ⑥,按日按场景汇总) */
export function getAiBillingReport(
params: AiBillingReportQueryParams,
): Promise<ApiResult<AiBillingReportVO[]>> {
return get(AI_BILLING_REPORT, params as Record<string, unknown>);
}
/** 直解格式统计报表(§1.4 ⑦) */
export function getAiFreeformReport(
params: AiFreeformReportQueryParams,
): Promise<ApiResult<AiFreeformReportVO[]>> {
return get(AI_FREEFORM_REPORT, params as Record<string, unknown>);
}
@@ -0,0 +1,93 @@
import { h, type VNodeChild } from 'vue';
import { StatusTag, type StatusTagTone } from '@/components';
import { ENABLED_MAP, BILLING_ENABLED_MAP } from './config';
/**
* AI 场景配置页 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
* 业务开关 / 计费开关:列表仅展示文案,编辑在弹窗中进行
* 操作列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
*/
/** 渲染状态标签 */
const renderStatusTag = (
text: string | number,
map: Record<string, { label: string; tone: StatusTagTone }>,
): VNodeChild => {
const info = map[String(text)] || { label: String(text), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
};
/** 渲染计费倍率 */
const renderMultiplier = (text: string | number): string => `${text}x`;
/** 渲染每日免费额度 */
const renderFreePoints = (text: string | number): string =>
Number(text) > 0 ? `${text} 条` : '无';
/** 渲染备注 */
const renderDescr = (text: string): string => text || '-';
export function useAiAgentColumns() {
const columns = [
{
title: '场景名称',
dataIndex: 'name',
key: 'name',
width: 160,
align: 'center' as const,
},
{
title: '业务类型',
dataIndex: 'bizType',
key: 'bizType',
width: 160,
align: 'center' as const,
},
{
title: '业务开关',
dataIndex: 'enabled',
key: 'enabled',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): VNodeChild =>
renderStatusTag(text, ENABLED_MAP),
},
{
title: '计费开关',
dataIndex: 'billingEnabled',
key: 'billingEnabled',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): VNodeChild =>
renderStatusTag(text, BILLING_ENABLED_MAP),
},
{
title: '计费倍率',
dataIndex: 'billingMultiplier',
key: 'billingMultiplier',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): string => renderMultiplier(text),
},
{
title: '每日免费额度',
dataIndex: 'dailyFreePoints',
key: 'dailyFreePoints',
width: 120,
align: 'center' as const,
customRender: ({ text }: { text: string | number }): string => renderFreePoints(text),
},
{
title: '备注',
dataIndex: 'descr',
key: 'descr',
width: 200,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderDescr(text),
},
];
return { columns };
}
@@ -0,0 +1,87 @@
import { computed } from 'vue';
import { message } from 'ant-design-vue';
import { useRequest } from '@/hooks/useRequest';
import { useState } from '@/hooks';
import {
getAiAgentList,
updateAiAgent,
type AiAgentConfigVO,
type AiAgentUpdateParams,
type ApiResult,
} from './services';
/**
* AI 场景配置页数据模型
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
*
* 架构分层:
* - 数据层:services.ts(API 调用)
* - 逻辑层:本文件(状态 + 业务逻辑)
* - 视图层:useAiAgentColumns.ts + index.tsx(纯 UI 渲染)
*/
export function useAiAgentModel() {
const [editModalVisible, setEditModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<AiAgentConfigVO | null>(null);
const [submitting, setSubmitting] = useState(false);
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<AiAgentConfigVO[]>>(() => getAiAgentList(), {
refreshDeps: [],
formatResult: (res) => res,
});
// 提取列表数据
const dataSource = computed<AiAgentConfigVO[]>(() => {
const res = data.value;
return res ? (res as any).data || [] : [];
});
const handleSearch = () => fetchList();
/** 打开编辑弹窗 */
const openEditModal = (record: AiAgentConfigVO) => {
setCurrentRecord({ ...record });
setEditModalVisible(true);
};
/** 关闭编辑弹窗 */
const closeEditModal = () => {
setEditModalVisible(false);
setCurrentRecord(null);
};
/** 提交编辑(对接文档 §1.1② POST /admin/manager/ai/agent/update) */
const handleSubmitEdit = async (params: AiAgentUpdateParams) => {
setSubmitting(true);
try {
const res = await updateAiAgent(params);
if (res && res.code == 200) {
message.success('配置已更新');
closeEditModal();
fetchList();
} else {
message.error(res?.msg || '更新失败');
}
} catch (err) {
console.error('[useAiAgentModel] updateAiAgent error:', err);
message.error('更新失败');
} finally {
setSubmitting(false);
}
};
return {
loading,
dataSource,
editModalVisible,
currentRecord,
submitting,
handleSearch,
openEditModal,
closeEditModal,
handleSubmitEdit,
};
}
@@ -0,0 +1,99 @@
import { h, type VNodeChild } from 'vue';
import { StatusTag } from '@/components';
import { BLACKLIST_STATUS_MAP } from './config';
/**
* AI 黑名单 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
*/
/** 渲染状态标签 */
const renderStatusTag = (text: number): VNodeChild => {
const info = BLACKLIST_STATUS_MAP[text] || { label: String(text), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
};
/** 渲染到期时间 */
const renderExpireDate = (text: string | null): string => {
if (!text) return '永久';
return text;
};
/** 渲染原因 */
const renderReason = (text: string): string => text || '-';
export function useBlacklistColumns() {
const columns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
align: 'center' as const,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderReason(text),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderReason(text),
},
{
title: '拉黑原因',
dataIndex: 'reason',
key: 'reason',
width: 240,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderReason(text),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderStatusTag(text),
},
{
title: '到期时间',
dataIndex: 'expireDate',
key: 'expireDate',
width: 180,
align: 'center' as const,
customRender: ({ text }: { text: string | null }): string => renderExpireDate(text),
},
{
title: '操作人ID',
dataIndex: 'operatorId',
key: 'operatorId',
width: 100,
align: 'center' as const,
},
{
title: '拉黑时间',
dataIndex: 'createDate',
key: 'createDate',
width: 180,
align: 'center' as const,
},
{
title: '更新时间',
dataIndex: 'updateDate',
key: 'updateDate',
width: 180,
align: 'center' as const,
},
];
return { columns };
}
@@ -0,0 +1,162 @@
import { computed, reactive, nextTick, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
import { useThrottleFn } from '@/hooks';
import {
getAiBlacklistList,
addAiBlacklist,
removeAiBlacklist,
type AiBlacklistVO,
type AiBlacklistQueryParams,
type AiBlacklistAddParams,
type ApiResult,
type PageData,
} from './services';
/**
* AI 黑名单 - 数据模型
*
* 职责:状态管理 + 数据获取 + 业务逻辑(拉黑/解除)
*
* 对接文档 §1.3:
* - GET /admin/manager/ai/blacklist/list — 分页
* - POST /admin/manager/ai/blacklist/add — 拉黑
* - POST /admin/manager/ai/blacklist/remove — 解除
*/
export function useBlacklistModel() {
const filterForm = reactive({
userId: '' as string | number,
status: '' as '' | number,
});
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const buildQueryParams = (): AiBlacklistQueryParams => {
const params: AiBlacklistQueryParams = {
page: (pagination as any).current.value,
size: (pagination as any).pageSize.value,
};
const uid = filterForm.userId;
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
params.userId = Number(uid);
}
if (filterForm.status !== '') {
params.status = Number(filterForm.status);
}
return params;
};
const {
data,
loading,
run: fetchList,
} = useRequest<ApiResult<PageData<AiBlacklistVO>>>(() => getAiBlacklistList(buildQueryParams()), {
refreshDeps: [],
formatResult: (res) => res,
});
const dataSource = computed<AiBlacklistVO[]>(() => {
const res = data.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(pagination, (data.value as any)?.data?.total);
}, [data]);
const addModalVisible = ref(false);
const submitting = ref(false);
/** 搜索 */
const handleSearch = () => {
pagination.reset();
fetchList();
};
/** 重置 */
const handleReset = useThrottleFn(async () => {
filterForm.userId = '';
filterForm.status = '';
pagination.reset();
await fetchList();
}, 500);
/** 分页变更 */
const handlePageChange = (page: number, pageSize: number) => {
pagination.setCurrent(page);
if (pageSize !== (pagination as any).pageSize.value) {
pagination.setPageSize(pageSize);
}
nextTick().then(() => fetchList());
};
/** 打开拉黑弹窗 */
const openAddModal = () => {
addModalVisible.value = true;
};
/** 关闭拉黑弹窗 */
const closeAddModal = () => {
addModalVisible.value = false;
};
/** 提交拉黑 */
const handleSubmitAdd = async (params: AiBlacklistAddParams) => {
submitting.value = true;
try {
const res = await addAiBlacklist(params);
if (res.code == 200) {
message.success(res.msg || '已拉黑');
closeAddModal();
fetchList();
} else {
message.error(res.msg || '拉黑失败');
}
} catch {
message.error('拉黑失败');
} finally {
submitting.value = false;
}
};
/** 解除拉黑(带确认) */
const handleRemove = useThrottleFn((record: AiBlacklistVO) => {
Modal.confirm({
title: '确认解除拉黑',
content: `确认解除用户 ${record.userId} 的拉黑状态吗?解除后该用户将恢复 AI 功能使用权限。`,
okText: '确认解除',
cancelText: '取消',
onOk: async () => {
try {
const res = await removeAiBlacklist({ userId: record.userId });
if (res.code == 200) {
message.success(res.msg || '已解除拉黑');
fetchList();
} else {
message.error(res.msg || '解除失败');
}
} catch {
message.error('解除失败');
}
},
});
}, 500);
return {
filterForm,
loading,
dataSource,
pagination,
addModalVisible,
submitting,
handleSearch,
handleReset,
handlePageChange,
openAddModal,
closeAddModal,
handleSubmitAdd,
handleRemove,
};
}
@@ -0,0 +1,197 @@
import { h, type VNodeChild } from 'vue';
import { StatusTag } from '@/components';
import { POINTS_TXN_TYPE_MAP } from './config';
/**
* AI 积分账户与流水 - 表格列配置(视图层)
*
* 职责:纯表格列配置,不包含业务逻辑
*/
/** 渲染余额(负数=欠款,红色) */
const renderPoints = (text: number): VNodeChild => {
if (text < 0) {
return h('span', { style: { color: '#f5222d', fontWeight: 600 } }, String(text));
}
return String(text);
};
/** 渲染流水类型标签 */
const renderTxnTypeTag = (text: number): VNodeChild => {
const info = POINTS_TXN_TYPE_MAP[text] || { label: String(text), tone: 'default' as const };
return h(StatusTag, { label: info.label, tone: info.tone });
};
/** 渲染变更积分(正数绿色,负数红色) */
const renderChangePoints = (text: number): VNodeChild => {
const color = text >= 0 ? '#52c41a' : '#f5222d';
const sign = text >= 0 ? '+' : '';
return h('span', { style: { color, fontWeight: 600 } }, `${sign}${text}`);
};
/** 渲染免费标记 */
const renderFreeFlag = (text: number): string => (text === 1 ? '免费' : '付费');
/** 渲染文本兜底 */
const renderText = (text: string): string => text || '-';
/** 积分账户表格列 */
export function usePointsAccountColumns() {
const columns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
align: 'center' as const,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 140,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '当前余额',
dataIndex: 'points',
key: 'points',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderPoints(text),
},
{
title: '累计充值',
dataIndex: 'totalRecharged',
key: 'totalRecharged',
width: 140,
align: 'center' as const,
},
{
title: '累计消费',
dataIndex: 'totalConsumed',
key: 'totalConsumed',
width: 140,
align: 'center' as const,
},
];
return { columns };
}
/** 积分流水表格列 */
export function usePointsTxnColumns() {
const columns = [
{
title: '用户ID',
dataIndex: 'userId',
key: 'userId',
width: 100,
align: 'center' as const,
},
{
title: '昵称',
dataIndex: 'nickname',
key: 'nickname',
width: 120,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 130,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '业务场景',
dataIndex: 'bizType',
key: 'bizType',
width: 140,
align: 'center' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '流水类型',
dataIndex: 'type',
key: 'type',
width: 110,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderTxnTypeTag(text),
},
{
title: '变更积分',
dataIndex: 'changePoints',
key: 'changePoints',
width: 110,
align: 'center' as const,
customRender: ({ text }: { text: number }): VNodeChild => renderChangePoints(text),
},
{
title: '输入Token',
dataIndex: 'inputTokens',
key: 'inputTokens',
width: 110,
align: 'center' as const,
},
{
title: '输出Token',
dataIndex: 'outputTokens',
key: 'outputTokens',
width: 110,
align: 'center' as const,
},
{
title: '模型',
dataIndex: 'model',
key: 'model',
width: 160,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '消息ID',
dataIndex: 'messageId',
key: 'messageId',
width: 160,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '免费',
dataIndex: 'freeFlag',
key: 'freeFlag',
width: 80,
align: 'center' as const,
customRender: ({ text }: { text: number }): string => renderFreeFlag(text),
},
{
title: '备注',
dataIndex: 'descr',
key: 'descr',
width: 200,
align: 'left' as const,
customRender: ({ text }: { text: string }): string => renderText(text),
},
{
title: '时间',
dataIndex: 'createDate',
key: 'createDate',
width: 180,
align: 'center' as const,
},
];
return { columns };
}
@@ -0,0 +1,241 @@
import { computed, reactive, nextTick, ref } from 'vue';
import { message } from 'ant-design-vue';
import { useRequest } from '@/hooks/useRequest';
import { useEffect } from '@/hooks/useEffect';
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
import { useThrottleFn } from '@/hooks';
import {
getAiPointsAccounts,
adjustAiPoints,
getAiPointsTransactions,
type AiPointsAccountVO,
type AiPointsTxnVO,
type AiPointsAccountQueryParams,
type AiPointsTxnQueryParams,
type AiPointsAdjustParams,
type ApiResult,
type PageData,
} from './services';
/**
* AI 积分账户与流水 - 数据模型
*
* 职责:状态管理 + 数据获取 + 业务逻辑(账户分页 / 流水分页 / 手工调点)
*
* 对接文档 §1.2:
* - GET /admin/manager/ai/points/accounts — 账户分页
* - POST /admin/manager/ai/points/adjust — 手工调点(充值/纠错)
* - GET /admin/manager/ai/points/transactions — 流水分页
*/
export function usePointsModel() {
const subView = ref<'account' | 'txn'>('account');
const accountFilter = reactive({
userId: '' as string | number,
});
const txnFilter = reactive({
userId: '' as string | number,
bizType: '' as string,
type: '' as '' | number,
date: '' as string,
});
const accountPagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const txnPagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
const buildAccountQueryParams = (): AiPointsAccountQueryParams => {
const params: AiPointsAccountQueryParams = {
page: (accountPagination as any).current.value,
size: (accountPagination as any).pageSize.value,
};
const uid = accountFilter.userId;
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
params.userId = Number(uid);
}
return params;
};
const buildTxnQueryParams = (): AiPointsTxnQueryParams => {
const params: AiPointsTxnQueryParams = {
page: (txnPagination as any).current.value,
size: (txnPagination as any).pageSize.value,
};
const uid = txnFilter.userId;
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
params.userId = Number(uid);
}
if (txnFilter.bizType) {
params.bizType = txnFilter.bizType;
}
if (txnFilter.type !== '') {
params.type = Number(txnFilter.type);
}
if (txnFilter.date) {
params.date = txnFilter.date;
}
return params;
};
const {
data: accountData,
loading: accountLoading,
run: fetchAccounts,
} = useRequest<ApiResult<PageData<AiPointsAccountVO>>>(
() => getAiPointsAccounts(buildAccountQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const accountDataSource = computed<AiPointsAccountVO[]>(() => {
const res = accountData.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(accountPagination, (accountData.value as any)?.data?.total);
}, [accountData]);
const {
data: txnData,
loading: txnLoading,
run: fetchTxns,
} = useRequest<ApiResult<PageData<AiPointsTxnVO>>>(
() => getAiPointsTransactions(buildTxnQueryParams()),
{ refreshDeps: [], formatResult: (res) => res },
);
const txnDataSource = computed<AiPointsTxnVO[]>(() => {
const res = txnData.value;
return res ? (res as any).data?.list || [] : [];
});
useEffect(() => {
syncPaginationTotal(txnPagination, (txnData.value as any)?.data?.total);
}, [txnData]);
const adjustModalVisible = ref(false);
const adjusting = ref(false);
/** 调点弹窗预设用户信息(从账户行点击「调点」时带入) */
const presetUser = ref<{ userId: number; phone?: string; nickname?: string } | undefined>(
undefined,
);
/** 账户搜索 */
const handleAccountSearch = () => {
accountPagination.reset();
fetchAccounts();
};
/** 账户重置 */
const handleAccountReset = useThrottleFn(async () => {
accountFilter.userId = '';
accountPagination.reset();
await fetchAccounts();
}, 500);
/** 账户分页变更 */
const handleAccountPageChange = (page: number, pageSize: number) => {
accountPagination.setCurrent(page);
if (pageSize !== (accountPagination as any).pageSize.value) {
accountPagination.setPageSize(pageSize);
}
nextTick().then(() => fetchAccounts());
};
/** 流水搜索 */
const handleTxnSearch = () => {
txnPagination.reset();
fetchTxns();
};
/** 流水重置 */
const handleTxnReset = useThrottleFn(async () => {
txnFilter.userId = '';
txnFilter.bizType = '';
txnFilter.type = '';
txnFilter.date = '';
txnPagination.reset();
await fetchTxns();
}, 500);
/** 流水分页变更 */
const handleTxnPageChange = (page: number, pageSize: number) => {
txnPagination.setCurrent(page);
if (pageSize !== (txnPagination as any).pageSize.value) {
txnPagination.setPageSize(pageSize);
}
nextTick().then(() => fetchTxns());
};
/** 查看某用户流水(从账户行点击) */
const handleViewTxn = (record: AiPointsAccountVO) => {
subView.value = 'txn';
txnFilter.userId = String(record.userId);
txnFilter.bizType = '';
txnFilter.type = '';
txnFilter.date = '';
txnPagination.reset();
nextTick().then(() => fetchTxns());
};
/** 打开调点弹窗(可预设用户信息) */
const openAdjustModal = (user?: { userId: number; phone?: string; nickname?: string }) => {
presetUser.value = user;
adjustModalVisible.value = true;
};
/** 关闭调点弹窗 */
const closeAdjustModal = () => {
adjustModalVisible.value = false;
presetUser.value = undefined;
};
/** 提交调点 */
const handleSubmitAdjust = async (params: AiPointsAdjustParams) => {
adjusting.value = true;
try {
const res = await adjustAiPoints(params);
if (res.code == 200) {
message.success(res.msg || '调点成功');
closeAdjustModal();
// 刷新当前视图数据
if (subView.value === 'account') {
fetchAccounts();
} else {
fetchTxns();
}
} else {
message.error(res.msg || '调点失败');
}
} catch {
message.error('调点失败');
} finally {
adjusting.value = false;
}
};
return {
subView,
accountFilter,
accountLoading,
accountDataSource,
accountPagination,
handleAccountSearch,
handleAccountReset,
handleAccountPageChange,
handleViewTxn,
txnFilter,
txnLoading,
txnDataSource,
txnPagination,
handleTxnSearch,
handleTxnReset,
handleTxnPageChange,
adjustModalVisible,
adjusting,
presetUser,
openAdjustModal,
closeAdjustModal,
handleSubmitAdjust,
};
}
+33
View File
@@ -0,0 +1,33 @@
@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;
}
+2
View File
@@ -7,6 +7,7 @@ import {
AiOutlineMoneyCollect,
AiOutlinePayCircle,
AiOutlinePicture,
AiOutlineRobot,
AiOutlineSafetyCertificate,
AiOutlineSetting,
AiOutlineShoppingCart,
@@ -35,6 +36,7 @@ const ROUTE_ICON_MAP: Record<string, IconType> = {
AiOutlineUser,
AiOutlineSafetyCertificate,
AiOutlineAudit,
AiOutlineRobot,
};
export type RouteIconName = keyof typeof ROUTE_ICON_MAP;