Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e67731a7cd | |||
| 9e0cb3f27a | |||
| ae960df897 | |||
| 955914995c | |||
| 1e9b013065 | |||
| e21c1458ae | |||
| 5f6325689f | |||
| 48ab5d013b | |||
| ce1a5e3f1f | |||
| 8c023e63ec | |||
| a581f64ccb | |||
| 8c317d5b84 | |||
| 91d4410c2a | |||
| 045e491ade | |||
| 64fdaba93d | |||
| 50d5f54015 | |||
| a77592dec6 | |||
| 00867ff960 | |||
| 4c08bd1f8c | |||
| ab6f5c787e | |||
| 79a7ea8c56 | |||
| b5ba0ca3fb | |||
| 9b5e2807b5 | |||
| 3a30e81183 | |||
| 28386f5429 | |||
| b0b8689acb | |||
| 8012cb82fc | |||
| 73b2da2d1e | |||
| 6f8e5036f7 | |||
| daab8ec41a | |||
| b513d701d8 |
+2
-2
@@ -1,6 +1,6 @@
|
||||
# 开发环境配置
|
||||
# 基础API地址
|
||||
VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
|
||||
|
||||
# VITE_API_BASE_URL=http://cpms.op.nps.yunvip123.cn/cpms
|
||||
VITE_API_BASE_URL=http://192.168.2.166:8080/cpms
|
||||
# 是否开启本地调试
|
||||
LOCAL_DEBUG=true
|
||||
|
||||
+4
-1
@@ -1,2 +1,5 @@
|
||||
# 生产环境
|
||||
VITE_API_BASE_URL = http://cpms.op.yunvip123.cn/cpms
|
||||
VITE_API_BASE_URL = https://cpmsapi.yunvip123.com
|
||||
|
||||
# 是否开启本地调试
|
||||
LOCAL_DEBUG=false
|
||||
|
||||
Generated
+1721
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -6,13 +6,15 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"serve": "vite",
|
||||
"serve:prod": "vite --mode production",
|
||||
"build:dev": "vue-tsc --noEmit && vite build --mode development",
|
||||
"build:test": "vue-tsc --noEmit && vite build --mode test",
|
||||
"build:prod": "vue-tsc --noEmit && vite build --mode production",
|
||||
"build:prod": "node --max-old-space-size=4096 ./node_modules/vite/bin/vite.js build --mode production",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"preview:prod": "pnpm build:prod && vite preview",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint": "eslint src --ext .js,.ts,.tsx",
|
||||
"lint:fix": "eslint src --ext .js,.ts,.tsx --fix",
|
||||
@@ -52,7 +54,7 @@
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^19.0.0",
|
||||
"less": "^4.6.4",
|
||||
"lint-staged": "^15.0.0",
|
||||
"prettier": "^3.0.0",
|
||||
|
||||
Generated
+343
-256
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,10 @@ export interface EventListQueryParams {
|
||||
idCardImg?: string;
|
||||
/** 是否要求提供证件号码;0=无需,1=需要 */
|
||||
idCardNo?: string;
|
||||
/** 报名人数起始(含) */
|
||||
scBegin?: string;
|
||||
/** 报名人数结束(含) */
|
||||
scEnd?: string;
|
||||
}
|
||||
|
||||
/** 分页数据 */
|
||||
|
||||
@@ -4,6 +4,32 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
// Tabs 容器:撑满高度
|
||||
.tabs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global {
|
||||
.ant-tabs-content-holder {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面筛选区域:横向 flex,可换行
|
||||
.filter {
|
||||
display: flex;
|
||||
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import './styles.less';
|
||||
import App from './App';
|
||||
import router from './router';
|
||||
import './router/guard'; // 路由守卫:登录鉴权 + 动态菜单加载
|
||||
|
||||
@@ -50,7 +50,7 @@ const getDefaultForm = () => ({
|
||||
type: '1' as '1' | '2' | '3' | '0',
|
||||
linkUrl: '',
|
||||
relatedEventId: undefined as string | undefined,
|
||||
area: '全国',
|
||||
area: '',
|
||||
displayTimeRange: null as [string, string] | null,
|
||||
sort: 1,
|
||||
});
|
||||
@@ -84,18 +84,49 @@ function base64ToFile(dataUrl: string, fileName: string): File {
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
/** 从 Cascader 路径中提取城市名(最后一级),用于区域筛选 */
|
||||
function extractCityName(path: string[]): string {
|
||||
if (!path || path.length === 0 || path[0] === '全国') return '';
|
||||
return path[path.length - 1];
|
||||
/** 在 Cascader options 树中按路径查找末端节点 */
|
||||
function findNodeByPath(options: any[], path: string[]): any | null {
|
||||
let level = options;
|
||||
let node: any = null;
|
||||
for (const seg of path) {
|
||||
node = level.find((o) => o.value === seg);
|
||||
if (!node) return null;
|
||||
level = node.children || [];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 从 Cascader 路径中提取存储用的区域字符串 */
|
||||
function extractAreaValue(path: string[]): string {
|
||||
if (!path || path.length === 0) return '全国';
|
||||
if (path[0] === '全国') return '全国';
|
||||
// 直辖市路径如 ["北京市"],普通城市如 ["广东省", "深圳市"]
|
||||
return path[path.length - 1];
|
||||
/** 递归收集节点下的所有叶子节点 value */
|
||||
function collectLeafValues(node: any, acc: string[]) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach((child: any) => collectLeafValues(child, acc));
|
||||
} else {
|
||||
acc.push(String(node.value));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Cascader 多选选中的路径数组转为叶子节点 value 数组(去重)
|
||||
* 多选时可能选中父节点(省),这里递归展开为最下级的叶子节点(市)
|
||||
* 如 [["广东省"], ["北京市"]] -> ["广州市", "深圳市", ..., "北京市"]
|
||||
*/
|
||||
function extractLeafValues(options: any[], paths: string[][]): string[] {
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const path of paths) {
|
||||
if (!path || path.length === 0) continue;
|
||||
const node = findNodeByPath(options, path);
|
||||
if (!node) continue;
|
||||
const leaves: string[] = [];
|
||||
collectLeafValues(node, leaves);
|
||||
leaves.forEach((v) => {
|
||||
if (!seen.has(v)) {
|
||||
seen.add(v);
|
||||
result.push(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 根据跳转类型计算 parms 字段的值 */
|
||||
@@ -128,8 +159,8 @@ export default defineComponent({
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const formData = reactive(getDefaultForm());
|
||||
|
||||
/** Cascader 区域的路径数组:如 ["全国"]、["广东省", "深圳市"] */
|
||||
const areaPath = ref<string[]>(['全国']);
|
||||
/** Cascader 区域的路径数组(多选):如 [["全国"]]、[["广东省", "深圳市"], ["北京市"]] */
|
||||
const areaPath = ref<string[][]>([]);
|
||||
|
||||
/** 是否由用户主动切换了跳转类型 */
|
||||
const typeChanged = ref(false);
|
||||
@@ -152,22 +183,22 @@ export default defineComponent({
|
||||
return [{ value: '', label: '不关联' }, ...eventOptions.value];
|
||||
});
|
||||
|
||||
// ---- 赛事搜索 API 调用 ----
|
||||
// ---- 赛事列表获取(keyword 可为空:选择跳转类型/所属区域后自动获取) ----
|
||||
const doSearch = async (keyword: string) => {
|
||||
if (!keyword.trim()) {
|
||||
setEventOptions([]);
|
||||
return;
|
||||
}
|
||||
setEventSearchLoading(true);
|
||||
try {
|
||||
const cityName = extractCityName(areaPath.value);
|
||||
// 当前选中的叶子区域名(多选去重后)
|
||||
const leafNames = extractLeafValues(areaCascaderOptions, areaPath.value);
|
||||
const params: Record<string, string> = {
|
||||
page: '1',
|
||||
limit: '999',
|
||||
name: keyword.trim(),
|
||||
};
|
||||
if (cityName) {
|
||||
params.areaName = cityName;
|
||||
if (keyword.trim()) {
|
||||
params.name = keyword.trim();
|
||||
}
|
||||
// 区域筛选:接口 areaName 为单值,仅在恰好选中一个区域时传递
|
||||
if (leafNames.length === 1 && leafNames[0] !== '全国') {
|
||||
params.areaName = leafNames[0];
|
||||
}
|
||||
const res = await getEventList(params as any);
|
||||
if (res.code == 200) {
|
||||
@@ -182,7 +213,7 @@ export default defineComponent({
|
||||
setEventOptions([]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Banner] 搜索赛事失败:', e);
|
||||
console.error('[Banner] 获取赛事列表失败:', e);
|
||||
setEventOptions([]);
|
||||
} finally {
|
||||
setEventSearchLoading(false);
|
||||
@@ -202,26 +233,24 @@ export default defineComponent({
|
||||
}, 350);
|
||||
};
|
||||
|
||||
/** 所属区域 Cascader 变化 */
|
||||
/** 所属区域 Cascader 变化(多选) */
|
||||
const onAreaChange = (val: any) => {
|
||||
const path: string[] = val || [];
|
||||
const newArea = extractAreaValue(path);
|
||||
areaPath.value = path;
|
||||
formData.area = newArea;
|
||||
const paths: string[][] = Array.isArray(val) ? val : [];
|
||||
areaPath.value = paths;
|
||||
|
||||
// 多选后只保留最下级的叶子节点,并用逗号分隔成字符串
|
||||
const leafNames = extractLeafValues(areaCascaderOptions, paths);
|
||||
formData.area = leafNames.join(',');
|
||||
|
||||
// 切换到非"全国"区域时清空已关联赛事
|
||||
if (newArea !== '全国') {
|
||||
if (formData.area !== '全国') {
|
||||
formData.relatedEventId = undefined;
|
||||
formRef.value?.clearValidate('relatedEventId');
|
||||
}
|
||||
|
||||
// 区域变化时重新搜索(如果当前有关键词)
|
||||
if (eventKeyword.value.trim()) {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
doSearch(eventKeyword.value);
|
||||
}, 0);
|
||||
}
|
||||
// 区域变化时自动重新获取赛事列表
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
doSearch(eventKeyword.value);
|
||||
};
|
||||
|
||||
// ---- 根据 record 初始化表单 ----
|
||||
@@ -229,21 +258,30 @@ export default defineComponent({
|
||||
const fresh = getDefaultForm();
|
||||
if (!record) {
|
||||
Object.assign(formData, fresh);
|
||||
areaPath.value = ['全国'];
|
||||
areaPath.value = [];
|
||||
eventKeyword.value = '';
|
||||
setEventOptions([]);
|
||||
// 新增时默认跳转类型为"赛事详情页",自动加载赛事下拉
|
||||
if (formData.type === '1') {
|
||||
doSearch('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 区域:兼容旧格式 "广东省-深圳市"、新格式 "深圳市"、"全国"
|
||||
// 区域:兼容旧格式 "广东省-深圳市"、新格式多选 "深圳市,北京市"、"全国"
|
||||
const rawArea = record.area || record.region || '';
|
||||
let cityName: string;
|
||||
if (rawArea.includes('-')) {
|
||||
cityName = rawArea.split('-').pop() || '全国';
|
||||
} else {
|
||||
cityName = rawArea || '全国';
|
||||
}
|
||||
areaPath.value = cityNameToCascaderPath(cityName);
|
||||
const areaNames = rawArea
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
const leafNames = areaNames.map((name: string) =>
|
||||
name.includes('-') ? name.split('-').pop()! : name,
|
||||
);
|
||||
const paths: string[][] = leafNames.length
|
||||
? leafNames.map((name: string) => cityNameToCascaderPath(name))
|
||||
: [['全国']];
|
||||
areaPath.value = paths;
|
||||
const normalizedArea = leafNames.length ? leafNames.join(',') : '全国';
|
||||
|
||||
// 跳转类型
|
||||
const type = String(record.type || record.jumpType || '1');
|
||||
@@ -285,7 +323,7 @@ export default defineComponent({
|
||||
type,
|
||||
linkUrl: record.params || record.linkUrl || record.jumpUrl || '',
|
||||
relatedEventId,
|
||||
area: cityName,
|
||||
area: normalizedArea,
|
||||
displayTimeRange:
|
||||
(record.showTimeBegin || record.displayStartTime) &&
|
||||
(record.showTimeEnd || record.displayEndTime)
|
||||
@@ -341,6 +379,12 @@ export default defineComponent({
|
||||
if (activeNames.length) {
|
||||
await formRef.value?.validateFields(activeNames).catch(() => {});
|
||||
}
|
||||
|
||||
// 切换到"赛事详情页"时自动加载赛事下拉
|
||||
if (formData.type === '1') {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
doSearch(eventKeyword.value);
|
||||
}
|
||||
};
|
||||
|
||||
syncTypeFields();
|
||||
@@ -614,8 +658,9 @@ export default defineComponent({
|
||||
<Cascader
|
||||
v-model:value={areaPath.value}
|
||||
options={areaCascaderOptions}
|
||||
multiple
|
||||
placeholder="请选择所属区域"
|
||||
allowClear={false}
|
||||
allowClear
|
||||
onChange={onAreaChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.view {
|
||||
flex-shrink: 0;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { defineComponent, ref, nextTick } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -12,10 +12,70 @@ import {
|
||||
Image,
|
||||
} from 'ant-design-vue';
|
||||
import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { useContainerSize, useState, useEffect } from '@/hooks';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import BannerFormModal from './components/BannerFormModal';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import cellStyles from './index.module.less';
|
||||
|
||||
/**
|
||||
* 所属区域单元格:最多 2 行截断,溢出时显示"查看"按钮
|
||||
*/
|
||||
const AreaCell = defineComponent({
|
||||
name: 'AreaCell',
|
||||
props: { text: { type: String, required: true } },
|
||||
setup(props) {
|
||||
const textRef = ref<HTMLElement | null>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const checkOverflow = () => {
|
||||
const el = textRef.value;
|
||||
if (el) setOverflow(el.scrollHeight > el.clientHeight);
|
||||
};
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
useEffect(() => {
|
||||
if (!textRef.value) return;
|
||||
nextTick(checkOverflow);
|
||||
observer = new ResizeObserver(checkOverflow);
|
||||
observer.observe(textRef.value);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
};
|
||||
}, [() => textRef.value]);
|
||||
|
||||
return () => {
|
||||
const raw = props.text;
|
||||
return (
|
||||
<div class={cellStyles.cell}>
|
||||
<div ref={textRef} class={cellStyles.text}>
|
||||
{raw}
|
||||
</div>
|
||||
{overflow.value ? (
|
||||
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
|
||||
查看
|
||||
</span>
|
||||
) : null}
|
||||
<Modal
|
||||
visible={modalOpen.value}
|
||||
title="所属区域"
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
destroyOnClose
|
||||
onCancel={() => setModalOpen(false)}
|
||||
>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.8 }}>
|
||||
{raw.replace(/,/g, ' ')}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* bodyCell 渲染函数
|
||||
@@ -72,6 +132,12 @@ function renderBodyCell({
|
||||
);
|
||||
}
|
||||
|
||||
// 所属区域列
|
||||
if (column.dataIndex === 'area') {
|
||||
const raw = text || '';
|
||||
return raw ? <AreaCell text={raw} /> : <span>--</span>;
|
||||
}
|
||||
|
||||
// 跳转类型列
|
||||
if (column.dataIndex === 'type') {
|
||||
return renderJumpType(text);
|
||||
|
||||
@@ -116,7 +116,7 @@ export function useBannerModel() {
|
||||
key: 'type',
|
||||
width: 120,
|
||||
},
|
||||
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 140 },
|
||||
{ title: '所属区域', dataIndex: 'area', key: 'area', width: 300 },
|
||||
{ title: '展示时间', key: 'displayTime', width: 260 },
|
||||
{ title: '创建时间', dataIndex: 'createDate', key: 'createDate', width: 160 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center' as const },
|
||||
|
||||
@@ -83,13 +83,37 @@
|
||||
}
|
||||
|
||||
.divisionList {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.coverWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
:global(.ant-image) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.divisionItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
padding: 4px 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1677ff;
|
||||
background: #e6f4ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,11 +270,13 @@ export default defineComponent({
|
||||
{/* ===== 封面 ===== */}
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>封面</div>
|
||||
<Image
|
||||
style={{ maxHeight: '380px' }}
|
||||
src={detail.value.img || defaultCover}
|
||||
fallback={defaultCover}
|
||||
/>
|
||||
<div class={styles.coverWrapper}>
|
||||
<Image
|
||||
style={{ maxHeight: '380px' }}
|
||||
src={detail.value.img || defaultCover}
|
||||
fallback={defaultCover}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 赛事公告 ===== */}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.imageGrid {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineComponent } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Table,
|
||||
Form,
|
||||
Space,
|
||||
@@ -89,7 +90,7 @@ export default defineComponent({
|
||||
const renderAction = (record: any) => {
|
||||
const status = record.status;
|
||||
|
||||
if (status === '0' || status === '1') {
|
||||
if (['0', '1', '2'].includes(status)) {
|
||||
return (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openDetailModal(record)}>
|
||||
@@ -266,6 +267,45 @@ export default defineComponent({
|
||||
onUpdate:value={(val: any) => (filterForm.region = val || [])}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="报名人数">
|
||||
<Space.Compact>
|
||||
<InputNumber
|
||||
placeholder="请输入"
|
||||
style={{ width: '140px' }}
|
||||
value={filterForm.scBegin || undefined}
|
||||
precision={0}
|
||||
step={0}
|
||||
min={0}
|
||||
controls={false}
|
||||
onUpdate:value={(val: any) =>
|
||||
(filterForm.scBegin = val != null ? String(val) : '')
|
||||
}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
style={{
|
||||
width: '40px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: '#fafafa',
|
||||
pointerEvents: 'none',
|
||||
color: 'rgba(0,0,0,0.45)',
|
||||
}}
|
||||
value="至"
|
||||
disabled
|
||||
/>
|
||||
<InputNumber
|
||||
placeholder="请输入"
|
||||
style={{ width: '140px' }}
|
||||
value={filterForm.scEnd || undefined}
|
||||
precision={0}
|
||||
step={0}
|
||||
min={0}
|
||||
controls={false}
|
||||
onUpdate:value={(val: any) => (filterForm.scEnd = val != null ? String(val) : '')}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
|
||||
@@ -15,6 +15,8 @@ export const FILTER_DEFAULTS = {
|
||||
isPublic: '',
|
||||
idCardNo: '',
|
||||
idCardImg: '',
|
||||
scBegin: '',
|
||||
scEnd: '',
|
||||
};
|
||||
|
||||
export type FieldMapping = [
|
||||
@@ -35,6 +37,8 @@ export const FIELD_MAPPINGS: FieldMapping[] = [
|
||||
['createDateRange', 'createDateEnd', (v) => v?.[1]],
|
||||
['startDateRange', 'startDate', (v) => v?.[0]],
|
||||
['startDateRange', 'startDateEnd', (v) => v?.[1]],
|
||||
['scBegin', 'scBegin'],
|
||||
['scEnd', 'scEnd'],
|
||||
];
|
||||
|
||||
/** 赛事状态选项(value 对应 API status) */
|
||||
|
||||
@@ -92,11 +92,24 @@ export function useEventListModel() {
|
||||
f.region.length > 0 ||
|
||||
!!f.isPublic ||
|
||||
!!f.idCardNo ||
|
||||
!!f.idCardImg
|
||||
!!f.idCardImg ||
|
||||
!!f.scBegin?.trim() ||
|
||||
!!f.scEnd?.trim()
|
||||
);
|
||||
});
|
||||
|
||||
/** 报名人数区间:起始大于结束时对调 */
|
||||
const normalizeSignupCountRange = () => {
|
||||
const begin = filterForm.scBegin.trim();
|
||||
const end = filterForm.scEnd.trim();
|
||||
if (begin && end && Number(begin) > Number(end)) {
|
||||
filterForm.scBegin = end;
|
||||
filterForm.scEnd = begin;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
normalizeSignupCountRange();
|
||||
pagination.reset();
|
||||
fetchList();
|
||||
};
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import { defineComponent, ref, nextTick } from 'vue';
|
||||
import { DatePicker, Select, Table } from 'ant-design-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useReportsModel } from './model/useReportsModel';
|
||||
import { useEffect, useContainerSize } from '@/hooks';
|
||||
import styles from './index.module.less';
|
||||
|
||||
echarts.use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
/**
|
||||
@@ -180,7 +192,7 @@ export default defineComponent({
|
||||
},
|
||||
{
|
||||
key: 'totalBalance',
|
||||
label: '平台余额(所有用户余额总和)',
|
||||
label: '平台余额(所有用户余额总和 = 总收入 - 提现金额)',
|
||||
value: s.totalBalance,
|
||||
bgColor: '#e6f4ff',
|
||||
accentColor: '#1677ff',
|
||||
@@ -224,7 +236,11 @@ export default defineComponent({
|
||||
<div class={styles.statValue} style={{ color: item.accentColor }}>
|
||||
¥{formatMoney(item.value)}
|
||||
</div>
|
||||
{/* 趋势文字暂时隐藏 */}
|
||||
{/* {item.key === 'totalBalance' && (
|
||||
<div class={styles.statDesc} style={{ color: 'rgba(0, 0, 0, 0.45)' }}>
|
||||
= 总收入 - 提现金额
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,9 @@ export function useReportsModel() {
|
||||
// ===== 图表数据 =====
|
||||
const chartData = computed(() => {
|
||||
const list = data.value?.monthList || [];
|
||||
return list.map((item) => ({
|
||||
// 按月份升序排列,确保图表 X 轴时间顺序正确
|
||||
const sorted = [...list].sort((a, b) => a.month.localeCompare(b.month));
|
||||
return sorted.map((item) => ({
|
||||
month: dayjs(item.month).format('MM月'),
|
||||
order: toNumber(item.orderAmount),
|
||||
refund: toNumber(item.refundAmount),
|
||||
|
||||
@@ -160,7 +160,8 @@ export default defineComponent({
|
||||
placeholder="请输入"
|
||||
style={{ width: '140px' }}
|
||||
value={filterForm.minBalance || undefined}
|
||||
precision={0}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
min={0}
|
||||
controls={false}
|
||||
onUpdate:value={(val: any) =>
|
||||
|
||||
@@ -143,9 +143,14 @@
|
||||
}
|
||||
|
||||
// ===== 上传 =====
|
||||
.uploader {
|
||||
.uploadWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.uploader {
|
||||
:global {
|
||||
.ant-upload-list-picture-card-container {
|
||||
width: 96px;
|
||||
@@ -159,6 +164,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pasteTip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: #e6f7ff;
|
||||
border-radius: 4px;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
|
||||
span {
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.uploadTrigger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineComponent, reactive, ref } from 'vue';
|
||||
import { defineComponent, reactive, ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useEffect } from '@/hooks';
|
||||
import { Modal, Radio, Input, Button, Upload, Image, Spin, message } from 'ant-design-vue';
|
||||
import type { UploadFile } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { PlusOutlined, CopyOutlined } from '@ant-design/icons-vue';
|
||||
import { uploadFile, OssUploadType } from '@/utils/oss';
|
||||
import { getWithdrawInfo, postWithdrawAudit, type WithdrawInfoVO } from '../model/services';
|
||||
import { getAuditStatusText, getPayStatusText } from '../model/useWithdrawModel';
|
||||
@@ -41,6 +41,34 @@ export default defineComponent({
|
||||
const auditForm = reactive({ pass: true, remark: '' });
|
||||
const imageList = ref<UploadFile[]>([]);
|
||||
const submitting = ref(false);
|
||||
const uploadAreaRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// ===== 粘贴图片上传 =====
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (!props.visible || !uploadAreaRef.value) return;
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
await beforeUpload(file as unknown as UploadFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
});
|
||||
|
||||
// ===== 拉取详情 =====
|
||||
const fetchDetail = async (id: number) => {
|
||||
@@ -239,21 +267,27 @@ export default defineComponent({
|
||||
{auditForm.pass && <span class={styles.required}>*</span>}
|
||||
审核图片
|
||||
</span>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={imageList.value}
|
||||
beforeUpload={beforeUpload}
|
||||
onRemove={handleRemove}
|
||||
accept="image/*"
|
||||
class={styles.uploader}
|
||||
>
|
||||
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
|
||||
<div class={styles.uploadTrigger}>
|
||||
<PlusOutlined />
|
||||
<div class={styles.uploadText}>请选择图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<div ref={uploadAreaRef} class={styles.uploadWrapper}>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={imageList.value}
|
||||
beforeUpload={beforeUpload}
|
||||
onRemove={handleRemove}
|
||||
accept="image/*"
|
||||
class={styles.uploader}
|
||||
>
|
||||
{imageList.value.length >= MAX_IMAGE_COUNT ? null : (
|
||||
<div class={styles.uploadTrigger}>
|
||||
<PlusOutlined />
|
||||
<div class={styles.uploadText}>选择或粘贴图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
<div class={styles.pasteTip}>
|
||||
<CopyOutlined />
|
||||
<span>支持 Ctrl+V 粘贴图片</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class={styles.footer}>
|
||||
<Button type="primary" onClick={handleSubmit} loading={submitting.value}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
.form {
|
||||
padding-top: 8px;
|
||||
|
||||
:global(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { defineComponent, reactive } from 'vue';
|
||||
import { Modal, Form, Input, InputNumber, Switch, Button, message } from 'ant-design-vue';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import type { AiAgentConfigVO, AiAgentUpdateParams } from '../model/services';
|
||||
import { MIN_MULTIPLIER, MAX_FREE_POINTS } from '../model/config';
|
||||
import styles from './AiAgentEditModal.module.less';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface AiAgentEditModalProps {
|
||||
visible: boolean;
|
||||
record: AiAgentConfigVO | null;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (params: AiAgentUpdateParams) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_FORM = {
|
||||
enabled: false,
|
||||
billingEnabled: false,
|
||||
billingMultiplier: 1,
|
||||
dailyFreePoints: 0,
|
||||
descr: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 场景配置编辑弹窗
|
||||
*
|
||||
* 字段:业务开关 / 计费开关 / 计费倍率 / 每日免费额度 / 备注
|
||||
* 注意:场景名称和业务类型不可编辑(由代码注册)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'AiAgentEditModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
record: { type: Object, default: null },
|
||||
submitting: { type: Boolean, default: false },
|
||||
onClose: { type: Function, default: null },
|
||||
onSubmit: { type: Function, default: null },
|
||||
},
|
||||
setup(props: AiAgentEditModalProps) {
|
||||
const formState = reactive({ ...DEFAULT_FORM });
|
||||
|
||||
/** 根据 record 初始化表单(与项目其他弹窗保持一致的写法) */
|
||||
const initFormFromRecord = (record: AiAgentConfigVO | null) => {
|
||||
if (record) {
|
||||
Object.assign(formState, {
|
||||
enabled: String(record.enabled) === '1',
|
||||
billingEnabled: String(record.billingEnabled) === '1',
|
||||
billingMultiplier: Number(record.billingMultiplier) || 1,
|
||||
dailyFreePoints: Number(record.dailyFreePoints) || 0,
|
||||
descr: record.descr || '',
|
||||
});
|
||||
} else {
|
||||
Object.assign(formState, DEFAULT_FORM);
|
||||
}
|
||||
};
|
||||
|
||||
// 弹窗打开时回填表单(useEffect 内置 immediate,首次挂载也会执行)
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
initFormFromRecord(props.record);
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 校验
|
||||
if (formState.billingMultiplier < MIN_MULTIPLIER) {
|
||||
message.warning(`计费倍率必须大于 ${MIN_MULTIPLIER}`);
|
||||
return;
|
||||
}
|
||||
if (formState.dailyFreePoints < 0 || formState.dailyFreePoints > MAX_FREE_POINTS) {
|
||||
message.warning(`每日免费额度范围为 0 ~ ${MAX_FREE_POINTS}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造提交参数(对齐文档 POST /admin/manager/ai/agent/update Body 格式)
|
||||
const params: AiAgentUpdateParams = {
|
||||
id: props.record!.id,
|
||||
enabled: formState.enabled ? 1 : 0,
|
||||
billingEnabled: formState.billingEnabled ? 1 : 0,
|
||||
billingMultiplier: formState.billingMultiplier,
|
||||
dailyFreePoints: formState.dailyFreePoints,
|
||||
descr: formState.descr.trim(),
|
||||
};
|
||||
|
||||
props.onSubmit?.(params);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
onCancel={props.onClose}
|
||||
title="编辑场景配置"
|
||||
width={520}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
>
|
||||
<Form layout="vertical" class={styles.form}>
|
||||
{/* 场景信息(只读) */}
|
||||
<Form.Item label="场景名称">
|
||||
<Input value={props.record?.name || ''} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="业务类型">
|
||||
<Input value={props.record?.bizType || ''} disabled />
|
||||
</Form.Item>
|
||||
|
||||
{/* 业务开关 */}
|
||||
<Form.Item label="业务开关">
|
||||
<Switch
|
||||
checked={formState.enabled}
|
||||
onChange={(checked: boolean | string | number) => (formState.enabled = !!checked)}
|
||||
checkedChildren="开启"
|
||||
unCheckedChildren="关闭"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 计费开关 */}
|
||||
<Form.Item label="计费开关">
|
||||
<Switch
|
||||
checked={formState.billingEnabled}
|
||||
onChange={(checked: boolean | string | number) =>
|
||||
(formState.billingEnabled = !!checked)
|
||||
}
|
||||
checkedChildren="计费"
|
||||
unCheckedChildren="免费"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 计费倍率 */}
|
||||
<Form.Item label="计费倍率" required>
|
||||
<InputNumber
|
||||
value={formState.billingMultiplier}
|
||||
onChange={(value: any) =>
|
||||
(formState.billingMultiplier = typeof value === 'number' ? value : 1)
|
||||
}
|
||||
min={MIN_MULTIPLIER}
|
||||
step={0.1}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div class={styles.tip}>倍率必须大于 0,如 1.0 = 原价,1.5 = 1.5 倍计费</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* 每日免费额度 */}
|
||||
<Form.Item label="每日免费额度">
|
||||
<InputNumber
|
||||
value={formState.dailyFreePoints}
|
||||
onChange={(value: any) =>
|
||||
(formState.dailyFreePoints = typeof value === 'number' ? value : 0)
|
||||
}
|
||||
min={0}
|
||||
max={MAX_FREE_POINTS}
|
||||
step={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div class={styles.tip}>每日免费结算条数,0 = 无免费额度</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* 备注 */}
|
||||
<Form.Item label="运营备注">
|
||||
<TextArea
|
||||
value={formState.descr}
|
||||
onUpdate:value={(val: any) => (formState.descr = val?.target?.value ?? val ?? '')}
|
||||
placeholder="请输入备注"
|
||||
rows={3}
|
||||
maxlength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<Form.Item>
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={props.onClose}>取消</Button>
|
||||
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
// 外层容器:撑满 Tab 面板,纵向 flex
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// 内容滚动区
|
||||
.wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// ===== 顶部说明栏 =====
|
||||
.infoBar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #f0f5ff;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #1677ff;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: #1677ff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
// ===== 分组容器 =====
|
||||
.group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 0;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
// 左侧彩色竖线
|
||||
.groupBar {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupName {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupDesc {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #ff4d4f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ===== 表单项表格 =====
|
||||
.itemTable {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 8px 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 表头
|
||||
.itemHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.headLabel {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.headValue {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headAction {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 表行
|
||||
.formItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
min-height: 56px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #fafcff;
|
||||
}
|
||||
}
|
||||
|
||||
// 左侧:label + 标签
|
||||
.itemLeft {
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
font-family: 'SF Mono', 'Courier New', Consolas, monospace;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// 中间:输入控件
|
||||
.itemRight {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.itemRemark {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
line-height: 1.4;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.requiredTip {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
// 右侧:操作
|
||||
.itemAction {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// ===== 底部操作栏(固定占满底部宽度) =====
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
margin-right: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
// ===== 空状态 =====
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
// ===== 模型单价区域 =====
|
||||
.priceSection {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.priceHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.priceBar {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.priceCell {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.priceLoadingRow,
|
||||
.priceEmptyRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
|
||||
.priceTip {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
background: #f6f0ff;
|
||||
border-radius: 0 0 8px 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-top: none;
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import { defineComponent, reactive, ref, computed } from 'vue';
|
||||
import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Form,
|
||||
Button,
|
||||
Spin,
|
||||
Tag,
|
||||
Select,
|
||||
Empty,
|
||||
message,
|
||||
Modal,
|
||||
} from 'ant-design-vue';
|
||||
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { useEffect } from '@/hooks';
|
||||
import {
|
||||
getAiModelDict,
|
||||
saveAiModelDict,
|
||||
resetAiModelDict,
|
||||
getAiBillingPrices,
|
||||
getDictTypes,
|
||||
getDictDataList,
|
||||
saveDictData,
|
||||
deleteDictData,
|
||||
AI_BILLING_PRICE_DICT_TYPE,
|
||||
type AiModelDictVO,
|
||||
type AiBillingPriceVO,
|
||||
type SysDictDataVO,
|
||||
type ApiResult,
|
||||
} from '../model/services';
|
||||
import styles from './AiModelDict.module.less';
|
||||
|
||||
// 用途组中文名
|
||||
const PURPOSE_MAP: Record<string, string> = {
|
||||
chat: '全局默认',
|
||||
steward: '赛事管家',
|
||||
extract: '导入提取',
|
||||
failover: '备用网关',
|
||||
};
|
||||
|
||||
// 用途组竖线颜色
|
||||
const PURPOSE_BAR_COLOR: Record<string, string> = {
|
||||
chat: '#1677ff',
|
||||
steward: '#52c41a',
|
||||
extract: '#fa8c16',
|
||||
failover: '#ff4d4f',
|
||||
};
|
||||
|
||||
// 用途组描述
|
||||
const PURPOSE_DESC: Record<string, string> = {
|
||||
chat: '对话/工具(全局默认),未单独配置的助手均回落此项',
|
||||
steward: '赛事管家模型,以查询问答为主,可配轻量高并发模型',
|
||||
extract: '导入提取+诊断,整组未配置时回落全局默认',
|
||||
failover: '备用网关,未配置 = 故障转移功能整体关闭',
|
||||
};
|
||||
|
||||
/** label 后缀 → 校验类型 */
|
||||
const MODEL_SUFFIX = '.model';
|
||||
const BASEURL_SUFFIX = '.baseUrl';
|
||||
const THINKING_SUFFIX = '.thinking';
|
||||
const APIKEY_SUFFIX = '.apiKey';
|
||||
|
||||
/** 判断是否为 model 类键(必填) */
|
||||
const isModelKey = (label: string) => label === 'model' || label.endsWith(MODEL_SUFFIX);
|
||||
|
||||
/** 判断是否为 baseUrl 类键(须 http(s) 开头) */
|
||||
const isBaseUrlKey = (label: string) => label === 'baseUrl' || label.endsWith(BASEURL_SUFFIX);
|
||||
|
||||
/** 判断是否为 thinking 类键(取值 on/off/default) */
|
||||
const isThinkingKey = (label: string) => label === 'thinking' || label.endsWith(THINKING_SUFFIX);
|
||||
|
||||
/** 判断是否为 apiKey 类键(敏感,留空=保留原值) */
|
||||
const isApiKeyKey = (label: string) => label === 'apiKey' || label.endsWith(APIKEY_SUFFIX);
|
||||
|
||||
/** thinking 合法取值 */
|
||||
const THINKING_OPTIONS = ['on', 'off', 'default'];
|
||||
|
||||
/** 获取 label 的中文用途描述,用于校验提示 */
|
||||
const getLabelDesc = (label: string, remark?: string) => remark || label;
|
||||
|
||||
/** 单价 label 后缀 */
|
||||
const INPUT_SUFFIX = '.input';
|
||||
const OUTPUT_SUFFIX = '.output';
|
||||
|
||||
/** 兜底默认模型名 */
|
||||
const DEFAULT_MODEL = 'default';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AiModelDict',
|
||||
setup() {
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const dictList = ref<AiModelDictVO[]>([]);
|
||||
const formMap = reactive<Record<string, string>>({});
|
||||
|
||||
// ===== 加载数据 =====
|
||||
const fetchDict = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getAiModelDict();
|
||||
if (res.code == 200) {
|
||||
dictList.value = res.data || [];
|
||||
// 初始化表单值
|
||||
dictList.value.forEach((item) => {
|
||||
formMap[item.label] = item.value || '';
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 网络层已提示
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDict();
|
||||
}, []);
|
||||
|
||||
const groupedData = computed(() => {
|
||||
const groups: Record<string, AiModelDictVO[]> = {};
|
||||
dictList.value.forEach((item) => {
|
||||
const key = item.purpose || 'chat';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(item);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
// purpose 排序
|
||||
const purposeOrder = ['chat', 'steward', 'extract', 'failover'];
|
||||
|
||||
// 是否有数据
|
||||
const hasData = computed(() => dictList.value.length > 0);
|
||||
|
||||
const validateForm = (): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
dictList.value.forEach((item) => {
|
||||
const label = item.label;
|
||||
const val = (formMap[label] ?? '').trim();
|
||||
const desc = getLabelDesc(label, item.remark);
|
||||
|
||||
// 1. *model 必填
|
||||
if (isModelKey(label) && !val) {
|
||||
errors.push(`${desc}不能为空(模型标识必填;如需回落配置文件请用重置)`);
|
||||
}
|
||||
|
||||
// 2. *baseUrl 须 http(s) 开头(填了才校验)
|
||||
if (isBaseUrlKey(label) && val && !/^https?:\/\//i.test(val)) {
|
||||
errors.push(`${desc}须以 http:// 或 https:// 开头`);
|
||||
}
|
||||
|
||||
// 3. *thinking 取值 on/off/default(填了才校验)
|
||||
if (isThinkingKey(label) && val && !THINKING_OPTIONS.includes(val)) {
|
||||
errors.push(`${desc}取值只能为 on / off / default`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// 前端校验
|
||||
const errors = validateForm();
|
||||
if (errors.length > 0) {
|
||||
// 逐条提示,重点展示第一个错误
|
||||
message.warning(errors[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
// 构造提交参数:apiKey 类含掩码 **** 的传空=保留原值,其余原样提交
|
||||
const items = dictList.value.map((item) => {
|
||||
const val = formMap[item.label] ?? '';
|
||||
// 敏感键:如果值包含掩码 ****,说明未修改,传空让后端保留原值
|
||||
if (isApiKeyKey(item.label) && val.includes('****')) {
|
||||
return { label: item.label, value: '' };
|
||||
}
|
||||
return { label: item.label, value: val };
|
||||
});
|
||||
|
||||
const res = await saveAiModelDict({ items });
|
||||
if (res && res.code == 200) {
|
||||
message.success('已保存(最长 30 秒生效)');
|
||||
fetchDict();
|
||||
} else {
|
||||
message.error(res?.msg || '保存失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AiModelDict] saveAiModelDict error:', err);
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = (label: string, remark: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认重置',
|
||||
content: `确认重置"${remark || label}"吗?重置后该键将回落后一级配置。`,
|
||||
okText: '确认重置',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await resetAiModelDict({ labels: [label] });
|
||||
if (res.code == 200) {
|
||||
message.success(res.msg || '已重置(最长 30 秒生效)');
|
||||
fetchDict();
|
||||
} else {
|
||||
message.error(res.msg || '重置失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('重置失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const priceLoading = ref(false);
|
||||
const priceSaving = ref(false);
|
||||
/** 生效价列表(来自 billing/prices,仅展示用) */
|
||||
const priceList = ref<AiBillingPriceVO[]>([]);
|
||||
/** 字典数据行列表(来自 dict/data/list,含 id,用于保存/删除) */
|
||||
const priceDictRows = ref<SysDictDataVO[]>([]);
|
||||
/** ai_billing_price 的 dictTypeId */
|
||||
const priceDictTypeId = ref<number>(0);
|
||||
/** 编辑态:key = model 名,value = { inputPrice, outputPrice } */
|
||||
const priceFormMap = reactive<Record<string, { inputPrice: string; outputPrice: string }>>({});
|
||||
/** 新增模型弹窗 */
|
||||
const addModalVisible = ref(false);
|
||||
const newModelName = ref('');
|
||||
|
||||
/** 从 dictLabel 提取 model 名(去掉 .input / .output 后缀) */
|
||||
const extractModelName = (dictLabel: string): string => {
|
||||
if (dictLabel.endsWith(INPUT_SUFFIX)) return dictLabel.slice(0, -INPUT_SUFFIX.length);
|
||||
if (dictLabel.endsWith(OUTPUT_SUFFIX)) return dictLabel.slice(0, -OUTPUT_SUFFIX.length);
|
||||
return dictLabel;
|
||||
};
|
||||
|
||||
/** 加载单价列表:先查 types 拿 dictTypeId,再查 data/list 拿完整行,最后查 billing/prices 拿生效价 */
|
||||
const fetchPrices = async () => {
|
||||
priceLoading.value = true;
|
||||
try {
|
||||
// 1. 查字典类型,找到 ai_billing_price 的 dictTypeId
|
||||
const typesRes = await getDictTypes();
|
||||
if (typesRes.code != 200 || !typesRes.data) {
|
||||
priceLoading.value = false;
|
||||
return;
|
||||
}
|
||||
const dictType = typesRes.data.find((t) => t.dictType === AI_BILLING_PRICE_DICT_TYPE);
|
||||
if (!dictType) {
|
||||
message.warning(`未找到字典类型 ${AI_BILLING_PRICE_DICT_TYPE},请先在通用字典页创建`);
|
||||
priceLoading.value = false;
|
||||
return;
|
||||
}
|
||||
priceDictTypeId.value = dictType.id;
|
||||
|
||||
// 2. 查该类型下全部数据行(含 id)
|
||||
const dataRes = await getDictDataList(dictType.id);
|
||||
if (dataRes.code == 200 && dataRes.data) {
|
||||
priceDictRows.value = dataRes.data;
|
||||
}
|
||||
|
||||
// 3. 查生效价(展示用)
|
||||
const priceRes = await getAiBillingPrices();
|
||||
if (priceRes.code == 200 && priceRes.data) {
|
||||
priceList.value = priceRes.data;
|
||||
}
|
||||
|
||||
// 4. 构建编辑表单:以字典数据行为主,提取 model 名和单价
|
||||
const modelSet = new Set<string>();
|
||||
priceDictRows.value.forEach((row) => {
|
||||
const modelName = extractModelName(row.dictLabel);
|
||||
modelSet.add(modelName);
|
||||
});
|
||||
// 也把生效价中出现但字典没有的模型纳入(漏配模型)
|
||||
priceList.value.forEach((item) => modelSet.add(item.model));
|
||||
|
||||
// 清空旧表单数据
|
||||
Object.keys(priceFormMap).forEach((key) => delete priceFormMap[key]);
|
||||
|
||||
// 构建表单:优先用生效价,其次用字典行
|
||||
modelSet.forEach((modelName) => {
|
||||
const inputRow = priceDictRows.value.find(
|
||||
(r) => r.dictLabel === `${modelName}${INPUT_SUFFIX}`,
|
||||
);
|
||||
const outputRow = priceDictRows.value.find(
|
||||
(r) => r.dictLabel === `${modelName}${OUTPUT_SUFFIX}`,
|
||||
);
|
||||
const priceItem = priceList.value.find((p) => p.model === modelName);
|
||||
priceFormMap[modelName] = {
|
||||
inputPrice: inputRow
|
||||
? inputRow.dictValue
|
||||
: priceItem
|
||||
? String(priceItem.inputPrice ?? '')
|
||||
: '',
|
||||
outputPrice: outputRow
|
||||
? outputRow.dictValue
|
||||
: priceItem
|
||||
? String(priceItem.outputPrice ?? '')
|
||||
: '',
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// 网络层已提示
|
||||
} finally {
|
||||
priceLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrices();
|
||||
}, []);
|
||||
|
||||
/** 排序后的模型列表(default 排最前) */
|
||||
const sortedModels = computed(() => {
|
||||
const models = Object.keys(priceFormMap);
|
||||
return models.sort((a, b) => {
|
||||
if (a === DEFAULT_MODEL) return -1;
|
||||
if (b === DEFAULT_MODEL) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
});
|
||||
|
||||
/** 保存某模型的输入单价 */
|
||||
const handleSaveInputPrice = async (model: string) => {
|
||||
const val = priceFormMap[model]?.inputPrice ?? '';
|
||||
const num = Number(val);
|
||||
if (val === '' || Number.isNaN(num) || num < 0) {
|
||||
message.warning('请输入有效的单价(≥0)');
|
||||
return;
|
||||
}
|
||||
priceSaving.value = true;
|
||||
try {
|
||||
const existingRow = priceDictRows.value.find(
|
||||
(r) => r.dictLabel === `${model}${INPUT_SUFFIX}`,
|
||||
);
|
||||
const res = await saveDictData({
|
||||
id: existingRow?.id,
|
||||
dictTypeId: priceDictTypeId.value,
|
||||
dictLabel: `${model}${INPUT_SUFFIX}`,
|
||||
dictValue: val,
|
||||
sort: existingRow?.sort ?? 0,
|
||||
remark: existingRow?.remark ?? '',
|
||||
});
|
||||
if (res.code == 200) {
|
||||
message.success(`${model} 输入单价已保存(最长 30 秒生效)`);
|
||||
fetchPrices();
|
||||
} else {
|
||||
message.error(res.msg || '保存失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
priceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存某模型的输出单价 */
|
||||
const handleSaveOutputPrice = async (model: string) => {
|
||||
const val = priceFormMap[model]?.outputPrice ?? '';
|
||||
const num = Number(val);
|
||||
if (val === '' || Number.isNaN(num) || num < 0) {
|
||||
message.warning('请输入有效的单价(≥0)');
|
||||
return;
|
||||
}
|
||||
priceSaving.value = true;
|
||||
try {
|
||||
const existingRow = priceDictRows.value.find(
|
||||
(r) => r.dictLabel === `${model}${OUTPUT_SUFFIX}`,
|
||||
);
|
||||
const res = await saveDictData({
|
||||
id: existingRow?.id,
|
||||
dictTypeId: priceDictTypeId.value,
|
||||
dictLabel: `${model}${OUTPUT_SUFFIX}`,
|
||||
dictValue: val,
|
||||
sort: existingRow?.sort ?? 0,
|
||||
remark: existingRow?.remark ?? '',
|
||||
});
|
||||
if (res.code == 200) {
|
||||
message.success(`${model} 输出单价已保存(最长 30 秒生效)`);
|
||||
fetchPrices();
|
||||
} else {
|
||||
message.error(res.msg || '保存失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
priceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除某模型的单条单价(按数据行 id 删除) */
|
||||
const handleDeletePrice = (model: string, type: 'input' | 'output') => {
|
||||
const suffix = type === 'input' ? INPUT_SUFFIX : OUTPUT_SUFFIX;
|
||||
const row = priceDictRows.value.find((r) => r.dictLabel === `${model}${suffix}`);
|
||||
if (!row) {
|
||||
message.warning(`未找到 ${model}${suffix} 的字典数据行,可能尚未配置`);
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '确认重置',
|
||||
content: `确认重置 ${model} 的${type === 'input' ? '输入' : '输出'}单价吗?重置后回落后一级配置。`,
|
||||
okText: '确认重置',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await deleteDictData(row.id);
|
||||
if (res.code == 200) {
|
||||
message.success(res.msg || '已重置(最长 30 秒生效)');
|
||||
fetchPrices();
|
||||
} else {
|
||||
message.error(res.msg || '重置失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('重置失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** 打开新增模型弹窗 */
|
||||
const openAddModal = () => {
|
||||
newModelName.value = '';
|
||||
addModalVisible.value = true;
|
||||
};
|
||||
|
||||
/** 确认新增模型单价 */
|
||||
const handleAddModel = async () => {
|
||||
const name = newModelName.value.trim();
|
||||
if (!name) {
|
||||
message.warning('请输入模型名称');
|
||||
return;
|
||||
}
|
||||
// 检查是否已存在
|
||||
if (Object.keys(priceFormMap).includes(name)) {
|
||||
message.warning(`模型 ${name} 已存在`);
|
||||
return;
|
||||
}
|
||||
if (!priceDictTypeId.value) {
|
||||
message.error('字典类型未就绪,请刷新页面重试');
|
||||
return;
|
||||
}
|
||||
// 初始化输入/输出单价为 0(通过字典 data/save 写入)
|
||||
priceSaving.value = true;
|
||||
try {
|
||||
const [inputRes, outputRes] = await Promise.all([
|
||||
saveDictData({
|
||||
dictTypeId: priceDictTypeId.value,
|
||||
dictLabel: `${name}${INPUT_SUFFIX}`,
|
||||
dictValue: '0',
|
||||
}),
|
||||
saveDictData({
|
||||
dictTypeId: priceDictTypeId.value,
|
||||
dictLabel: `${name}${OUTPUT_SUFFIX}`,
|
||||
dictValue: '0',
|
||||
}),
|
||||
]);
|
||||
if (inputRes.code == 200 && outputRes.code == 200) {
|
||||
message.success(`已新增模型 ${name} 单价(最长 30 秒生效)`);
|
||||
addModalVisible.value = false;
|
||||
fetchPrices();
|
||||
} else {
|
||||
message.error(inputRes.msg || outputRes.msg || '新增失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('新增失败');
|
||||
} finally {
|
||||
priceSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
const groups = groupedData.value;
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.wrapper}>
|
||||
{loading.value ? (
|
||||
<div class={styles.emptyState}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : !hasData.value ? (
|
||||
<div class={styles.emptyState}>
|
||||
<Empty description="暂无字典数据" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class={styles.infoBar}>
|
||||
<InfoCircleOutlined class={styles.infoIcon} />
|
||||
<span>
|
||||
模型/网关/Key 运行时热切换配置,共 16 个白名单键。敏感键留空 = 保留原值;
|
||||
<span style={{ color: '#ff4d4f' }}>model 类必填</span>;保存后最长 30 秒生效。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{purposeOrder.map((purpose) => {
|
||||
const items = groups[purpose];
|
||||
if (!items || items.length === 0) return null;
|
||||
const barColor = PURPOSE_BAR_COLOR[purpose] || '#1677ff';
|
||||
|
||||
return (
|
||||
<div key={purpose} class={styles.group}>
|
||||
{/* 分组头部 */}
|
||||
<div class={styles.groupHeader}>
|
||||
<span class={styles.groupBar} style={{ backgroundColor: barColor }} />
|
||||
<span class={styles.groupName}>{PURPOSE_MAP[purpose] || purpose}</span>
|
||||
<span class={styles.groupDesc}>{PURPOSE_DESC[purpose] || ''}</span>
|
||||
{purpose === 'failover' && (
|
||||
<span class={styles.hint}>未配置 = 整体关闭</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表格容器 */}
|
||||
<div class={styles.itemTable}>
|
||||
{/* 表头 */}
|
||||
<div class={styles.itemHead}>
|
||||
<div class={styles.headLabel}>配置键</div>
|
||||
<div class={styles.headValue}>配置值</div>
|
||||
<div class={styles.headAction}>操作</div>
|
||||
</div>
|
||||
|
||||
{/* 表行 */}
|
||||
{items.map((item) => {
|
||||
const isModel = isModelKey(item.label);
|
||||
const isThinking = isThinkingKey(item.label);
|
||||
const originalVal = item.value || '';
|
||||
const currentVal = formMap[item.label] ?? '';
|
||||
const isModified =
|
||||
currentVal !== originalVal &&
|
||||
!(isApiKeyKey(item.label) && currentVal.includes('****'));
|
||||
|
||||
return (
|
||||
<div key={item.label} class={styles.formItem}>
|
||||
{/* 左侧:label + 标签 */}
|
||||
<div class={styles.itemLeft}>
|
||||
{isModel && <span class={styles.required}>*</span>}
|
||||
<span class={styles.itemLabel} title={item.label}>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.sensitive && <Tag color="red">敏感</Tag>}
|
||||
{isModified && <Tag color="blue">已改</Tag>}
|
||||
</div>
|
||||
|
||||
{/* 中间:输入控件 */}
|
||||
<div class={styles.itemRight}>
|
||||
{item.sensitive ? (
|
||||
<Input.Password
|
||||
value={formMap[item.label]}
|
||||
onUpdate:value={(val: string) =>
|
||||
(formMap[item.label] = val ?? '')
|
||||
}
|
||||
placeholder="留空 = 保留原值"
|
||||
visibilityToggle={true}
|
||||
class={styles.input}
|
||||
allowClear
|
||||
/>
|
||||
) : isThinking ? (
|
||||
<Select
|
||||
value={formMap[item.label] || undefined}
|
||||
onUpdate:value={(val: any) => (formMap[item.label] = val ?? '')}
|
||||
placeholder="未配置(跟随全局默认)"
|
||||
class={styles.input}
|
||||
allowClear
|
||||
options={THINKING_OPTIONS.map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={formMap[item.label]}
|
||||
onUpdate:value={(val: string) =>
|
||||
(formMap[item.label] = val ?? '')
|
||||
}
|
||||
placeholder={isModel ? '必填,如 deepseek-flash' : '未配置'}
|
||||
class={styles.input}
|
||||
allowClear
|
||||
/>
|
||||
)}
|
||||
<div class={styles.itemRemark}>
|
||||
{item.remark}
|
||||
{isModel && (
|
||||
<span class={styles.requiredTip}> (必填,回落请用重置)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:重置 */}
|
||||
<div class={styles.itemAction}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleReset(item.label, item.remark)}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div class={styles.priceSection}>
|
||||
<div class={styles.priceHeader}>
|
||||
<span class={styles.priceBar} style={{ backgroundColor: '#722ed1' }} />
|
||||
<span class={styles.groupName}>模型计费单价</span>
|
||||
<span class={styles.groupDesc}>
|
||||
单价 = 点/千 token;字典类型 ai_billing_price,label 契约:{'{model}.input'} /{' '}
|
||||
{'{model}.output'}
|
||||
</span>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={openAddModal}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
新增模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class={styles.itemTable}>
|
||||
{/* 表头 */}
|
||||
<div class={styles.itemHead}>
|
||||
<div class={styles.headLabel}>模型</div>
|
||||
<div class={styles.headValue}>输入单价(点/千token)</div>
|
||||
<div class={styles.headValue}>输出单价(点/千token)</div>
|
||||
<div class={styles.headAction}>操作</div>
|
||||
</div>
|
||||
|
||||
{/* 数据行 */}
|
||||
{priceLoading.value ? (
|
||||
<div class={styles.priceLoadingRow}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : sortedModels.value.length === 0 ? (
|
||||
<div class={styles.priceEmptyRow}>
|
||||
<Empty description="暂无单价数据" imageStyle={{ height: '48px' }} />
|
||||
</div>
|
||||
) : (
|
||||
sortedModels.value.map((modelName) => {
|
||||
const isDefault = modelName === DEFAULT_MODEL;
|
||||
return (
|
||||
<div key={modelName} class={styles.formItem}>
|
||||
{/* 模型名 */}
|
||||
<div class={styles.itemLeft}>
|
||||
<span class={styles.itemLabel} title={modelName}>
|
||||
{modelName}
|
||||
</span>
|
||||
{isDefault && <Tag color="purple">兜底</Tag>}
|
||||
</div>
|
||||
|
||||
{/* 输入单价 */}
|
||||
<div class={styles.priceCell}>
|
||||
<InputNumber
|
||||
value={priceFormMap[modelName]?.inputPrice}
|
||||
onChange={(val: any) => {
|
||||
if (!priceFormMap[modelName]) {
|
||||
priceFormMap[modelName] = { inputPrice: '', outputPrice: '' };
|
||||
}
|
||||
priceFormMap[modelName].inputPrice =
|
||||
typeof val === 'number' ? String(val) : '';
|
||||
}}
|
||||
min={0}
|
||||
step={0.1}
|
||||
precision={4}
|
||||
placeholder="0"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleSaveInputPrice(modelName)}
|
||||
loading={priceSaving.value}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDeletePrice(modelName, 'input')}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 输出单价 */}
|
||||
<div class={styles.priceCell}>
|
||||
<InputNumber
|
||||
value={priceFormMap[modelName]?.outputPrice}
|
||||
onChange={(val: any) => {
|
||||
if (!priceFormMap[modelName]) {
|
||||
priceFormMap[modelName] = { inputPrice: '', outputPrice: '' };
|
||||
}
|
||||
priceFormMap[modelName].outputPrice =
|
||||
typeof val === 'number' ? String(val) : '';
|
||||
}}
|
||||
min={0}
|
||||
step={0.1}
|
||||
precision={4}
|
||||
placeholder="0"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleSaveOutputPrice(modelName)}
|
||||
loading={priceSaving.value}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDeletePrice(modelName, 'output')}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 操作列占位 */}
|
||||
<div class={styles.itemAction} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={styles.priceTip}>
|
||||
生效价回落链:字典 → 配置文件 → 内置默认(输入 0.8 / 输出 2.0);改字典最长 30s 生效
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
visible={addModalVisible.value}
|
||||
onCancel={() => (addModalVisible.value = false)}
|
||||
title="新增模型单价"
|
||||
centered
|
||||
destroyOnClose
|
||||
width={420}
|
||||
footer={null}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="模型名称" required>
|
||||
<Input
|
||||
value={newModelName.value}
|
||||
onUpdate:value={(val: string) => (newModelName.value = val ?? '')}
|
||||
placeholder="如 deepseek-flash"
|
||||
onPressEnter={handleAddModel}
|
||||
/>
|
||||
<div class={styles.tip}>
|
||||
新增后将初始化输入/输出单价为 0,可稍后编辑。default 模型为兜底默认价。
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={() => (addModalVisible.value = false)}>取消</Button>
|
||||
<Button type="primary" loading={priceSaving.value} onClick={handleAddModel}>
|
||||
确认新增
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{hasData.value && !loading.value && (
|
||||
<div class={styles.footer}>
|
||||
<span class={styles.tip}>保存后最长 30 秒生效,无需重启</span>
|
||||
<Button type="primary" size="middle" onClick={handleSave} loading={saving.value}>
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// ===== 高频标记 =====
|
||||
.highFreqTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.normalTag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f0f0f0;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { defineComponent, reactive, ref, computed } from 'vue';
|
||||
import { Button, Table, Space, DatePicker, InputNumber, Radio, Pagination } from 'ant-design-vue';
|
||||
import { type Dayjs } from 'dayjs';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import {
|
||||
getAiBillingReport,
|
||||
getAiFreeformReport,
|
||||
getAiAgentList,
|
||||
type AiBillingReportVO,
|
||||
type AiFreeformReportVO,
|
||||
type AiAgentConfigVO,
|
||||
} from '../model/services';
|
||||
import styles from './BillingReportTab.module.less';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
|
||||
// ===== 工具函数 =====
|
||||
|
||||
/** 格式化数字(千分位),兼容后端返回字符串类型 */
|
||||
const formatNum = (val: unknown): string => {
|
||||
const n = Number(val);
|
||||
if (val == null || Number.isNaN(n)) return '-';
|
||||
return n.toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
/** 安全的文本渲染:null/undefined/空 → '-' */
|
||||
const renderText = (text: unknown): string => (text != null && text !== '' ? String(text) : '-');
|
||||
|
||||
/** 等宽字体渲染(用于指纹、签名等哈希值) */
|
||||
const renderMono = (text: string) => (
|
||||
<span style={{ fontFamily: "'SF Mono', 'Courier New', Consolas, monospace", fontSize: '12px' }}>
|
||||
{renderText(text)}
|
||||
</span>
|
||||
);
|
||||
|
||||
// ===== 场景名称映射 =====
|
||||
|
||||
const useBizTypeMap = () => {
|
||||
const map = ref<Record<string, string>>({});
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await getAiAgentList();
|
||||
if (res.code == 200 && res.data) {
|
||||
const m: Record<string, string> = {};
|
||||
res.data.forEach((item: AiAgentConfigVO) => {
|
||||
m[item.bizType] = item.name;
|
||||
});
|
||||
map.value = m;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
return map;
|
||||
};
|
||||
|
||||
// ===== 组件 =====
|
||||
|
||||
export default defineComponent({
|
||||
name: 'BillingReportTab',
|
||||
setup() {
|
||||
type SubView = 'billing' | 'freeform';
|
||||
const subView = ref<SubView>('billing');
|
||||
const bizTypeMap = useBizTypeMap();
|
||||
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
|
||||
|
||||
// ===== 计费报表(前端分页) =====
|
||||
const billingLoading = ref(false);
|
||||
const billingData = ref<AiBillingReportVO[]>([]);
|
||||
const billingFilter = reactive({
|
||||
startDate: null as Dayjs | null,
|
||||
endDate: null as Dayjs | null,
|
||||
});
|
||||
const billingPage = reactive({ current: 1, pageSize: 20 });
|
||||
const billingPagedData = computed(() => {
|
||||
const start = (billingPage.current - 1) * billingPage.pageSize;
|
||||
return billingData.value.slice(start, start + billingPage.pageSize);
|
||||
});
|
||||
|
||||
const fetchBilling = async () => {
|
||||
billingLoading.value = true;
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (billingFilter.startDate)
|
||||
params.startDate = billingFilter.startDate.format('YYYY-MM-DD');
|
||||
if (billingFilter.endDate) params.endDate = billingFilter.endDate.format('YYYY-MM-DD');
|
||||
const res = await getAiBillingReport(params);
|
||||
billingData.value = res.code == 200 && res.data ? res.data : [];
|
||||
} catch {
|
||||
billingData.value = [];
|
||||
} finally {
|
||||
billingLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件初始化时立即加载
|
||||
fetchBilling();
|
||||
|
||||
const onBillingReset = () => {
|
||||
billingFilter.startDate = null;
|
||||
billingFilter.endDate = null;
|
||||
billingPage.current = 1;
|
||||
fetchBilling();
|
||||
};
|
||||
|
||||
const onBillingPageChange = (page: number, pageSize: number) => {
|
||||
billingPage.current = page;
|
||||
billingPage.pageSize = pageSize;
|
||||
};
|
||||
|
||||
const billingColumns = [
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
width: 130,
|
||||
align: 'center' as const,
|
||||
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
|
||||
(a.date || '').localeCompare(b.date || ''),
|
||||
defaultSortOrder: 'descend' as const,
|
||||
},
|
||||
{
|
||||
title: '业务场景',
|
||||
dataIndex: 'bizType',
|
||||
key: 'bizType',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }) => bizTypeMap.value[text] || text || '-',
|
||||
},
|
||||
{
|
||||
title: '消息数',
|
||||
dataIndex: 'messageCount',
|
||||
key: 'messageCount',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
|
||||
Number(a.messageCount) - Number(b.messageCount),
|
||||
customRender: ({ text }: { text: number }) => formatNum(text),
|
||||
},
|
||||
{
|
||||
title: '总 Token',
|
||||
dataIndex: 'totalTokens',
|
||||
key: 'totalTokens',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
|
||||
Number(a.totalTokens) - Number(b.totalTokens),
|
||||
customRender: ({ text }: { text: number }) => formatNum(text),
|
||||
},
|
||||
{
|
||||
title: '消耗积分',
|
||||
dataIndex: 'totalPoints',
|
||||
key: 'totalPoints',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
sorter: (a: AiBillingReportVO, b: AiBillingReportVO) =>
|
||||
Number(a.totalPoints) - Number(b.totalPoints),
|
||||
customRender: ({ text }: { text: number }) => (
|
||||
<span style={{ color: '#ff4d4f', fontWeight: 600 }}>{formatNum(text)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 直解报表 =====
|
||||
const freeformLoading = ref(false);
|
||||
const freeformData = ref<AiFreeformReportVO[]>([]);
|
||||
const freeformTop = ref(50);
|
||||
const freeformLoaded = ref(false);
|
||||
|
||||
const fetchFreeform = async () => {
|
||||
freeformLoading.value = true;
|
||||
try {
|
||||
const res = await getAiFreeformReport({ top: freeformTop.value });
|
||||
freeformData.value = res.code == 200 && res.data ? res.data : [];
|
||||
} catch {
|
||||
freeformData.value = [];
|
||||
} finally {
|
||||
freeformLoading.value = false;
|
||||
freeformLoaded.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到直解格式统计时自动加载(首次)
|
||||
useEffect(() => {
|
||||
if (subView.value === 'freeform' && !freeformLoaded.value) {
|
||||
fetchFreeform();
|
||||
}
|
||||
}, [subView]);
|
||||
|
||||
const onFreeformReset = () => {
|
||||
freeformTop.value = 50;
|
||||
fetchFreeform();
|
||||
};
|
||||
|
||||
const freeformColumns = [
|
||||
{
|
||||
title: '文档指纹',
|
||||
dataIndex: 'fingerprint',
|
||||
key: 'fingerprint',
|
||||
width: 200,
|
||||
align: 'left' as const,
|
||||
ellipsis: true,
|
||||
customRender: ({ text }: { text: string }) => renderMono(text),
|
||||
},
|
||||
{
|
||||
title: '文档性质',
|
||||
dataIndex: 'docNature',
|
||||
key: 'docNature',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }) => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '信号集',
|
||||
dataIndex: 'signalSet',
|
||||
key: 'signalSet',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }) => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '表头签名',
|
||||
dataIndex: 'headerSignature',
|
||||
key: 'headerSignature',
|
||||
width: 180,
|
||||
align: 'left' as const,
|
||||
ellipsis: true,
|
||||
customRender: ({ text }: { text: string }) => renderMono(text),
|
||||
},
|
||||
{
|
||||
title: '使用次数',
|
||||
dataIndex: 'useCount',
|
||||
key: 'useCount',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) =>
|
||||
Number(a.useCount) - Number(b.useCount),
|
||||
defaultSortOrder: 'descend' as const,
|
||||
customRender: ({ text }: { text: number }) => formatNum(text),
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
dataIndex: 'lastUsedAt',
|
||||
key: 'lastUsedAt',
|
||||
width: 180,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '频次',
|
||||
dataIndex: 'highFrequency',
|
||||
key: 'highFrequency',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
sorter: (a: AiFreeformReportVO, b: AiFreeformReportVO) =>
|
||||
Number(a.highFrequency) - Number(b.highFrequency),
|
||||
customRender: ({ text }: { text: boolean }) =>
|
||||
text ? (
|
||||
<span class={styles.highFreqTag}>高频</span>
|
||||
) : (
|
||||
<span class={styles.normalTag}>普通</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return () => (
|
||||
<>
|
||||
{/* 筛选栏 */}
|
||||
<div class={pageStyles.filter}>
|
||||
<Radio.Group
|
||||
value={subView.value}
|
||||
onUpdate:value={(val: SubView) => (subView.value = val)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="billing">计费报表</Radio.Button>
|
||||
<Radio.Button value="freeform">直解格式统计</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{subView.value === 'billing' ? (
|
||||
<Space>
|
||||
<DatePicker
|
||||
value={billingFilter.startDate ?? undefined}
|
||||
onUpdate:value={(val: any) => (billingFilter.startDate = val ?? null)}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="开始日期"
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker
|
||||
value={billingFilter.endDate ?? undefined}
|
||||
onUpdate:value={(val: any) => (billingFilter.endDate = val ?? null)}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="结束日期"
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={fetchBilling} loading={billingLoading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={onBillingReset}>重置</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.65)' }}>返回行数:</span>
|
||||
<InputNumber
|
||||
value={freeformTop.value}
|
||||
onUpdate:value={(val: any) =>
|
||||
(freeformTop.value = typeof val === 'number' ? val : 50)
|
||||
}
|
||||
min={1}
|
||||
max={500}
|
||||
step={10}
|
||||
precision={0}
|
||||
placeholder="50"
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<Button type="primary" onClick={fetchFreeform} loading={freeformLoading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={onFreeformReset}>重置</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表格区 */}
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
{subView.value === 'billing' ? (
|
||||
<Table
|
||||
columns={billingColumns as any}
|
||||
dataSource={billingPagedData.value}
|
||||
loading={billingLoading.value}
|
||||
rowKey={(record: AiBillingReportVO) => `${record.date}_${record.bizType}`}
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={freeformColumns as any}
|
||||
dataSource={freeformData.value}
|
||||
loading={freeformLoading.value}
|
||||
rowKey="fingerprint"
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{subView.value === 'billing' && (
|
||||
<div class={pageStyles.pagination}>
|
||||
<Pagination
|
||||
current={billingPage.current}
|
||||
pageSize={billingPage.pageSize}
|
||||
total={billingData.value.length}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
showTotal={(total: number) => `共 ${total} 条`}
|
||||
pageSizeOptions={['10', '20', '50', '100']}
|
||||
onChange={onBillingPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
.form {
|
||||
padding-top: 8px;
|
||||
|
||||
:global(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { defineComponent, reactive, watch } from 'vue';
|
||||
import { Modal, Form, Input, Select, DatePicker, Button, message } from 'ant-design-vue';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import { useUserSearch } from '../hooks/useUserSearch';
|
||||
import type { AiBlacklistAddParams } from '../model/services';
|
||||
import styles from './BlacklistAddModal.module.less';
|
||||
|
||||
interface BlacklistAddModalProps {
|
||||
visible: boolean;
|
||||
submitting: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (params: AiBlacklistAddParams) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉黑弹窗
|
||||
*
|
||||
* 字段:用户ID / 拉黑原因 / 到期时间(可选,空=永久)
|
||||
*
|
||||
* 注意(文档 §1.3 ⑫):
|
||||
* - userId 必填,建议用用户选择器(当前简化为输入)
|
||||
* - reason 必填(审计要求)
|
||||
* - expireDate 可选,空=永久;必须晚于当前时间
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'BlacklistAddModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
submitting: { type: Boolean, default: false },
|
||||
onClose: { type: Function, default: null },
|
||||
onSubmit: { type: Function, default: null },
|
||||
},
|
||||
setup(props: BlacklistAddModalProps) {
|
||||
const form = reactive({
|
||||
userId: undefined as number | undefined,
|
||||
reason: '',
|
||||
expireDate: null as Dayjs | null,
|
||||
});
|
||||
|
||||
// 用户搜索
|
||||
const userSearch = useUserSearch();
|
||||
|
||||
// 弹窗打开时重置表单
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.userId = undefined;
|
||||
form.reason = '';
|
||||
form.expireDate = null;
|
||||
userSearch.reset();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.userId || form.userId <= 0) {
|
||||
message.warning('请搜索并选择用户');
|
||||
return;
|
||||
}
|
||||
if (!form.reason.trim()) {
|
||||
message.warning('请输入拉黑原因');
|
||||
return;
|
||||
}
|
||||
|
||||
// 校验到期时间必须晚于当前
|
||||
if (form.expireDate) {
|
||||
const now = dayjs();
|
||||
if (form.expireDate.isBefore(now)) {
|
||||
message.warning('到期时间必须晚于当前时间');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const params: AiBlacklistAddParams = {
|
||||
userId: form.userId,
|
||||
reason: form.reason.trim(),
|
||||
};
|
||||
|
||||
if (form.expireDate) {
|
||||
params.expireDate = form.expireDate.format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
props.onSubmit?.(params);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
onCancel={props.onClose}
|
||||
title="拉黑用户"
|
||||
width={520}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
>
|
||||
<Form layout="vertical" class={styles.form}>
|
||||
<Form.Item label="用户" required>
|
||||
<Select
|
||||
showSearch
|
||||
value={form.userId ? String(form.userId) : undefined}
|
||||
placeholder="输入手机号搜索用户"
|
||||
filterOption={false}
|
||||
loading={userSearch.searching.value}
|
||||
options={userSearch.options.value as any}
|
||||
style={{ width: '100%' }}
|
||||
onSearch={userSearch.onSearch}
|
||||
onChange={(val: any) => {
|
||||
form.userId = val ? Number(val) : undefined;
|
||||
}}
|
||||
allowClear
|
||||
notFoundContent={
|
||||
userSearch.searching.value
|
||||
? '搜索中...'
|
||||
: userSearch.keyword.value
|
||||
? '无匹配用户'
|
||||
: '请输入手机号搜索'
|
||||
}
|
||||
/>
|
||||
<div class={styles.tip}>搜索手机号选择需要拉黑的用户,展示手机号,传用户ID给后端</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="拉黑原因" required>
|
||||
<Input.TextArea
|
||||
value={form.reason}
|
||||
onUpdate:value={(val: string) => (form.reason = val ?? '')}
|
||||
placeholder="请输入拉黑原因(审计要求必填)"
|
||||
rows={3}
|
||||
maxlength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="到期时间">
|
||||
<DatePicker
|
||||
value={form.expireDate ?? undefined}
|
||||
onUpdate:value={(val: any) => (form.expireDate = val ?? null)}
|
||||
showTime
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="留空 = 永久拉黑"
|
||||
style={{ width: '100%' }}
|
||||
disabledDate={(current: Dayjs) => current && current.isBefore(dayjs().startOf('day'))}
|
||||
/>
|
||||
<div class={styles.tip}>留空表示永久拉黑;选择日期则到指定时间自动解除</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={props.onClose}>取消</Button>
|
||||
<Button type="primary" danger loading={props.submitting} onClick={handleSubmit}>
|
||||
确认拉黑
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { Button, Table, Space, Select, Pagination } from 'ant-design-vue';
|
||||
import { useBlacklistModel } from '../model/useBlacklistModel';
|
||||
import { useBlacklistColumns } from '../model/useBlacklistColumns';
|
||||
import { BLACKLIST_STATUS_OPTIONS } from '../model/config';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { useUserSearch } from '../hooks/useUserSearch';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import BlacklistAddModal from './BlacklistAddModal';
|
||||
import type { AiBlacklistVO } from '../model/services';
|
||||
|
||||
/**
|
||||
* AI 黑名单 Tab 组件
|
||||
*
|
||||
* 对接文档 §1.3:
|
||||
* - 黑名单分页列表
|
||||
* - 拉黑 / 解除拉黑
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'BlacklistTab',
|
||||
setup() {
|
||||
const {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
pagination,
|
||||
addModalVisible,
|
||||
submitting,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
openAddModal,
|
||||
closeAddModal,
|
||||
handleSubmitAdd,
|
||||
handleRemove,
|
||||
} = useBlacklistModel();
|
||||
|
||||
const { columns } = useBlacklistColumns();
|
||||
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
|
||||
|
||||
// 用户搜索
|
||||
const userSearch = useUserSearch();
|
||||
|
||||
/** 重置(包装:同时清除搜索状态) */
|
||||
const onReset = () => {
|
||||
userSearch.reset();
|
||||
handleReset();
|
||||
};
|
||||
|
||||
/** 渲染操作列 */
|
||||
const renderAction = (record: AiBlacklistVO) => {
|
||||
if (record.status === 1) {
|
||||
return (
|
||||
<Button type="link" size="small" danger onClick={() => handleRemove(record)}>
|
||||
解除拉黑
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <span style={{ color: '#999' }}>-</span>;
|
||||
};
|
||||
|
||||
/** 完整表格列 */
|
||||
const tableColumns = [
|
||||
...columns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return () => (
|
||||
<>
|
||||
<div class={pageStyles.filter}>
|
||||
<Space>
|
||||
<Select
|
||||
showSearch
|
||||
value={filterForm.userId ? String(filterForm.userId) : undefined}
|
||||
placeholder="手机号搜索用户"
|
||||
filterOption={false}
|
||||
loading={userSearch.searching.value}
|
||||
options={userSearch.options.value as any}
|
||||
style={{ width: '220px' }}
|
||||
onSearch={userSearch.onSearch}
|
||||
onChange={(val: any) => {
|
||||
filterForm.userId = val ?? '';
|
||||
}}
|
||||
allowClear
|
||||
notFoundContent={
|
||||
userSearch.searching.value
|
||||
? '搜索中...'
|
||||
: userSearch.keyword.value
|
||||
? '无匹配用户'
|
||||
: '输入手机号搜索'
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
value={filterForm.status}
|
||||
onUpdate:value={(val: any) => (filterForm.status = val ?? '')}
|
||||
style={{ width: '200px' }}
|
||||
options={BLACKLIST_STATUS_OPTIONS as any}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={onReset}>重置</Button>
|
||||
</Space>
|
||||
<div class={pageStyles.filterActions}>
|
||||
<Button type="primary" danger onClick={openAddModal}>
|
||||
拉黑用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={dataSource.value}
|
||||
loading={loading.value}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
>
|
||||
{{
|
||||
bodyCell: (args: { column: { key: string }; record: AiBlacklistVO }) => {
|
||||
if (args.column.key === 'action') {
|
||||
return renderAction(args.record);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
</Table>
|
||||
</div>
|
||||
<div class={pageStyles.pagination}>
|
||||
<Pagination
|
||||
current={(pagination as any).current.value}
|
||||
pageSize={(pagination as any).pageSize.value}
|
||||
total={(pagination as any).total.value}
|
||||
showSizeChanger
|
||||
showQuickJumper
|
||||
showTotal={(total: number) => `共 ${total} 条`}
|
||||
pageSizeOptions={['10', '20', '50', '100']}
|
||||
onChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addModalVisible.value && (
|
||||
<BlacklistAddModal
|
||||
visible={addModalVisible.value}
|
||||
submitting={submitting.value}
|
||||
onClose={closeAddModal}
|
||||
onSubmit={handleSubmitAdd}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
.form {
|
||||
padding-top: 8px;
|
||||
|
||||
:global(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { defineComponent, reactive } from 'vue';
|
||||
import { Modal, Form, InputNumber, Input, Select, Button, message } from 'ant-design-vue';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { POINTS_ADJUST_TYPE_OPTIONS } from '../model/config';
|
||||
import { useUserSearch } from '../hooks/useUserSearch';
|
||||
import type { AiPointsAdjustParams } from '../model/services';
|
||||
import styles from './PointsAdjustModal.module.less';
|
||||
|
||||
interface PointsAdjustModalProps {
|
||||
visible: boolean;
|
||||
submitting: boolean;
|
||||
/** 预设用户信息(从账户行点击调点时带入) */
|
||||
presetUser?: { userId: number; phone?: string; nickname?: string };
|
||||
onClose: () => void;
|
||||
onSubmit: (params: AiPointsAdjustParams) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手工调点弹窗(充值/纠错)
|
||||
*
|
||||
* 对接文档 §1.2 ④:
|
||||
* - userId 必填,后端不校验真实性,前端建议用用户选择器(当前简化为输入)
|
||||
* - points 必填,非 0;正=充/赠,负=扣
|
||||
* - type 可选 1=充值/赠送 2=调账;缺省按正负推导
|
||||
* - descr 可选,审计备注
|
||||
*
|
||||
* ⚠️ 后端自动建户:目标用户无账户时自动创建(0 余额起户后原子加点)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'PointsAdjustModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
submitting: { type: Boolean, default: false },
|
||||
presetUser: { type: Object, default: undefined },
|
||||
onClose: { type: Function, default: null },
|
||||
onSubmit: { type: Function, default: null },
|
||||
},
|
||||
setup(props: PointsAdjustModalProps) {
|
||||
const form = reactive({
|
||||
userId: undefined as number | undefined,
|
||||
points: undefined as number | undefined,
|
||||
type: 1 as number,
|
||||
descr: '',
|
||||
});
|
||||
|
||||
// 用户搜索
|
||||
const userSearch = useUserSearch();
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visible) {
|
||||
form.userId = props.presetUser?.userId ?? undefined;
|
||||
form.points = undefined;
|
||||
form.type = 1;
|
||||
form.descr = '';
|
||||
userSearch.reset();
|
||||
if (props.presetUser) {
|
||||
userSearch.setPresetUser(props.presetUser);
|
||||
}
|
||||
}
|
||||
}, [() => props.visible]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.userId || form.userId <= 0) {
|
||||
message.warning('请搜索并选择用户');
|
||||
return;
|
||||
}
|
||||
if (form.points == null || form.points === 0) {
|
||||
message.warning('变更积分不能为 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const params: AiPointsAdjustParams = {
|
||||
userId: form.userId,
|
||||
points: form.points,
|
||||
type: form.type,
|
||||
};
|
||||
|
||||
if (form.descr.trim()) {
|
||||
params.descr = form.descr.trim();
|
||||
}
|
||||
|
||||
props.onSubmit?.(params);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<Modal
|
||||
visible={props.visible}
|
||||
onCancel={props.onClose}
|
||||
title="手工调点"
|
||||
width={520}
|
||||
centered
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
>
|
||||
<Form layout="vertical" class={styles.form}>
|
||||
<Form.Item label="用户" required>
|
||||
<Select
|
||||
showSearch
|
||||
value={form.userId ? String(form.userId) : undefined}
|
||||
placeholder="输入手机号搜索用户"
|
||||
filterOption={false}
|
||||
loading={userSearch.searching.value}
|
||||
options={userSearch.options.value as any}
|
||||
disabled={!!props.presetUser}
|
||||
style={{ width: '100%' }}
|
||||
onSearch={userSearch.onSearch}
|
||||
onChange={(val: any) => {
|
||||
form.userId = val ? Number(val) : undefined;
|
||||
}}
|
||||
allowClear
|
||||
notFoundContent={
|
||||
userSearch.searching.value
|
||||
? '搜索中...'
|
||||
: userSearch.keyword.value
|
||||
? '无匹配用户'
|
||||
: '请输入手机号搜索'
|
||||
}
|
||||
/>
|
||||
<div class={styles.tip}>
|
||||
搜索手机号选择用户,展示手机号,传用户ID给后端;目标用户无账户时系统自动建户
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="变更积分" required>
|
||||
<InputNumber
|
||||
value={form.points}
|
||||
onChange={(val: any) => (form.points = typeof val === 'number' ? val : undefined)}
|
||||
placeholder="正数=充/赠,负数=扣"
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div class={styles.tip}>正数 = 充值/赠送;负数 = 扣减;不能为 0</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="调点类型" name="type">
|
||||
<Select
|
||||
value={form.type}
|
||||
options={POINTS_ADJUST_TYPE_OPTIONS as any}
|
||||
style={{ width: '100%' }}
|
||||
onUpdate:value={(val: any) => (form.type = val ?? 1)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="审计备注">
|
||||
<Input.TextArea
|
||||
value={form.descr}
|
||||
onUpdate:value={(val: string) => (form.descr = val ?? '')}
|
||||
placeholder="可选,如:客诉补偿"
|
||||
rows={3}
|
||||
maxlength={200}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div class={styles.footer}>
|
||||
<Button onClick={props.onClose}>取消</Button>
|
||||
<Button type="primary" loading={props.submitting} onClick={handleSubmit}>
|
||||
确认调点
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { defineComponent, onMounted, ref } from 'vue';
|
||||
import { Button, Table, Space, Select, DatePicker, Pagination, Radio } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { usePointsModel } from '../model/usePointsModel';
|
||||
import { usePointsAccountColumns, usePointsTxnColumns } from '../model/usePointsColumns';
|
||||
import { POINTS_TXN_TYPE_OPTIONS } from '../model/config';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { useUserSearch } from '../hooks/useUserSearch';
|
||||
import {
|
||||
getAiAgentList,
|
||||
type AiAgentConfigVO,
|
||||
type AiPointsAccountVO,
|
||||
type AiPointsTxnVO,
|
||||
} from '../model/services';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import PointsAdjustModal from './PointsAdjustModal';
|
||||
|
||||
/** 用户搜索 Select 的 notFoundContent 公共渲染 */
|
||||
const renderNotFound = (searching: boolean, keyword: string) =>
|
||||
searching ? '搜索中...' : keyword ? '无匹配用户' : '输入手机号搜索';
|
||||
|
||||
/** 分页组件公共配置 */
|
||||
const paginationProps = {
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 积分账户与流水 Tab 组件
|
||||
*
|
||||
* 对接文档 §1.2:
|
||||
* - 账户分页:GET /admin/manager/ai/points/accounts
|
||||
* - 手工调点:POST /admin/manager/ai/points/adjust
|
||||
* - 流水分页:GET /admin/manager/ai/points/transactions
|
||||
*
|
||||
* 子视图切换:
|
||||
* - account:积分账户列表(支持调点 / 查看流水)
|
||||
* - txn:积分流水列表(支持多维筛选)
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'PointsTab',
|
||||
setup() {
|
||||
const {
|
||||
subView,
|
||||
// 账户
|
||||
accountFilter,
|
||||
accountLoading,
|
||||
accountDataSource,
|
||||
accountPagination,
|
||||
handleAccountSearch,
|
||||
handleAccountReset,
|
||||
handleAccountPageChange,
|
||||
handleViewTxn,
|
||||
// 流水
|
||||
txnFilter,
|
||||
txnLoading,
|
||||
txnDataSource,
|
||||
txnPagination,
|
||||
handleTxnSearch,
|
||||
handleTxnReset,
|
||||
handleTxnPageChange,
|
||||
// 调点
|
||||
adjustModalVisible,
|
||||
adjusting,
|
||||
presetUser,
|
||||
openAdjustModal,
|
||||
closeAdjustModal,
|
||||
handleSubmitAdjust,
|
||||
} = usePointsModel();
|
||||
|
||||
const { columns: accountColumns } = usePointsAccountColumns();
|
||||
const { columns: txnColumns } = usePointsTxnColumns();
|
||||
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
|
||||
|
||||
// 用户搜索实例
|
||||
const accountSearch = useUserSearch();
|
||||
const txnSearch = useUserSearch();
|
||||
|
||||
// 场景配置下拉选项(流水筛选用)
|
||||
const agentOptions = ref<{ value: string; label: string }[]>([]);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getAiAgentList();
|
||||
if (res.code == 200 && res.data) {
|
||||
agentOptions.value = res.data.map((item: AiAgentConfigVO) => ({
|
||||
value: item.bizType,
|
||||
label: item.name,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// 网络层已提示
|
||||
}
|
||||
});
|
||||
|
||||
/** 账户重置 */
|
||||
const onAccountReset = () => {
|
||||
accountSearch.reset();
|
||||
handleAccountReset();
|
||||
};
|
||||
|
||||
/** 流水重置 */
|
||||
const onTxnReset = () => {
|
||||
txnSearch.reset();
|
||||
handleTxnReset();
|
||||
};
|
||||
|
||||
/** 账户表格列(追加操作列) */
|
||||
const accountTableColumns = [
|
||||
...accountColumns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/** 渲染账户操作列 */
|
||||
const renderAccountAction = (record: AiPointsAccountVO) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
openAdjustModal({
|
||||
userId: record.userId,
|
||||
phone: record.phone,
|
||||
nickname: record.nickname,
|
||||
})
|
||||
}
|
||||
>
|
||||
调点
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => handleViewTxn(record)}>
|
||||
流水
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return () => (
|
||||
<>
|
||||
{/* ===== 工具栏:子视图切换 + 操作按钮 ===== */}
|
||||
<div class={pageStyles.filter}>
|
||||
<Radio.Group
|
||||
value={subView.value}
|
||||
onUpdate:value={(val: 'account' | 'txn') => (subView.value = val)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="account">积分账户</Radio.Button>
|
||||
<Radio.Button value="txn">积分流水</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div class={pageStyles.filterActions} style={{ marginLeft: 'auto' }}>
|
||||
{subView.value === 'account' && (
|
||||
<Button type="primary" onClick={() => openAdjustModal()}>
|
||||
手工调点
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.filter} style={{ marginTop: '12px' }}>
|
||||
{subView.value === 'account' ? (
|
||||
<Space>
|
||||
<Select
|
||||
showSearch
|
||||
value={accountFilter.userId ? String(accountFilter.userId) : undefined}
|
||||
placeholder="手机号搜索用户"
|
||||
filterOption={false}
|
||||
loading={accountSearch.searching.value}
|
||||
options={accountSearch.options.value as any}
|
||||
style={{ width: '220px' }}
|
||||
onSearch={accountSearch.onSearch}
|
||||
onChange={(val: any) => {
|
||||
accountFilter.userId = val ?? '';
|
||||
}}
|
||||
allowClear
|
||||
notFoundContent={renderNotFound(
|
||||
accountSearch.searching.value,
|
||||
accountSearch.keyword.value,
|
||||
)}
|
||||
/>
|
||||
<Button type="primary" onClick={handleAccountSearch} loading={accountLoading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={onAccountReset}>重置</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space wrap>
|
||||
<Select
|
||||
showSearch
|
||||
value={txnFilter.userId ? String(txnFilter.userId) : undefined}
|
||||
placeholder="手机号搜索用户"
|
||||
filterOption={false}
|
||||
loading={txnSearch.searching.value}
|
||||
options={txnSearch.options.value as any}
|
||||
style={{ width: '200px' }}
|
||||
onSearch={txnSearch.onSearch}
|
||||
onChange={(val: any) => {
|
||||
txnFilter.userId = val ?? '';
|
||||
}}
|
||||
allowClear
|
||||
notFoundContent={renderNotFound(txnSearch.searching.value, txnSearch.keyword.value)}
|
||||
/>
|
||||
<Select
|
||||
value={txnFilter.bizType || undefined}
|
||||
onUpdate:value={(val: any) => (txnFilter.bizType = val ?? '')}
|
||||
placeholder="业务场景"
|
||||
style={{ width: '200px' }}
|
||||
options={agentOptions.value as any}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
value={txnFilter.type}
|
||||
onUpdate:value={(val: any) => (txnFilter.type = val ?? '')}
|
||||
placeholder="流水类型"
|
||||
style={{ width: '140px' }}
|
||||
options={POINTS_TXN_TYPE_OPTIONS as any}
|
||||
/>
|
||||
<DatePicker
|
||||
value={txnFilter.date ? dayjs(txnFilter.date) : undefined}
|
||||
onUpdate:value={(val: any) => {
|
||||
txnFilter.date = val ? val.format('YYYY-MM-DD') : '';
|
||||
}}
|
||||
format="YYYY-MM-DD"
|
||||
placeholder="日期"
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={handleTxnSearch} loading={txnLoading.value}>
|
||||
查询
|
||||
</Button>
|
||||
<Button onClick={onTxnReset}>重置</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
{subView.value === 'account' ? (
|
||||
<Table
|
||||
columns={accountTableColumns}
|
||||
dataSource={accountDataSource.value}
|
||||
loading={accountLoading.value}
|
||||
rowKey="userId"
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
>
|
||||
{{
|
||||
bodyCell: (args: { column: { key: string }; record: AiPointsAccountVO }) => {
|
||||
if (args.column.key === 'action') {
|
||||
return renderAccountAction(args.record);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
</Table>
|
||||
) : (
|
||||
<Table
|
||||
columns={txnColumns}
|
||||
dataSource={txnDataSource.value}
|
||||
loading={txnLoading.value}
|
||||
rowKey={(record: AiPointsTxnVO) =>
|
||||
`${record.userId}_${record.messageId}_${record.createDate}`
|
||||
}
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div class={pageStyles.pagination}>
|
||||
{subView.value === 'account' ? (
|
||||
<Pagination
|
||||
{...paginationProps}
|
||||
current={(accountPagination as any).current.value}
|
||||
pageSize={(accountPagination as any).pageSize.value}
|
||||
total={(accountPagination as any).total.value}
|
||||
onChange={handleAccountPageChange}
|
||||
/>
|
||||
) : (
|
||||
<Pagination
|
||||
{...paginationProps}
|
||||
current={(txnPagination as any).current.value}
|
||||
pageSize={(txnPagination as any).pageSize.value}
|
||||
total={(txnPagination as any).total.value}
|
||||
onChange={handleTxnPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adjustModalVisible.value && (
|
||||
<PointsAdjustModal
|
||||
visible={adjustModalVisible.value}
|
||||
submitting={adjusting.value}
|
||||
presetUser={presetUser.value}
|
||||
onClose={closeAdjustModal}
|
||||
onSubmit={handleSubmitAdjust}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ref } from 'vue';
|
||||
import { getUserList, type EventUserVO } from '@/pages/events/users/model/services';
|
||||
|
||||
export interface UserSearchOption {
|
||||
/** userId 作为 value(传给后端) */
|
||||
value: string;
|
||||
/** 展示文案:手机号(昵称) */
|
||||
label: string;
|
||||
/** 原始用户数据 */
|
||||
raw: EventUserVO;
|
||||
}
|
||||
|
||||
export interface PresetUserInfo {
|
||||
userId: number | string;
|
||||
phone?: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export function useUserSearch(delay = 300) {
|
||||
/** 搜索关键字(内部管理,调用方无需关心) */
|
||||
const keyword = ref('');
|
||||
/** Select 选项列表 */
|
||||
const options = ref<UserSearchOption[]>([]);
|
||||
/** 搜索中状态 */
|
||||
const searching = ref(false);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const doSearch = async (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
if (!trimmed) {
|
||||
options.value = [];
|
||||
return;
|
||||
}
|
||||
searching.value = true;
|
||||
try {
|
||||
const res = await getUserList({
|
||||
page: '1',
|
||||
limit: '20',
|
||||
phone: trimmed,
|
||||
});
|
||||
if (res.code == 200 && res.data?.list) {
|
||||
options.value = res.data.list.map((user) => ({
|
||||
value: user.id,
|
||||
label: `${user.phone}${user.nickname ? `(${user.nickname})` : ''}`,
|
||||
raw: user,
|
||||
}));
|
||||
} else {
|
||||
options.value = [];
|
||||
}
|
||||
} catch {
|
||||
options.value = [];
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Select onSearch 回调(带防抖) */
|
||||
const onSearch = (val: string) => {
|
||||
keyword.value = val;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!val || !val.trim()) {
|
||||
options.value = [];
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => doSearch(val), delay);
|
||||
};
|
||||
|
||||
/** Select onChange 回调:返回 userId(数字),清空时返回 undefined */
|
||||
const onChange = (val: any): number | undefined => {
|
||||
return val ? Number(val) : undefined;
|
||||
};
|
||||
|
||||
/** 注入预设用户选项(从表格行点击带入时使用) */
|
||||
const setPresetUser = (user: PresetUserInfo) => {
|
||||
const label = user.phone
|
||||
? `${user.phone}${user.nickname ? `(${user.nickname})` : ''}`
|
||||
: `用户ID: ${user.userId}`;
|
||||
options.value = [
|
||||
{
|
||||
value: String(user.userId),
|
||||
label,
|
||||
raw: {
|
||||
id: String(user.userId),
|
||||
nickname: user.nickname || '',
|
||||
phone: user.phone || '',
|
||||
realName: '',
|
||||
idCard: '',
|
||||
idCardImg: '',
|
||||
idCardImgList: [],
|
||||
gender: '',
|
||||
status: '',
|
||||
createTime: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/** 重置:清除 keyword + options(供筛选栏重置按钮调用) */
|
||||
const reset = () => {
|
||||
keyword.value = '';
|
||||
options.value = [];
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
keyword,
|
||||
options,
|
||||
searching,
|
||||
onSearch,
|
||||
onChange,
|
||||
doSearch,
|
||||
setPresetUser,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { Button, Table, Space, Tabs, TabPane } from 'ant-design-vue';
|
||||
import { useAiAgentModel } from './model/useAiAgentModel';
|
||||
import { useAiAgentColumns } from './model/useAiAgentColumns';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import AiAgentEditModal from './components/AiAgentEditModal';
|
||||
import AiModelDict from './components/AiModelDict';
|
||||
import BlacklistTab from './components/BlacklistTab';
|
||||
import PointsTab from './components/PointsTab';
|
||||
import BillingReportTab from './components/BillingReportTab';
|
||||
import type { AiAgentConfigVO } from './model/services';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SystemAiAgent',
|
||||
setup() {
|
||||
const activeTab = ref('agent');
|
||||
|
||||
const {
|
||||
loading,
|
||||
dataSource,
|
||||
editModalVisible,
|
||||
currentRecord,
|
||||
submitting,
|
||||
handleSearch,
|
||||
openEditModal,
|
||||
closeEditModal,
|
||||
handleSubmitEdit,
|
||||
} = useAiAgentModel();
|
||||
|
||||
const { columns } = useAiAgentColumns();
|
||||
const { containerRef, height } = useContainerSize({ headerOffset: 55 });
|
||||
|
||||
/** 渲染操作列 */
|
||||
const renderAction = (record: AiAgentConfigVO) => (
|
||||
<Button type="link" size="small" onClick={() => openEditModal(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
);
|
||||
|
||||
/** 完整表格列 */
|
||||
const tableColumns = [
|
||||
...columns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return () => (
|
||||
<div class={pageStyles.containerMain}>
|
||||
<Tabs
|
||||
activeKey={activeTab.value}
|
||||
onUpdate:activeKey={(val: string | number) => (activeTab.value = String(val))}
|
||||
class={pageStyles.tabs}
|
||||
destroyInactiveTabPane
|
||||
>
|
||||
<TabPane key="agent" tab="场景配置">
|
||||
<div class={pageStyles.filter}>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch} loading={loading.value}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div class={pageStyles.table}>
|
||||
<div ref={containerRef} class={pageStyles.tableBody}>
|
||||
<Table
|
||||
columns={tableColumns}
|
||||
dataSource={dataSource.value}
|
||||
loading={loading.value}
|
||||
rowKey="id"
|
||||
scroll={{ x: 'max-content', y: height.value }}
|
||||
pagination={false}
|
||||
>
|
||||
{{
|
||||
bodyCell: (args: { column: { key: string }; record: AiAgentConfigVO }) => {
|
||||
if (args.column.key === 'action') {
|
||||
return renderAction(args.record);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="model" tab="模型字典">
|
||||
<AiModelDict />
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="points" tab="积分账户">
|
||||
<PointsTab />
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="blacklist" tab="黑名单管理">
|
||||
<BlacklistTab />
|
||||
</TabPane>
|
||||
|
||||
<TabPane key="report" tab="报表">
|
||||
<BillingReportTab />
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
{editModalVisible.value && (
|
||||
<AiAgentEditModal
|
||||
visible={editModalVisible.value}
|
||||
record={currentRecord.value as any}
|
||||
submitting={submitting.value}
|
||||
onClose={closeEditModal}
|
||||
onSubmit={handleSubmitEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AI 场景配置 - 配置文件
|
||||
* 集中管理选项配置和状态映射
|
||||
*/
|
||||
|
||||
import type { StatusTagTone } from '@/components';
|
||||
|
||||
/** 业务开关映射 */
|
||||
export const ENABLED_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||
'1': { label: '开启', tone: 'success' },
|
||||
'0': { label: '关闭', tone: 'danger' },
|
||||
};
|
||||
|
||||
/** 计费开关映射 */
|
||||
export const BILLING_ENABLED_MAP: Record<string, { label: string; tone: StatusTagTone }> = {
|
||||
'1': { label: '计费', tone: 'primary' },
|
||||
'0': { label: '免费', tone: 'default' },
|
||||
};
|
||||
|
||||
/** 倍率最小值 */
|
||||
export const MIN_MULTIPLIER = 0.01;
|
||||
/** 免费额度最大值 */
|
||||
export const MAX_FREE_POINTS = 999999;
|
||||
|
||||
/** 黑名单状态映射 */
|
||||
export const BLACKLIST_STATUS_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
|
||||
1: { label: '拉黑中', tone: 'danger' },
|
||||
0: { label: '已解除', tone: 'default' },
|
||||
};
|
||||
|
||||
/** 黑名单状态筛选选项 */
|
||||
export const BLACKLIST_STATUS_OPTIONS = [
|
||||
{ value: '' as '' | number, label: '全部' },
|
||||
{ value: 1, label: '拉黑中' },
|
||||
{ value: 0, label: '已解除' },
|
||||
] as const;
|
||||
|
||||
/** 流水类型映射 */
|
||||
export const POINTS_TXN_TYPE_MAP: Record<number, { label: string; tone: StatusTagTone }> = {
|
||||
1: { label: '充值/赠送', tone: 'success' },
|
||||
2: { label: '调账', tone: 'primary' },
|
||||
3: { label: '消费', tone: 'warning' },
|
||||
4: { label: '欠款', tone: 'danger' },
|
||||
};
|
||||
|
||||
/** 流水类型筛选选项 */
|
||||
export const POINTS_TXN_TYPE_OPTIONS = [
|
||||
{ value: '' as '' | number, label: '全部' },
|
||||
{ value: 1, label: '充值/赠送' },
|
||||
{ value: 2, label: '调账' },
|
||||
{ value: 3, label: '消费' },
|
||||
{ value: 4, label: '欠款' },
|
||||
] as const;
|
||||
|
||||
/** 调点类型选项 */
|
||||
export const POINTS_ADJUST_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '充值/赠送' },
|
||||
{ value: 2, label: '调账' },
|
||||
] as const;
|
||||
@@ -0,0 +1,416 @@
|
||||
import { get, post } from '@/utils/request';
|
||||
|
||||
/** AI 场景配置 VO */
|
||||
export interface AiAgentConfigVO {
|
||||
id: string | number;
|
||||
/** 业务类型(唯一键) */
|
||||
bizType: string;
|
||||
/** 展示名 */
|
||||
name: string;
|
||||
/** 业务开关 1/0(0=前端隐藏入口、会话创建被拒) */
|
||||
enabled: string | number;
|
||||
/** 计费开关 1/0(0=该场景免费) */
|
||||
billingEnabled: string | number;
|
||||
/** 计费倍率(>0,促销可调) */
|
||||
billingMultiplier: string | number;
|
||||
/** 每日免费额度(口径=每日免费结算条数;0=无免费额) */
|
||||
dailyFreePoints: string | number;
|
||||
/** 运营备注 */
|
||||
descr: string;
|
||||
}
|
||||
|
||||
/** 更新场景配置参数 */
|
||||
export interface AiAgentUpdateParams {
|
||||
id: string | number;
|
||||
enabled?: number;
|
||||
billingEnabled?: number;
|
||||
billingMultiplier?: number;
|
||||
dailyFreePoints?: number;
|
||||
descr?: string;
|
||||
}
|
||||
|
||||
/** 模型字典 VO */
|
||||
export interface AiModelDictVO {
|
||||
/** 配置键(如 model / extract.apiKey) */
|
||||
label: string;
|
||||
/** 配置值(敏感键脱敏;未配置 null) */
|
||||
value: string | null;
|
||||
/** 是否敏感(apiKey 类:留空=保留原值) */
|
||||
sensitive: boolean;
|
||||
/** 用途组:chat=对话/工具(全局默认) extract=导入提取 failover=备用网关 */
|
||||
purpose: string;
|
||||
/** 表单提示文案 */
|
||||
remark: string;
|
||||
}
|
||||
|
||||
/** 模型字典保存参数 */
|
||||
export interface AiModelDictSaveParams {
|
||||
items: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
/** 模型字典重置参数 */
|
||||
export interface AiModelDictResetParams {
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
/** AI 积分账户 VO */
|
||||
export interface AiPointsAccountVO {
|
||||
/** 用户 ID */
|
||||
userId: number;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 当前余额(可为负=欠款) */
|
||||
points: number;
|
||||
/** 累计充值 */
|
||||
totalRecharged: number;
|
||||
/** 累计消费 */
|
||||
totalConsumed: number;
|
||||
}
|
||||
|
||||
/** AI 积分流水 VO */
|
||||
export interface AiPointsTxnVO {
|
||||
/** 用户 ID */
|
||||
userId: number;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 业务场景(如 matchImport / chat 等) */
|
||||
bizType: string;
|
||||
/** 流水类型 1=充值/赠送 2=调账 3=消费 4=欠款 */
|
||||
type: number;
|
||||
/** 变更积分数(正=充/赠/调增,负=扣/消费) */
|
||||
changePoints: number;
|
||||
/** 输入 token 数 */
|
||||
inputTokens: number;
|
||||
/** 输出 token 数 */
|
||||
outputTokens: number;
|
||||
/** 模型标识 */
|
||||
model: string;
|
||||
/** 消息 ID */
|
||||
messageId: string;
|
||||
/** 是否免费 1=免费 0=付费 */
|
||||
freeFlag: number;
|
||||
/** 审计备注 */
|
||||
descr: string;
|
||||
/** 创建时间 */
|
||||
createDate: string;
|
||||
}
|
||||
|
||||
/** 积分账户分页查询参数 */
|
||||
export interface AiPointsAccountQueryParams {
|
||||
page: number;
|
||||
size: number;
|
||||
/** 可选:用户 ID 筛选 */
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
/** 积分流水分页查询参数 */
|
||||
export interface AiPointsTxnQueryParams {
|
||||
page: number;
|
||||
size: number;
|
||||
/** 可选:用户 ID 筛选 */
|
||||
userId?: number;
|
||||
/** 可选:业务场景筛选 */
|
||||
bizType?: string;
|
||||
/** 可选:流水类型 1充值/2调账/3消费/4欠款 */
|
||||
type?: number;
|
||||
/** 可选:日期筛选 yyyy-MM-dd */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/** 手工调点参数 */
|
||||
export interface AiPointsAdjustParams {
|
||||
/** 必填:用户 ID */
|
||||
userId: number;
|
||||
/** 必填:积分数(非0;正=充/赠,负=扣) */
|
||||
points: number;
|
||||
/** 可选 1=充值/赠送 2=调账;缺省按正负推导 */
|
||||
type?: number;
|
||||
/** 可选:审计备注 */
|
||||
descr?: string;
|
||||
}
|
||||
|
||||
/** AI 黑名单 VO */
|
||||
export interface AiBlacklistVO {
|
||||
id: number;
|
||||
/** 用户 ID */
|
||||
userId: number;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 手机号 */
|
||||
phone?: string;
|
||||
/** 拉黑原因(审计要求) */
|
||||
reason: string;
|
||||
/** 状态:1=拉黑中 0=已解除 */
|
||||
status: number;
|
||||
/** 到期时间(null=永久;yyyy-MM-dd HH:mm:ss) */
|
||||
expireDate: string | null;
|
||||
/** 操作人 ID */
|
||||
operatorId: number;
|
||||
/** 创建时间 */
|
||||
createDate: string;
|
||||
/** 更新时间 */
|
||||
updateDate: string;
|
||||
}
|
||||
|
||||
/** 黑名单分页查询参数 */
|
||||
export interface AiBlacklistQueryParams {
|
||||
page: number;
|
||||
size: number;
|
||||
/** 可选:用户 ID 筛选 */
|
||||
userId?: number;
|
||||
/** 可选:状态筛选 1=拉黑中 0=已解除 */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/** 拉黑参数 */
|
||||
export interface AiBlacklistAddParams {
|
||||
/** 必填:用户 ID */
|
||||
userId: number;
|
||||
/** 必填:拉黑原因 */
|
||||
reason: string;
|
||||
/** 可选:到期时间(yyyy-MM-dd[ HH:mm:ss];空=永久) */
|
||||
expireDate?: string;
|
||||
}
|
||||
|
||||
/** 解除拉黑参数 */
|
||||
export interface AiBlacklistRemoveParams {
|
||||
userId: number;
|
||||
}
|
||||
|
||||
/** 分页数据 */
|
||||
export interface PageData<T> {
|
||||
total: number;
|
||||
list: T[];
|
||||
}
|
||||
|
||||
/** 接口通用返回结构 */
|
||||
export interface ApiResult<T> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** 计费单价 VO(文档 §3.3,GET /admin/manager/ai/billing/prices) */
|
||||
export interface AiBillingPriceVO {
|
||||
/** 模型标识 */
|
||||
model: string;
|
||||
/** 输入单价(点/千 token) */
|
||||
inputPrice: number;
|
||||
/** 输出单价(点/千 token) */
|
||||
outputPrice: number;
|
||||
}
|
||||
|
||||
/** 通用字典类型 VO(§2.4 GET types) */
|
||||
export interface SysDictTypeVO {
|
||||
id: number;
|
||||
dictType: string;
|
||||
dictName: string;
|
||||
remark: string;
|
||||
sort: number;
|
||||
dataCount: number;
|
||||
}
|
||||
|
||||
/** 通用字典数据行 VO(§2.4 GET data/list) */
|
||||
export interface SysDictDataVO {
|
||||
id: number;
|
||||
dictTypeId: number;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
sort: number;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
/** 通用字典 data/save 参数(§2.4) */
|
||||
export interface DictDataSaveParams {
|
||||
/** 数据行 id;空=新建 */
|
||||
id?: number;
|
||||
/** 关联类型 id */
|
||||
dictTypeId: number;
|
||||
/** 字典 label,如 deepseek-flash.input / default.output */
|
||||
dictLabel: string;
|
||||
/** 字典 value,单价值 */
|
||||
dictValue: string;
|
||||
/** 排序 */
|
||||
sort?: number;
|
||||
/** 备注 */
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
/** 通用字典 data/delete 参数(§2.4,按 id 删除) */
|
||||
export interface DictDataDeleteParams {
|
||||
id: number;
|
||||
}
|
||||
|
||||
// ===== 报表相关(文档 §1.4) =====
|
||||
|
||||
/** 计费报表行 VO(§1.4 ⑥ GET /admin/manager/ai/billing/report) */
|
||||
export interface AiBillingReportVO {
|
||||
/** 日期 yyyy-MM-dd */
|
||||
date: string;
|
||||
/** 业务场景 */
|
||||
bizType: string;
|
||||
/** 消息数 */
|
||||
messageCount: number;
|
||||
/** 总 token 数 */
|
||||
totalTokens: number;
|
||||
/** 总消耗积分 */
|
||||
totalPoints: number;
|
||||
}
|
||||
|
||||
/** 直解格式统计报表行 VO(§1.4 ⑦ GET /admin/manager/ai/freeform/report) */
|
||||
export interface AiFreeformReportVO {
|
||||
/** 文档指纹 */
|
||||
fingerprint: string;
|
||||
/** 文档性质 */
|
||||
docNature: string;
|
||||
/** 信号集 */
|
||||
signalSet: string;
|
||||
/** 表头签名 */
|
||||
headerSignature: string;
|
||||
/** 使用次数 */
|
||||
useCount: number;
|
||||
/** 最后使用时间 */
|
||||
lastUsedAt: string;
|
||||
/** 是否高频(月频次≥5) */
|
||||
highFrequency: boolean;
|
||||
}
|
||||
|
||||
/** 计费报表查询参数(§1.4 ⑥) */
|
||||
export interface AiBillingReportQueryParams {
|
||||
/** 可选:开始日期 yyyy-MM-dd */
|
||||
startDate?: string;
|
||||
/** 可选:结束日期 yyyy-MM-dd */
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
/** 直解报表查询参数(§1.4 ⑦) */
|
||||
export interface AiFreeformReportQueryParams {
|
||||
/** 返回行数,默认 50 */
|
||||
top?: number;
|
||||
}
|
||||
|
||||
const AI_AGENT_LIST = '/admin/manager/ai/agent/list';
|
||||
const AI_AGENT_UPDATE = '/admin/manager/ai/agent/update';
|
||||
const AI_MODEL_DICT_GET = '/admin/manager/ai/model/dict';
|
||||
const AI_MODEL_DICT_SAVE = '/admin/manager/ai/model/dict';
|
||||
const AI_MODEL_DICT_RESET = '/admin/manager/ai/model/dict/reset';
|
||||
const AI_BLACKLIST_LIST = '/admin/manager/ai/blacklist/list';
|
||||
const AI_BLACKLIST_ADD = '/admin/manager/ai/blacklist/add';
|
||||
const AI_BLACKLIST_REMOVE = '/admin/manager/ai/blacklist/remove';
|
||||
const AI_POINTS_ACCOUNTS = '/admin/manager/ai/points/accounts';
|
||||
const AI_POINTS_ADJUST = '/admin/manager/ai/points/adjust';
|
||||
const AI_POINTS_TRANSACTIONS = '/admin/manager/ai/points/transactions';
|
||||
const AI_BILLING_PRICES = '/admin/manager/ai/billing/prices';
|
||||
const DICT_TYPES = '/admin/manager/dict/types';
|
||||
const DICT_DATA_LIST = '/admin/manager/dict/data/list';
|
||||
const DICT_DATA_SAVE = '/admin/manager/dict/data/save';
|
||||
const DICT_DATA_DELETE = '/admin/manager/dict/data/delete';
|
||||
const AI_BILLING_REPORT = '/admin/manager/ai/billing/report';
|
||||
const AI_FREEFORM_REPORT = '/admin/manager/ai/freeform/report';
|
||||
|
||||
/** 单价字典类型标识 */
|
||||
export const AI_BILLING_PRICE_DICT_TYPE = 'ai_billing_price';
|
||||
|
||||
/** 场景配置列表 */
|
||||
export function getAiAgentList(): Promise<ApiResult<AiAgentConfigVO[]>> {
|
||||
return get(AI_AGENT_LIST, {});
|
||||
}
|
||||
|
||||
/** 更新场景配置 */
|
||||
export function updateAiAgent(params: AiAgentUpdateParams): Promise<ApiResult<string>> {
|
||||
return post(AI_AGENT_UPDATE, params);
|
||||
}
|
||||
|
||||
/** 读取模型字典当前配置(白名单 16 键全量返回) */
|
||||
export function getAiModelDict(): Promise<ApiResult<AiModelDictVO[]>> {
|
||||
return get(AI_MODEL_DICT_GET, {});
|
||||
}
|
||||
|
||||
/** 保存模型字典(整组 upsert) */
|
||||
export function saveAiModelDict(params: AiModelDictSaveParams): Promise<ApiResult<string>> {
|
||||
return post(AI_MODEL_DICT_SAVE, params);
|
||||
}
|
||||
|
||||
/** 重置模型字典指定键(回落后一级) */
|
||||
export function resetAiModelDict(params: AiModelDictResetParams): Promise<ApiResult<string>> {
|
||||
return post(AI_MODEL_DICT_RESET, params);
|
||||
}
|
||||
|
||||
/** 积分账户分页列表 */
|
||||
export function getAiPointsAccounts(
|
||||
params: AiPointsAccountQueryParams,
|
||||
): Promise<ApiResult<PageData<AiPointsAccountVO>>> {
|
||||
return get(AI_POINTS_ACCOUNTS, params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 手工调点(充值/纠错) */
|
||||
export function adjustAiPoints(params: AiPointsAdjustParams): Promise<ApiResult<string>> {
|
||||
return post(AI_POINTS_ADJUST, params);
|
||||
}
|
||||
|
||||
/** 积分流水分页列表 */
|
||||
export function getAiPointsTransactions(
|
||||
params: AiPointsTxnQueryParams,
|
||||
): Promise<ApiResult<PageData<AiPointsTxnVO>>> {
|
||||
return get(AI_POINTS_TRANSACTIONS, params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 黑名单分页列表 */
|
||||
export function getAiBlacklistList(
|
||||
params: AiBlacklistQueryParams,
|
||||
): Promise<ApiResult<PageData<AiBlacklistVO>>> {
|
||||
return get(AI_BLACKLIST_LIST, params as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 拉黑 */
|
||||
export function addAiBlacklist(params: AiBlacklistAddParams): Promise<ApiResult<string>> {
|
||||
return post(AI_BLACKLIST_ADD, params);
|
||||
}
|
||||
|
||||
/** 解除拉黑 */
|
||||
export function removeAiBlacklist(params: AiBlacklistRemoveParams): Promise<ApiResult<string>> {
|
||||
return post(AI_BLACKLIST_REMOVE, params);
|
||||
}
|
||||
|
||||
/** 读取模型计费单价列表(生效价,§3.3) */
|
||||
export function getAiBillingPrices(): Promise<ApiResult<AiBillingPriceVO[]>> {
|
||||
return get(AI_BILLING_PRICES, {});
|
||||
}
|
||||
|
||||
/** 通用字典 — 获取全部类型(§2.4 GET types) */
|
||||
export function getDictTypes(): Promise<ApiResult<SysDictTypeVO[]>> {
|
||||
return get(DICT_TYPES, {});
|
||||
}
|
||||
|
||||
/** 通用字典 — 获取某类型下数据行(§2.4 GET data/list) */
|
||||
export function getDictDataList(dictTypeId: number): Promise<ApiResult<SysDictDataVO[]>> {
|
||||
return get(DICT_DATA_LIST, { dictTypeId });
|
||||
}
|
||||
|
||||
/** 通用字典 — 保存/更新数据行(§2.4 POST data/save) */
|
||||
export function saveDictData(params: DictDataSaveParams): Promise<ApiResult<string>> {
|
||||
return post(DICT_DATA_SAVE, params);
|
||||
}
|
||||
|
||||
/** 通用字典 — 删除数据行(§2.4 POST data/delete,按 id 删除) */
|
||||
export function deleteDictData(id: number): Promise<ApiResult<string>> {
|
||||
return post(DICT_DATA_DELETE, { id });
|
||||
}
|
||||
|
||||
/** 计费报表(§1.4 ⑥,按日按场景汇总) */
|
||||
export function getAiBillingReport(
|
||||
params: AiBillingReportQueryParams,
|
||||
): Promise<ApiResult<AiBillingReportVO[]>> {
|
||||
return get(AI_BILLING_REPORT, params as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** 直解格式统计报表(§1.4 ⑦) */
|
||||
export function getAiFreeformReport(
|
||||
params: AiFreeformReportQueryParams,
|
||||
): Promise<ApiResult<AiFreeformReportVO[]>> {
|
||||
return get(AI_FREEFORM_REPORT, params as Record<string, unknown>);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { h, type VNodeChild } from 'vue';
|
||||
import { StatusTag, type StatusTagTone } from '@/components';
|
||||
import { ENABLED_MAP, BILLING_ENABLED_MAP } from './config';
|
||||
|
||||
/**
|
||||
* AI 场景配置页 - 表格列配置(视图层)
|
||||
*
|
||||
* 职责:纯表格列配置,不包含业务逻辑
|
||||
* 业务开关 / 计费开关:列表仅展示文案,编辑在弹窗中进行
|
||||
* 操作列通过 customRender 占位,在 index.tsx 中通过 bodyCell slot 渲染
|
||||
*/
|
||||
|
||||
/** 渲染状态标签 */
|
||||
const renderStatusTag = (
|
||||
text: string | number,
|
||||
map: Record<string, { label: string; tone: StatusTagTone }>,
|
||||
): VNodeChild => {
|
||||
const info = map[String(text)] || { label: String(text), tone: 'default' as const };
|
||||
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||
};
|
||||
|
||||
/** 渲染计费倍率 */
|
||||
const renderMultiplier = (text: string | number): string => `${text}x`;
|
||||
|
||||
/** 渲染每日免费额度 */
|
||||
const renderFreePoints = (text: string | number): string =>
|
||||
Number(text) > 0 ? `${text} 条` : '无';
|
||||
|
||||
/** 渲染备注 */
|
||||
const renderDescr = (text: string): string => text || '-';
|
||||
|
||||
export function useAiAgentColumns() {
|
||||
const columns = [
|
||||
{
|
||||
title: '场景名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '业务类型',
|
||||
dataIndex: 'bizType',
|
||||
key: 'bizType',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '业务开关',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string | number }): VNodeChild =>
|
||||
renderStatusTag(text, ENABLED_MAP),
|
||||
},
|
||||
{
|
||||
title: '计费开关',
|
||||
dataIndex: 'billingEnabled',
|
||||
key: 'billingEnabled',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string | number }): VNodeChild =>
|
||||
renderStatusTag(text, BILLING_ENABLED_MAP),
|
||||
},
|
||||
{
|
||||
title: '计费倍率',
|
||||
dataIndex: 'billingMultiplier',
|
||||
key: 'billingMultiplier',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string | number }): string => renderMultiplier(text),
|
||||
},
|
||||
{
|
||||
title: '每日免费额度',
|
||||
dataIndex: 'dailyFreePoints',
|
||||
key: 'dailyFreePoints',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string | number }): string => renderFreePoints(text),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'descr',
|
||||
key: 'descr',
|
||||
width: 200,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderDescr(text),
|
||||
},
|
||||
];
|
||||
|
||||
return { columns };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { computed } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useRequest } from '@/hooks/useRequest';
|
||||
import { useState } from '@/hooks';
|
||||
import {
|
||||
getAiAgentList,
|
||||
updateAiAgent,
|
||||
type AiAgentConfigVO,
|
||||
type AiAgentUpdateParams,
|
||||
type ApiResult,
|
||||
} from './services';
|
||||
|
||||
/**
|
||||
* AI 场景配置页数据模型
|
||||
* 职责:状态管理 + 数据获取 + 业务逻辑 + 交互处理
|
||||
*
|
||||
* 架构分层:
|
||||
* - 数据层:services.ts(API 调用)
|
||||
* - 逻辑层:本文件(状态 + 业务逻辑)
|
||||
* - 视图层:useAiAgentColumns.ts + index.tsx(纯 UI 渲染)
|
||||
*/
|
||||
export function useAiAgentModel() {
|
||||
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||
const [currentRecord, setCurrentRecord] = useState<AiAgentConfigVO | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
run: fetchList,
|
||||
} = useRequest<ApiResult<AiAgentConfigVO[]>>(() => getAiAgentList(), {
|
||||
refreshDeps: [],
|
||||
formatResult: (res) => res,
|
||||
});
|
||||
|
||||
// 提取列表数据
|
||||
const dataSource = computed<AiAgentConfigVO[]>(() => {
|
||||
const res = data.value;
|
||||
return res ? (res as any).data || [] : [];
|
||||
});
|
||||
|
||||
const handleSearch = () => fetchList();
|
||||
|
||||
/** 打开编辑弹窗 */
|
||||
const openEditModal = (record: AiAgentConfigVO) => {
|
||||
setCurrentRecord({ ...record });
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
/** 关闭编辑弹窗 */
|
||||
const closeEditModal = () => {
|
||||
setEditModalVisible(false);
|
||||
setCurrentRecord(null);
|
||||
};
|
||||
|
||||
/** 提交编辑(对接文档 §1.1② POST /admin/manager/ai/agent/update) */
|
||||
const handleSubmitEdit = async (params: AiAgentUpdateParams) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await updateAiAgent(params);
|
||||
if (res && res.code == 200) {
|
||||
message.success('配置已更新');
|
||||
closeEditModal();
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res?.msg || '更新失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[useAiAgentModel] updateAiAgent error:', err);
|
||||
message.error('更新失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
dataSource,
|
||||
editModalVisible,
|
||||
currentRecord,
|
||||
submitting,
|
||||
handleSearch,
|
||||
openEditModal,
|
||||
closeEditModal,
|
||||
handleSubmitEdit,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { h, type VNodeChild } from 'vue';
|
||||
import { StatusTag } from '@/components';
|
||||
import { BLACKLIST_STATUS_MAP } from './config';
|
||||
|
||||
/**
|
||||
* AI 黑名单 - 表格列配置(视图层)
|
||||
*
|
||||
* 职责:纯表格列配置,不包含业务逻辑
|
||||
*/
|
||||
|
||||
/** 渲染状态标签 */
|
||||
const renderStatusTag = (text: number): VNodeChild => {
|
||||
const info = BLACKLIST_STATUS_MAP[text] || { label: String(text), tone: 'default' as const };
|
||||
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||
};
|
||||
|
||||
/** 渲染到期时间 */
|
||||
const renderExpireDate = (text: string | null): string => {
|
||||
if (!text) return '永久';
|
||||
return text;
|
||||
};
|
||||
|
||||
/** 渲染原因 */
|
||||
const renderReason = (text: string): string => text || '-';
|
||||
|
||||
export function useBlacklistColumns() {
|
||||
const columns = [
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
width: 140,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderReason(text),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderReason(text),
|
||||
},
|
||||
{
|
||||
title: '拉黑原因',
|
||||
dataIndex: 'reason',
|
||||
key: 'reason',
|
||||
width: 240,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderReason(text),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: number }): VNodeChild => renderStatusTag(text),
|
||||
},
|
||||
{
|
||||
title: '到期时间',
|
||||
dataIndex: 'expireDate',
|
||||
key: 'expireDate',
|
||||
width: 180,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string | null }): string => renderExpireDate(text),
|
||||
},
|
||||
{
|
||||
title: '操作人ID',
|
||||
dataIndex: 'operatorId',
|
||||
key: 'operatorId',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '拉黑时间',
|
||||
dataIndex: 'createDate',
|
||||
key: 'createDate',
|
||||
width: 180,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updateDate',
|
||||
key: 'updateDate',
|
||||
width: 180,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return { columns };
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { computed, reactive, nextTick, ref } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { useRequest } from '@/hooks/useRequest';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
|
||||
import { useThrottleFn } from '@/hooks';
|
||||
import {
|
||||
getAiBlacklistList,
|
||||
addAiBlacklist,
|
||||
removeAiBlacklist,
|
||||
type AiBlacklistVO,
|
||||
type AiBlacklistQueryParams,
|
||||
type AiBlacklistAddParams,
|
||||
type ApiResult,
|
||||
type PageData,
|
||||
} from './services';
|
||||
|
||||
/**
|
||||
* AI 黑名单 - 数据模型
|
||||
*
|
||||
* 职责:状态管理 + 数据获取 + 业务逻辑(拉黑/解除)
|
||||
*
|
||||
* 对接文档 §1.3:
|
||||
* - GET /admin/manager/ai/blacklist/list — 分页
|
||||
* - POST /admin/manager/ai/blacklist/add — 拉黑
|
||||
* - POST /admin/manager/ai/blacklist/remove — 解除
|
||||
*/
|
||||
export function useBlacklistModel() {
|
||||
const filterForm = reactive({
|
||||
userId: '' as string | number,
|
||||
status: '' as '' | number,
|
||||
});
|
||||
|
||||
const pagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
|
||||
|
||||
const buildQueryParams = (): AiBlacklistQueryParams => {
|
||||
const params: AiBlacklistQueryParams = {
|
||||
page: (pagination as any).current.value,
|
||||
size: (pagination as any).pageSize.value,
|
||||
};
|
||||
const uid = filterForm.userId;
|
||||
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
|
||||
params.userId = Number(uid);
|
||||
}
|
||||
if (filterForm.status !== '') {
|
||||
params.status = Number(filterForm.status);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
run: fetchList,
|
||||
} = useRequest<ApiResult<PageData<AiBlacklistVO>>>(() => getAiBlacklistList(buildQueryParams()), {
|
||||
refreshDeps: [],
|
||||
formatResult: (res) => res,
|
||||
});
|
||||
|
||||
const dataSource = computed<AiBlacklistVO[]>(() => {
|
||||
const res = data.value;
|
||||
return res ? (res as any).data?.list || [] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncPaginationTotal(pagination, (data.value as any)?.data?.total);
|
||||
}, [data]);
|
||||
|
||||
const addModalVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
/** 搜索 */
|
||||
const handleSearch = () => {
|
||||
pagination.reset();
|
||||
fetchList();
|
||||
};
|
||||
|
||||
/** 重置 */
|
||||
const handleReset = useThrottleFn(async () => {
|
||||
filterForm.userId = '';
|
||||
filterForm.status = '';
|
||||
pagination.reset();
|
||||
await fetchList();
|
||||
}, 500);
|
||||
|
||||
/** 分页变更 */
|
||||
const handlePageChange = (page: number, pageSize: number) => {
|
||||
pagination.setCurrent(page);
|
||||
if (pageSize !== (pagination as any).pageSize.value) {
|
||||
pagination.setPageSize(pageSize);
|
||||
}
|
||||
nextTick().then(() => fetchList());
|
||||
};
|
||||
|
||||
/** 打开拉黑弹窗 */
|
||||
const openAddModal = () => {
|
||||
addModalVisible.value = true;
|
||||
};
|
||||
|
||||
/** 关闭拉黑弹窗 */
|
||||
const closeAddModal = () => {
|
||||
addModalVisible.value = false;
|
||||
};
|
||||
|
||||
/** 提交拉黑 */
|
||||
const handleSubmitAdd = async (params: AiBlacklistAddParams) => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await addAiBlacklist(params);
|
||||
if (res.code == 200) {
|
||||
message.success(res.msg || '已拉黑');
|
||||
closeAddModal();
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || '拉黑失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('拉黑失败');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 解除拉黑(带确认) */
|
||||
const handleRemove = useThrottleFn((record: AiBlacklistVO) => {
|
||||
Modal.confirm({
|
||||
title: '确认解除拉黑',
|
||||
content: `确认解除用户 ${record.userId} 的拉黑状态吗?解除后该用户将恢复 AI 功能使用权限。`,
|
||||
okText: '确认解除',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await removeAiBlacklist({ userId: record.userId });
|
||||
if (res.code == 200) {
|
||||
message.success(res.msg || '已解除拉黑');
|
||||
fetchList();
|
||||
} else {
|
||||
message.error(res.msg || '解除失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('解除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
filterForm,
|
||||
loading,
|
||||
dataSource,
|
||||
pagination,
|
||||
addModalVisible,
|
||||
submitting,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handlePageChange,
|
||||
openAddModal,
|
||||
closeAddModal,
|
||||
handleSubmitAdd,
|
||||
handleRemove,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { h, type VNodeChild } from 'vue';
|
||||
import { StatusTag } from '@/components';
|
||||
import { POINTS_TXN_TYPE_MAP } from './config';
|
||||
|
||||
/**
|
||||
* AI 积分账户与流水 - 表格列配置(视图层)
|
||||
*
|
||||
* 职责:纯表格列配置,不包含业务逻辑
|
||||
*/
|
||||
|
||||
/** 渲染余额(负数=欠款,红色) */
|
||||
const renderPoints = (text: number): VNodeChild => {
|
||||
if (text < 0) {
|
||||
return h('span', { style: { color: '#f5222d', fontWeight: 600 } }, String(text));
|
||||
}
|
||||
return String(text);
|
||||
};
|
||||
|
||||
/** 渲染流水类型标签 */
|
||||
const renderTxnTypeTag = (text: number): VNodeChild => {
|
||||
const info = POINTS_TXN_TYPE_MAP[text] || { label: String(text), tone: 'default' as const };
|
||||
return h(StatusTag, { label: info.label, tone: info.tone });
|
||||
};
|
||||
|
||||
/** 渲染变更积分(正数绿色,负数红色) */
|
||||
const renderChangePoints = (text: number): VNodeChild => {
|
||||
const color = text >= 0 ? '#52c41a' : '#f5222d';
|
||||
const sign = text >= 0 ? '+' : '';
|
||||
return h('span', { style: { color, fontWeight: 600 } }, `${sign}${text}`);
|
||||
};
|
||||
|
||||
/** 渲染免费标记 */
|
||||
const renderFreeFlag = (text: number): string => (text === 1 ? '免费' : '付费');
|
||||
|
||||
/** 渲染文本兜底 */
|
||||
const renderText = (text: string): string => text || '-';
|
||||
|
||||
/** 积分账户表格列 */
|
||||
export function usePointsAccountColumns() {
|
||||
const columns = [
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
width: 140,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '当前余额',
|
||||
dataIndex: 'points',
|
||||
key: 'points',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: number }): VNodeChild => renderPoints(text),
|
||||
},
|
||||
{
|
||||
title: '累计充值',
|
||||
dataIndex: 'totalRecharged',
|
||||
key: 'totalRecharged',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '累计消费',
|
||||
dataIndex: 'totalConsumed',
|
||||
key: 'totalConsumed',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return { columns };
|
||||
}
|
||||
|
||||
/** 积分流水表格列 */
|
||||
export function usePointsTxnColumns() {
|
||||
const columns = [
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
width: 120,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 130,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '业务场景',
|
||||
dataIndex: 'bizType',
|
||||
key: 'bizType',
|
||||
width: 140,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '流水类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: number }): VNodeChild => renderTxnTypeTag(text),
|
||||
},
|
||||
{
|
||||
title: '变更积分',
|
||||
dataIndex: 'changePoints',
|
||||
key: 'changePoints',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: number }): VNodeChild => renderChangePoints(text),
|
||||
},
|
||||
{
|
||||
title: '输入Token',
|
||||
dataIndex: 'inputTokens',
|
||||
key: 'inputTokens',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '输出Token',
|
||||
dataIndex: 'outputTokens',
|
||||
key: 'outputTokens',
|
||||
width: 110,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '模型',
|
||||
dataIndex: 'model',
|
||||
key: 'model',
|
||||
width: 160,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '消息ID',
|
||||
dataIndex: 'messageId',
|
||||
key: 'messageId',
|
||||
width: 160,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '免费',
|
||||
dataIndex: 'freeFlag',
|
||||
key: 'freeFlag',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
customRender: ({ text }: { text: number }): string => renderFreeFlag(text),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'descr',
|
||||
key: 'descr',
|
||||
width: 200,
|
||||
align: 'left' as const,
|
||||
customRender: ({ text }: { text: string }): string => renderText(text),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createDate',
|
||||
key: 'createDate',
|
||||
width: 180,
|
||||
align: 'center' as const,
|
||||
},
|
||||
];
|
||||
|
||||
return { columns };
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { computed, reactive, nextTick, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useRequest } from '@/hooks/useRequest';
|
||||
import { useEffect } from '@/hooks/useEffect';
|
||||
import { usePagination, syncPaginationTotal } from '@/hooks/usePagination';
|
||||
import { useThrottleFn } from '@/hooks';
|
||||
import {
|
||||
getAiPointsAccounts,
|
||||
adjustAiPoints,
|
||||
getAiPointsTransactions,
|
||||
type AiPointsAccountVO,
|
||||
type AiPointsTxnVO,
|
||||
type AiPointsAccountQueryParams,
|
||||
type AiPointsTxnQueryParams,
|
||||
type AiPointsAdjustParams,
|
||||
type ApiResult,
|
||||
type PageData,
|
||||
} from './services';
|
||||
|
||||
/**
|
||||
* AI 积分账户与流水 - 数据模型
|
||||
*
|
||||
* 职责:状态管理 + 数据获取 + 业务逻辑(账户分页 / 流水分页 / 手工调点)
|
||||
*
|
||||
* 对接文档 §1.2:
|
||||
* - GET /admin/manager/ai/points/accounts — 账户分页
|
||||
* - POST /admin/manager/ai/points/adjust — 手工调点(充值/纠错)
|
||||
* - GET /admin/manager/ai/points/transactions — 流水分页
|
||||
*/
|
||||
export function usePointsModel() {
|
||||
const subView = ref<'account' | 'txn'>('account');
|
||||
|
||||
const accountFilter = reactive({
|
||||
userId: '' as string | number,
|
||||
});
|
||||
|
||||
const txnFilter = reactive({
|
||||
userId: '' as string | number,
|
||||
bizType: '' as string,
|
||||
type: '' as '' | number,
|
||||
date: '' as string,
|
||||
});
|
||||
|
||||
const accountPagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
|
||||
const txnPagination = usePagination({ defaultCurrent: 1, defaultPageSize: 10 });
|
||||
|
||||
const buildAccountQueryParams = (): AiPointsAccountQueryParams => {
|
||||
const params: AiPointsAccountQueryParams = {
|
||||
page: (accountPagination as any).current.value,
|
||||
size: (accountPagination as any).pageSize.value,
|
||||
};
|
||||
const uid = accountFilter.userId;
|
||||
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
|
||||
params.userId = Number(uid);
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const buildTxnQueryParams = (): AiPointsTxnQueryParams => {
|
||||
const params: AiPointsTxnQueryParams = {
|
||||
page: (txnPagination as any).current.value,
|
||||
size: (txnPagination as any).pageSize.value,
|
||||
};
|
||||
const uid = txnFilter.userId;
|
||||
if (uid !== '' && uid != null && !Number.isNaN(Number(uid))) {
|
||||
params.userId = Number(uid);
|
||||
}
|
||||
if (txnFilter.bizType) {
|
||||
params.bizType = txnFilter.bizType;
|
||||
}
|
||||
if (txnFilter.type !== '') {
|
||||
params.type = Number(txnFilter.type);
|
||||
}
|
||||
if (txnFilter.date) {
|
||||
params.date = txnFilter.date;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const {
|
||||
data: accountData,
|
||||
loading: accountLoading,
|
||||
run: fetchAccounts,
|
||||
} = useRequest<ApiResult<PageData<AiPointsAccountVO>>>(
|
||||
() => getAiPointsAccounts(buildAccountQueryParams()),
|
||||
{ refreshDeps: [], formatResult: (res) => res },
|
||||
);
|
||||
|
||||
const accountDataSource = computed<AiPointsAccountVO[]>(() => {
|
||||
const res = accountData.value;
|
||||
return res ? (res as any).data?.list || [] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncPaginationTotal(accountPagination, (accountData.value as any)?.data?.total);
|
||||
}, [accountData]);
|
||||
|
||||
const {
|
||||
data: txnData,
|
||||
loading: txnLoading,
|
||||
run: fetchTxns,
|
||||
} = useRequest<ApiResult<PageData<AiPointsTxnVO>>>(
|
||||
() => getAiPointsTransactions(buildTxnQueryParams()),
|
||||
{ refreshDeps: [], formatResult: (res) => res },
|
||||
);
|
||||
|
||||
const txnDataSource = computed<AiPointsTxnVO[]>(() => {
|
||||
const res = txnData.value;
|
||||
return res ? (res as any).data?.list || [] : [];
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
syncPaginationTotal(txnPagination, (txnData.value as any)?.data?.total);
|
||||
}, [txnData]);
|
||||
|
||||
const adjustModalVisible = ref(false);
|
||||
const adjusting = ref(false);
|
||||
/** 调点弹窗预设用户信息(从账户行点击「调点」时带入) */
|
||||
const presetUser = ref<{ userId: number; phone?: string; nickname?: string } | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
/** 账户搜索 */
|
||||
const handleAccountSearch = () => {
|
||||
accountPagination.reset();
|
||||
fetchAccounts();
|
||||
};
|
||||
|
||||
/** 账户重置 */
|
||||
const handleAccountReset = useThrottleFn(async () => {
|
||||
accountFilter.userId = '';
|
||||
accountPagination.reset();
|
||||
await fetchAccounts();
|
||||
}, 500);
|
||||
|
||||
/** 账户分页变更 */
|
||||
const handleAccountPageChange = (page: number, pageSize: number) => {
|
||||
accountPagination.setCurrent(page);
|
||||
if (pageSize !== (accountPagination as any).pageSize.value) {
|
||||
accountPagination.setPageSize(pageSize);
|
||||
}
|
||||
nextTick().then(() => fetchAccounts());
|
||||
};
|
||||
|
||||
/** 流水搜索 */
|
||||
const handleTxnSearch = () => {
|
||||
txnPagination.reset();
|
||||
fetchTxns();
|
||||
};
|
||||
|
||||
/** 流水重置 */
|
||||
const handleTxnReset = useThrottleFn(async () => {
|
||||
txnFilter.userId = '';
|
||||
txnFilter.bizType = '';
|
||||
txnFilter.type = '';
|
||||
txnFilter.date = '';
|
||||
txnPagination.reset();
|
||||
await fetchTxns();
|
||||
}, 500);
|
||||
|
||||
/** 流水分页变更 */
|
||||
const handleTxnPageChange = (page: number, pageSize: number) => {
|
||||
txnPagination.setCurrent(page);
|
||||
if (pageSize !== (txnPagination as any).pageSize.value) {
|
||||
txnPagination.setPageSize(pageSize);
|
||||
}
|
||||
nextTick().then(() => fetchTxns());
|
||||
};
|
||||
|
||||
/** 查看某用户流水(从账户行点击) */
|
||||
const handleViewTxn = (record: AiPointsAccountVO) => {
|
||||
subView.value = 'txn';
|
||||
txnFilter.userId = String(record.userId);
|
||||
txnFilter.bizType = '';
|
||||
txnFilter.type = '';
|
||||
txnFilter.date = '';
|
||||
txnPagination.reset();
|
||||
nextTick().then(() => fetchTxns());
|
||||
};
|
||||
|
||||
/** 打开调点弹窗(可预设用户信息) */
|
||||
const openAdjustModal = (user?: { userId: number; phone?: string; nickname?: string }) => {
|
||||
presetUser.value = user;
|
||||
adjustModalVisible.value = true;
|
||||
};
|
||||
|
||||
/** 关闭调点弹窗 */
|
||||
const closeAdjustModal = () => {
|
||||
adjustModalVisible.value = false;
|
||||
presetUser.value = undefined;
|
||||
};
|
||||
|
||||
/** 提交调点 */
|
||||
const handleSubmitAdjust = async (params: AiPointsAdjustParams) => {
|
||||
adjusting.value = true;
|
||||
try {
|
||||
const res = await adjustAiPoints(params);
|
||||
if (res.code == 200) {
|
||||
message.success(res.msg || '调点成功');
|
||||
closeAdjustModal();
|
||||
// 刷新当前视图数据
|
||||
if (subView.value === 'account') {
|
||||
fetchAccounts();
|
||||
} else {
|
||||
fetchTxns();
|
||||
}
|
||||
} else {
|
||||
message.error(res.msg || '调点失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('调点失败');
|
||||
} finally {
|
||||
adjusting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
subView,
|
||||
accountFilter,
|
||||
accountLoading,
|
||||
accountDataSource,
|
||||
accountPagination,
|
||||
handleAccountSearch,
|
||||
handleAccountReset,
|
||||
handleAccountPageChange,
|
||||
handleViewTxn,
|
||||
txnFilter,
|
||||
txnLoading,
|
||||
txnDataSource,
|
||||
txnPagination,
|
||||
handleTxnSearch,
|
||||
handleTxnReset,
|
||||
handleTxnPageChange,
|
||||
adjustModalVisible,
|
||||
adjusting,
|
||||
presetUser,
|
||||
openAdjustModal,
|
||||
closeAdjustModal,
|
||||
handleSubmitAdjust,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.view {
|
||||
flex-shrink: 0;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,81 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { Button, Input, Table, DatePicker, Form, Space, Select, Pagination } from 'ant-design-vue';
|
||||
import { defineComponent, ref, nextTick } from 'vue';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Table,
|
||||
DatePicker,
|
||||
Form,
|
||||
Space,
|
||||
Select,
|
||||
Pagination,
|
||||
Modal,
|
||||
} from 'ant-design-vue';
|
||||
import { useLogModel, ACTION_TYPE_OPTIONS, ACTION_TYPE_MAP } from './model/useLogModel';
|
||||
import { useContainerSize } from '@/hooks';
|
||||
import { useContainerSize, useState, useEffect } from '@/hooks';
|
||||
import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import cellStyles from './index.module.less';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
/**
|
||||
* 操作内容单元格:最多 2 行截断,溢出时显示"查看"按钮
|
||||
*/
|
||||
const LogContentCell = defineComponent({
|
||||
name: 'LogContentCell',
|
||||
props: { text: { type: String, required: true } },
|
||||
setup(props) {
|
||||
const textRef = ref<HTMLElement | null>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const checkOverflow = () => {
|
||||
const el = textRef.value;
|
||||
if (el) setOverflow(el.scrollHeight > el.clientHeight);
|
||||
};
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
useEffect(() => {
|
||||
if (!textRef.value) return;
|
||||
nextTick(checkOverflow);
|
||||
observer = new ResizeObserver(checkOverflow);
|
||||
observer.observe(textRef.value);
|
||||
return () => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
};
|
||||
}, [() => textRef.value]);
|
||||
|
||||
return () => {
|
||||
const raw = props.text;
|
||||
return (
|
||||
<div class={cellStyles.cell}>
|
||||
<div ref={textRef} class={cellStyles.text}>
|
||||
{raw}
|
||||
</div>
|
||||
{overflow.value ? (
|
||||
<span class={cellStyles.view} onClick={() => setModalOpen(true)}>
|
||||
查看
|
||||
</span>
|
||||
) : null}
|
||||
<Modal
|
||||
visible={modalOpen.value}
|
||||
title="操作内容"
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
destroyOnClose
|
||||
onCancel={() => setModalOpen(false)}
|
||||
>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', lineHeight: 1.8 }}>
|
||||
{raw}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* bodyCell 渲染
|
||||
*/
|
||||
@@ -13,6 +83,10 @@ function renderBodyCell({ column, text }: { column: any; text: any }) {
|
||||
if (column.key === 'type') {
|
||||
return <span>{ACTION_TYPE_MAP[text as number] || text || '--'}</span>;
|
||||
}
|
||||
if (column.dataIndex === 'content') {
|
||||
const raw = text || '';
|
||||
return raw ? <LogContentCell text={raw} /> : <span>--</span>;
|
||||
}
|
||||
return <span>{text || '--'}</span>;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export const ACTION_TYPE_OPTIONS = [
|
||||
{ value: '3', label: '禁用用户' },
|
||||
{ value: '4', label: '启用用户' },
|
||||
{ value: '7', label: '系统参数' },
|
||||
{ value: '8', label: '用户登录' },
|
||||
] as const;
|
||||
|
||||
/** 操作类型文案映射 */
|
||||
@@ -33,6 +34,7 @@ export const ACTION_TYPE_MAP: Record<number, string> = {
|
||||
3: '禁用用户',
|
||||
4: '启用用户',
|
||||
7: '系统参数',
|
||||
8: '用户登录',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -103,7 +105,7 @@ export function useLogModel() {
|
||||
const columns = [
|
||||
{ title: '操作类型', dataIndex: 'type', key: 'type', width: 140 },
|
||||
{ title: '操作人', dataIndex: 'nickname', key: 'nickname', width: 140 },
|
||||
{ title: '操作内容', dataIndex: 'content', key: 'content', minWidth: 360 },
|
||||
{ title: '操作内容', dataIndex: 'content', key: 'content', width: 480 },
|
||||
{ title: '操作时间', dataIndex: 'createDate', key: 'createDate', width: 180 },
|
||||
];
|
||||
|
||||
|
||||
@@ -104,13 +104,13 @@ export default defineComponent({
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="参数值" name="paramValue" rules={paramValueRules}>
|
||||
<Input
|
||||
<TextArea
|
||||
value={formData.paramValue}
|
||||
onUpdate:value={(val: any) => {
|
||||
formData.paramValue = val?.target?.value ?? val ?? '';
|
||||
}}
|
||||
placeholder="请输入参数值"
|
||||
allowClear
|
||||
rows={2}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -123,7 +123,7 @@ export default defineComponent({
|
||||
placeholder="请输入备注说明(选填,最多200字)"
|
||||
maxlength={200}
|
||||
showCount
|
||||
rows={3}
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -8,11 +8,14 @@ import pageStyles from '@/assets/styles/pageLayout.module.less';
|
||||
import cellStyles from './index.module.less';
|
||||
|
||||
/**
|
||||
* 参数值单元格:最多 2 行截断,溢出时显示"查看"按钮
|
||||
* 文本单元格:最多 2 行截断,溢出时显示"查看"按钮
|
||||
*/
|
||||
const ParamValueCell = defineComponent({
|
||||
name: 'ParamValueCell',
|
||||
props: { text: { type: String, required: true } },
|
||||
const TruncatedTextCell = defineComponent({
|
||||
name: 'TruncatedTextCell',
|
||||
props: {
|
||||
text: { type: String, required: true },
|
||||
modalTitle: { type: String, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const textRef = ref<HTMLElement | null>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
@@ -49,7 +52,7 @@ const ParamValueCell = defineComponent({
|
||||
) : null}
|
||||
<Modal
|
||||
visible={modalOpen.value}
|
||||
title="参数值"
|
||||
title={props.modalTitle}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
@@ -90,14 +93,11 @@ function renderBodyCell({
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
// 参数值列:两行截断 + 查看弹窗
|
||||
if (column.dataIndex === 'paramValue') {
|
||||
// 参数值 / 备注列:两行截断 + 查看弹窗
|
||||
if (column.dataIndex === 'paramValue' || column.dataIndex === 'remark') {
|
||||
const raw = text || '';
|
||||
return raw ? <ParamValueCell text={raw} /> : <span>--</span>;
|
||||
}
|
||||
// 备注列:空值显示 --
|
||||
if (column.dataIndex === 'remark' && !text) {
|
||||
return <span>--</span>;
|
||||
const modalTitle = column.dataIndex === 'paramValue' ? '参数值' : '备注说明';
|
||||
return raw ? <TruncatedTextCell text={raw} modalTitle={modalTitle} /> : <span>--</span>;
|
||||
}
|
||||
return <span>{text ?? '--'}</span>;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export function useParamsColumns() {
|
||||
title: '备注说明',
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
minWidth: 240,
|
||||
width: 240,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
@import url('//at.alicdn.com/t/c/font_5142813_uugy4s5x1d.css');
|
||||
|
||||
/* 全局样式 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
overscroll-behavior-y: none;
|
||||
touch-action: pan-x;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
img {
|
||||
-webkit-user-drag: none;
|
||||
user-drag: none;
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module 'china-area-data' {
|
||||
declare module 'china-area-data/data.json' {
|
||||
const data: Record<string, Record<string, string>>;
|
||||
export default data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import areaData from 'china-area-data';
|
||||
import areaData from 'china-area-data/data.json';
|
||||
|
||||
export interface CascadeOption {
|
||||
value: string;
|
||||
|
||||
+3
-3
@@ -34,8 +34,8 @@ export const REAL_NAME_REGEX = /^[\u4e00-\u9fa5A-Za-z0-9·]{1,8}$/;
|
||||
export const IMAGE_TYPE_REGEX = /^image\//;
|
||||
|
||||
/**
|
||||
* 纯数字(整数)
|
||||
* - 用于过滤非数字字符
|
||||
* 非数字字符
|
||||
* - 用于过滤 0-9 以外的字符(含小数点、字母、符号等)
|
||||
*/
|
||||
export const INTEGER_ONLY_REGEX = /\D/g;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function isImageFile(file: { type: string }): boolean {
|
||||
return IMAGE_TYPE_REGEX.test(file.type);
|
||||
}
|
||||
|
||||
/** 过滤非数字字符,只保留整数 */
|
||||
/** 只保留数字字符 */
|
||||
export function toIntegerOnly(value: unknown): string {
|
||||
return String(value ?? '').replace(INTEGER_ONLY_REGEX, '');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+15
-3
@@ -7,6 +7,12 @@ 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';
|
||||
|
||||
@@ -54,12 +60,10 @@ export default defineConfig(({ mode }) => {
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: mode !== 'production',
|
||||
// 生产构建移除 console 和注释(esbuild minify 阶段)
|
||||
minify: isProd ? 'esbuild' : false,
|
||||
chunkSizeWarningLimit: 1500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 更细粒度的分包,确保按需加载
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('ant-design-vue') || id.includes('@ant-design')) {
|
||||
@@ -68,7 +72,15 @@ export default defineConfig(({ mode }) => {
|
||||
if (id.includes('vue') || id.includes('@vue') || id.includes('vue-router')) {
|
||||
return 'vue-vendor';
|
||||
}
|
||||
// 其他第三方依赖单独打包,未使用的不会进入
|
||||
if (id.includes('echarts')) {
|
||||
return 'echarts';
|
||||
}
|
||||
if (id.includes('china-area-data')) {
|
||||
return 'area-data';
|
||||
}
|
||||
if (id.includes('big.js')) {
|
||||
return 'bigjs';
|
||||
}
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user