解决冲突
This commit is contained in:
+543
File diff suppressed because one or more lines are too long
-407
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-wN5bY_f7.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CxLKoDUI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+169
@@ -14,6 +14,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1235,6 +1236,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.2.0.tgz",
|
||||
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"printj": "~1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"adler32": "bin/adler32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.3.7.tgz",
|
||||
@@ -1299,6 +1316,28 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb/node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1308,6 +1347,34 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.14.0.tgz",
|
||||
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"commander": "~2.14.1",
|
||||
"exit-on-epipe": "~1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"codepage": "bin/codepage.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage/node_modules/commander": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.14.1.tgz",
|
||||
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.17.1.tgz",
|
||||
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||
@@ -1327,6 +1394,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1349,6 +1428,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1367,6 +1455,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.3.11.tgz",
|
||||
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1726,6 +1829,18 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/printj": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/printj/-/printj-1.1.2.tgz",
|
||||
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"printj": "bin/printj.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
@@ -1863,6 +1978,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||
@@ -2001,6 +2128,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
|
||||
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.2.0",
|
||||
"cfb": "^1.1.4",
|
||||
"codepage": "~1.14.0",
|
||||
"commander": "~2.17.1",
|
||||
"crc-32": "~1.2.0",
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"fflate": "^0.3.8",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.13",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
|
||||
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
|
||||
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
||||
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
||||
} from '../types';
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
@@ -99,14 +100,26 @@ export async function getAdminStats(startDate?: string, endDate?: string): Promi
|
||||
return api.get(`/admin/stats${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
export async function getAdminUsers(page = 1, pageSize = 20, search?: string): Promise<{ items: AdminUser[]; total: number }> {
|
||||
export async function getAdminUsers(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search?: string,
|
||||
userType?: string,
|
||||
frontendUserKind?: string,
|
||||
): Promise<{ items: AdminUser[]; total: number }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
if (search) params.set('search', search);
|
||||
if (userType) params.set('user_type', userType);
|
||||
if (frontendUserKind) params.set('frontend_user_kind', frontendUserKind);
|
||||
return api.get(`/admin/users?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function updateFrontendUserKind(userId: string, frontendUserKind: 'internal' | 'external'): Promise<AdminUser> {
|
||||
return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind });
|
||||
}
|
||||
|
||||
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
||||
}
|
||||
@@ -171,19 +184,27 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getCreditRecords(filters?: {
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
type?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<any> {
|
||||
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
||||
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
||||
}
|
||||
|
||||
export async function getCreditRecords(filters?: AdminCreditRecordQueryParams): Promise<AdminCreditRecordListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.user_id) params.set('user_id', filters.user_id);
|
||||
if (filters?.user_name) params.set('user_name', filters.user_name);
|
||||
if (filters?.type) params.set('type', filters.type);
|
||||
if (filters?.start_date) params.set('start_date', filters.start_date);
|
||||
if (filters?.end_date) params.set('end_date', filters.end_date);
|
||||
setMaybe(params, 'page', filters?.page);
|
||||
setMaybe(params, 'page_size', filters?.pageSize);
|
||||
setMaybe(params, 'user_id', filters?.userId);
|
||||
setMaybe(params, 'user_name', filters?.userName);
|
||||
setMaybe(params, 'user_type', filters?.userType);
|
||||
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
|
||||
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
|
||||
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
||||
setMaybe(params, 'media_type', filters?.mediaType);
|
||||
setMaybe(params, 'charge_kind', filters?.chargeKind);
|
||||
setMaybe(params, 'source_module', filters?.sourceModule);
|
||||
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
|
||||
setMaybe(params, 'billing_scene', filters?.billingScene);
|
||||
setMaybe(params, 'start_date', filters?.startDate);
|
||||
setMaybe(params, 'end_date', filters?.endDate);
|
||||
const q = params.toString() ? `?${params}` : '';
|
||||
return api.get(`/admin/credit-records${q}`);
|
||||
}
|
||||
@@ -324,7 +345,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
|
||||
// ── User Creation ───────────────────────────────────────
|
||||
|
||||
export async function createUser(data: { username: string; password: string; email?: string; phone?: string; credits: number; user_type: string; allowed_menus?: string[] | null }): Promise<any> {
|
||||
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null }): Promise<any> {
|
||||
return api.post('/admin/users', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,202 +1,358 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
|
||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
|
||||
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getCreditRecords } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||
import dayjs from 'dayjs';
|
||||
import { getCreditRecords } from '../api';
|
||||
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface CreditRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const CREDIT_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '退回', color: 'blue', icon: <RollbackOutlined /> },
|
||||
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||
totalRecharge: 0,
|
||||
totalConsume: 0,
|
||||
totalRefund: 0,
|
||||
transactionCount: 0,
|
||||
generationCount: 0,
|
||||
generationAttemptCount: 0,
|
||||
imageGenerationCount: 0,
|
||||
videoGenerationCount: 0,
|
||||
imageConsume: 0,
|
||||
videoConsume: 0,
|
||||
textConsume: 0,
|
||||
analysisConsume: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
|
||||
};
|
||||
|
||||
const userScopeOptions = [
|
||||
{ value: '', label: '全部用户' },
|
||||
{ value: 'admin', label: '后台用户' },
|
||||
{ value: 'frontend_internal', label: '前台内部用户' },
|
||||
{ value: 'frontend_external', label: '前台外部用户' },
|
||||
];
|
||||
|
||||
const recordTypeOptions = [
|
||||
{ value: '', label: '全部流水' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
];
|
||||
|
||||
const creditSubjectOptions = [
|
||||
{ value: '', label: '全部积分类型' },
|
||||
{ value: 'media', label: '图片/视频生成积分' },
|
||||
{ value: 'text', label: '提词优化积分' },
|
||||
{ value: 'module', label: '模块功能积分' },
|
||||
{ value: 'analysis', label: '分析积分' },
|
||||
{ value: 'split', label: '切片积分' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'recharge', label: '充值积分' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
const mediaTypeOptions = [
|
||||
{ value: '', label: '全部媒体' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
];
|
||||
|
||||
const chargeKindOptions = [
|
||||
{ value: '', label: '全部扣费子类' },
|
||||
{ value: 'media', label: '媒体生成' },
|
||||
{ value: 'text_prompt', label: '提词优化' },
|
||||
{ value: 'file_parse', label: '文件解析' },
|
||||
{ value: 'vision_input', label: '图片理解' },
|
||||
{ value: 'module_create', label: '创建模块项目' },
|
||||
{ value: 'video_analysis', label: '视频分析' },
|
||||
{ value: 'video_split', label: '视频切片' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
];
|
||||
|
||||
const sourceModuleOptions = [
|
||||
{ value: '', label: '全部模块' },
|
||||
{ value: 'ai_creation', label: 'AI创作' },
|
||||
{ value: 'generation_record', label: '项目记录' },
|
||||
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
|
||||
{ value: 'shot_replicate', label: '拆镜复刻' },
|
||||
{ value: 'admin', label: '后台管理' },
|
||||
{ value: 'payment', label: '支付充值' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
const sourceStepOptions = [
|
||||
{ value: '', label: '全部步骤' },
|
||||
{ value: 'image_prompt_optimize', label: '图片提词优化' },
|
||||
{ value: 'image_generate', label: '图片生成' },
|
||||
{ value: 'video_prompt_optimize', label: '视频提词优化' },
|
||||
{ value: 'video_generate', label: '视频生成' },
|
||||
{ value: 'video_analysis', label: '视频分析' },
|
||||
];
|
||||
|
||||
const billingSceneOptions = [
|
||||
{ value: '', label: '全部计费场景' },
|
||||
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
|
||||
{ value: 'ai_creation_video_generate', label: 'AI创作视频生成' },
|
||||
{ value: 'generation_record_text_prompt_optimize', label: '项目记录提词优化' },
|
||||
{ value: 'generation_record_image_generate', label: '项目记录图片生成' },
|
||||
{ value: 'generation_record_video_generate', label: '项目记录视频生成' },
|
||||
{ value: 'generation_record_file_parse', label: '项目记录文件解析' },
|
||||
{ value: 'generation_record_vision_input', label: '项目记录图片理解' },
|
||||
{ value: 'hot_opening_project_create', label: '爆款开头复刻创建项目' },
|
||||
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻图片提词优化' },
|
||||
{ value: 'hot_opening_image_generate', label: '爆款开头复刻图片生成' },
|
||||
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻视频提词优化' },
|
||||
{ value: 'hot_opening_video_generate', label: '爆款开头复刻视频生成' },
|
||||
{ value: 'shot_video_analysis', label: '拆镜视频分析' },
|
||||
{ value: 'shot_original_video_analysis', label: '拆镜复刻原视频分析' },
|
||||
{ value: 'shot_segment_video_analysis', label: '拆镜复刻片段视频分析' },
|
||||
{ value: 'shot_video_split', label: '拆镜切片' },
|
||||
{ value: 'shot_segment_replicate_create', label: '从切片创建复刻项目' },
|
||||
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻图片提词优化' },
|
||||
{ value: 'shot_image_generate', label: '拆镜复刻图片生成' },
|
||||
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻视频提词优化' },
|
||||
{ value: 'shot_video_generate', label: '拆镜复刻视频生成' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
function n(value: number | undefined | null): string {
|
||||
return Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
function engineTypeLabel(type?: string): string {
|
||||
if (type === 'model') return '提词/分析模型';
|
||||
if (type === 'image') return '图片引擎';
|
||||
if (type === 'video') return '视频引擎';
|
||||
return '执行配置';
|
||||
}
|
||||
|
||||
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
||||
if (scope === 'admin') return { userType: 'admin' };
|
||||
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
|
||||
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
|
||||
return {};
|
||||
}
|
||||
|
||||
const AdminCreditRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<CreditRecord[]>([]);
|
||||
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
||||
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [userNameFilter, setUserNameFilter] = useState<string>('');
|
||||
|
||||
const [userScope, setUserScope] = useState('');
|
||||
const [recordType, setRecordType] = useState('');
|
||||
const [creditSubject, setCreditSubject] = useState('');
|
||||
const [mediaType, setMediaType] = useState('');
|
||||
const [chargeKind, setChargeKind] = useState('');
|
||||
const [sourceModule, setSourceModule] = useState('');
|
||||
const [sourceStepCode, setSourceStepCode] = useState('');
|
||||
const [billingScene, setBillingScene] = useState('');
|
||||
const [userNameFilter, setUserNameFilter] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||
|
||||
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
||||
page,
|
||||
pageSize,
|
||||
userName: userNameFilter || undefined,
|
||||
recordType: recordType || undefined,
|
||||
creditSubject: creditSubject || undefined,
|
||||
mediaType: mediaType || undefined,
|
||||
chargeKind: chargeKind || undefined,
|
||||
sourceModule: sourceModule || undefined,
|
||||
sourceStepCode: sourceStepCode || undefined,
|
||||
billingScene: billingScene || undefined,
|
||||
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||
...buildScope(userScope),
|
||||
}), [page, pageSize, userNameFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string; page?: number; page_size?: number } = {};
|
||||
if (typeFilter) filters.type = typeFilter;
|
||||
if (userNameFilter) filters.user_name = userNameFilter;
|
||||
if (dateRange[0]) filters.start_date = dateRange[0].format('YYYY-MM-DD');
|
||||
if (dateRange[1]) filters.end_date = dateRange[1].format('YYYY-MM-DD');
|
||||
filters.page = page;
|
||||
filters.page_size = pageSize;
|
||||
const res = await getCreditRecords(Object.keys(filters).length > 0 ? filters : undefined);
|
||||
const res = await getCreditRecords(query);
|
||||
setRecords(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
message.error('加载积分记录失败');
|
||||
setSummary(res.summary || DEFAULT_SUMMARY);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载积分记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, pageSize, typeFilter, userNameFilter, dateRange]);
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
load();
|
||||
};
|
||||
useEffect(() => { load(); }, [query]);
|
||||
|
||||
const handleReset = () => {
|
||||
setTypeFilter('');
|
||||
setUserScope('');
|
||||
setRecordType('');
|
||||
setCreditSubject('');
|
||||
setMediaType('');
|
||||
setChargeKind('');
|
||||
setSourceModule('');
|
||||
setSourceStepCode('');
|
||||
setBillingScene('');
|
||||
setUserNameFilter('');
|
||||
setDateRange([null, null]);
|
||||
load();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
|
||||
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
|
||||
const exportExcel = async () => {
|
||||
setExporting(true);
|
||||
setExportProgress('准备导出...');
|
||||
try {
|
||||
const exportPageSize = 500;
|
||||
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
|
||||
const first = await getCreditRecords(baseQuery);
|
||||
const all: AdminCreditRecord[] = [...(first.items || [])];
|
||||
const exportSummary = first.summary || DEFAULT_SUMMARY;
|
||||
const totalRows = first.total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
|
||||
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
|
||||
for (let p = 2; p <= totalPages; p += 1) {
|
||||
const res = await getCreditRecords({ ...baseQuery, page: p });
|
||||
all.push(...(res.items || []));
|
||||
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
|
||||
}
|
||||
|
||||
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
|
||||
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
|
||||
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
|
||||
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
|
||||
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
||||
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
||||
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||||
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
||||
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
|
||||
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
|
||||
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
|
||||
{ title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' },
|
||||
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
|
||||
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
|
||||
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
|
||||
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
|
||||
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
|
||||
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
|
||||
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
|
||||
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
|
||||
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
|
||||
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
|
||||
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
|
||||
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
|
||||
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
|
||||
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
|
||||
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
|
||||
];
|
||||
|
||||
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
|
||||
exportStyledExcel<AdminCreditRecord>({
|
||||
filename,
|
||||
sheetName: '积分流水',
|
||||
title: '积分流水汇总',
|
||||
metadataRows: [
|
||||
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
|
||||
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
||||
['导出条数', totalRows],
|
||||
],
|
||||
summaryRows: [
|
||||
['总充值', exportSummary.totalRecharge],
|
||||
['总消费', exportSummary.totalConsume],
|
||||
['总回退', exportSummary.totalRefund],
|
||||
['交易笔数', exportSummary.transactionCount],
|
||||
['生成条数', exportSummary.generationCount],
|
||||
['生成尝试次数', exportSummary.generationAttemptCount],
|
||||
['图片生成条数', exportSummary.imageGenerationCount],
|
||||
['视频生成条数', exportSummary.videoGenerationCount],
|
||||
['图片消费积分', exportSummary.imageConsume],
|
||||
['视频消费积分', exportSummary.videoConsume],
|
||||
['提词消费积分', exportSummary.textConsume],
|
||||
['视频分析积分', exportSummary.analysisConsume],
|
||||
['总 Token', exportSummary.totalTokens],
|
||||
['输入 Token', exportSummary.inputTokens],
|
||||
['输出 Token', exportSummary.outputTokens],
|
||||
],
|
||||
columns: detailColumns,
|
||||
rows: all,
|
||||
});
|
||||
message.success('Excel 已导出');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
setExportProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户', dataIndex: 'username', width: 120,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
|
||||
return (
|
||||
<Tag color={cfg.color} icon={cfg.icon}>
|
||||
{cfg.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{v > 0 ? '+' : ''}{v.toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
|
||||
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '说明', dataIndex: 'description', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
||||
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
||||
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
||||
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
|
||||
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
||||
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
|
||||
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
|
||||
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}>入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}</div></div> },
|
||||
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
|
||||
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
||||
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
<Card variant="outlined" style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#10b981',
|
||||
}}><ArrowUpOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总充值</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="outlined" style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#ef4444',
|
||||
}}><ArrowDownOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总消费</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="outlined" style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}><WalletOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>交易笔数</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
||||
</div>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card size="small" bordered={false}>图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>提词消费:{n(summary.textConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>视频分析:{n(summary.analysisConsume)} 积分</Card>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(value) => setTypeFilter(value)}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '退回' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户名搜索"
|
||||
value={userNameFilter}
|
||||
onChange={(e) => setUserNameFilter(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||||
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
||||
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
|
||||
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
|
||||
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
|
||||
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
|
||||
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}>下载 Excel</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
@@ -204,13 +360,13 @@ const AdminCreditRecords: React.FC = () => {
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
scroll={{ x: 1900 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import {
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig } from '../api';
|
||||
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig, updateFrontendUserKind } from '../api';
|
||||
import type { AdminUser, SystemConfig } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
@@ -14,6 +14,7 @@ const AdminUsers: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('frontend');
|
||||
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
|
||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [createType, setCreateType] = useState<string>('frontend');
|
||||
@@ -36,14 +37,14 @@ const AdminUsers: React.FC = () => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminUsers(page, pageSize, search || undefined);
|
||||
const data = await getAdminUsers(page, pageSize, search || undefined, activeTab, activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined);
|
||||
setUsers(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch { /* auth error handled by client */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, pageSize, search]);
|
||||
useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCreditConfigs = async () => {
|
||||
@@ -116,6 +117,7 @@ const AdminUsers: React.FC = () => {
|
||||
phone: userType === 'frontend' ? values.phone : (values.phone || undefined),
|
||||
credits: values.credits || 0,
|
||||
user_type: userType,
|
||||
frontend_user_kind: values.frontend_user_kind || 'external',
|
||||
});
|
||||
message.success('用户创建成功');
|
||||
setCreateModal(false);
|
||||
@@ -176,7 +178,16 @@ const AdminUsers: React.FC = () => {
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u => u.userType === activeTab);
|
||||
const handleUpdateFrontendKind = async (user: AdminUser, kind: 'internal' | 'external') => {
|
||||
try {
|
||||
await updateFrontendUserKind(user.id, kind);
|
||||
message.success(kind === 'internal' ? '已设为前台内部用户' : '已设为前台外部用户');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '设置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isAdminTab = activeTab === 'admin';
|
||||
|
||||
const columns = [
|
||||
@@ -214,6 +225,10 @@ const AdminUsers: React.FC = () => {
|
||||
title: '手机号', dataIndex: 'phone', width: 130,
|
||||
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
|
||||
},
|
||||
...(!isAdminTab ? [{
|
||||
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
|
||||
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
|
||||
}] : []),
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => (
|
||||
@@ -238,6 +253,12 @@ const AdminUsers: React.FC = () => {
|
||||
调整积分
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && r.frontendUserKind !== 'internal' && (
|
||||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}>设为内部</Button>
|
||||
)}
|
||||
{!isAdminTab && r.frontendUserKind === 'internal' && (
|
||||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}>取消内部</Button>
|
||||
)}
|
||||
<Button type="link" size="small" icon={<MenuOutlined />}
|
||||
onClick={() => openMenuModal(r)}>
|
||||
菜单权限
|
||||
@@ -329,6 +350,18 @@ const AdminUsers: React.FC = () => {
|
||||
style={{ width: 280, borderRadius: 8 }}
|
||||
allowClear
|
||||
/>
|
||||
{!isAdminTab && (
|
||||
<Select
|
||||
value={frontendKindFilter}
|
||||
onChange={(v) => { setPage(1); setFrontendKindFilter(v); }}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: '', label: '全部前台用户' },
|
||||
{ value: 'internal', label: '内部用户' },
|
||||
{ value: 'external', label: '外部用户' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />}
|
||||
@@ -340,7 +373,7 @@ const AdminUsers: React.FC = () => {
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setPage(1); }}
|
||||
items={[
|
||||
{ key: 'frontend', label: '前台用户' },
|
||||
{ key: 'admin', label: '后台用户' },
|
||||
@@ -349,7 +382,7 @@ const AdminUsers: React.FC = () => {
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredUsers}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
@@ -412,6 +445,14 @@ const AdminUsers: React.FC = () => {
|
||||
{ value: 'admin', label: '后台管理员' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
{createType === 'frontend' && (
|
||||
<Form.Item name="frontend_user_kind" label="前台归类" initialValue="external">
|
||||
<Select size="large" options={[
|
||||
{ value: 'external', label: '外部用户' },
|
||||
{ value: 'internal', label: '内部用户' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{createType === 'frontend' ? (
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||
<Input placeholder="请输入手机号" maxLength={11} size="large" />
|
||||
|
||||
@@ -18,6 +18,26 @@ interface OpenType {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const normalizeUploadError = (e: unknown): Error => {
|
||||
if (e instanceof Error) {
|
||||
return e;
|
||||
}
|
||||
|
||||
if (typeof e === 'string') {
|
||||
return new Error(e);
|
||||
}
|
||||
|
||||
return new Error('上传失败');
|
||||
};
|
||||
|
||||
const assertUploadFile = (file: unknown): File => {
|
||||
if (file instanceof File) {
|
||||
return file;
|
||||
}
|
||||
|
||||
throw new Error('请选择有效图片文件');
|
||||
};
|
||||
|
||||
const AdminPlatform: React.FC = () => {
|
||||
const [openTypes, setOpenTypes] = useState<OpenType[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -49,7 +69,9 @@ const AdminPlatform: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
@@ -137,53 +159,89 @@ const AdminPlatform: React.FC = () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID', dataIndex: 'id', width: 100,
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
width: 100,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '开户类型', dataIndex: 'open_type', width: 100,
|
||||
title: '开户类型',
|
||||
dataIndex: 'open_type',
|
||||
width: 100,
|
||||
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型名称', dataIndex: 'type_name', width: 150,
|
||||
title: '类型名称',
|
||||
dataIndex: 'type_name',
|
||||
width: 150,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '描述', dataIndex: 'description', width: 250, ellipsis: true,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{v}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '缩略图', dataIndex: 'thumb', width: 120,
|
||||
render: (v: string) => v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-',
|
||||
title: '缩略图',
|
||||
dataIndex: 'thumb',
|
||||
width: 120,
|
||||
render: (v: string) => (
|
||||
v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDate(v)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间', dataIndex: 'updatedAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDate(v)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 200,
|
||||
title: '操作',
|
||||
width: 200,
|
||||
render: (_: any, record: OpenType) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<EyeOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDetail(record.id)}
|
||||
>详情</Button>
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleUpdate(record.id)}
|
||||
>更新</Button>
|
||||
>
|
||||
更新
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>删除</Button>
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -198,8 +256,12 @@ const AdminPlatform: React.FC = () => {
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>平台开户方式管理</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>新增</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
|
||||
新增
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
@@ -212,7 +274,11 @@ const AdminPlatform: React.FC = () => {
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); load(p, ps); },
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
load(p, ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
@@ -231,7 +297,6 @@ const AdminPlatform: React.FC = () => {
|
||||
width={600}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
@@ -245,12 +310,15 @@ const AdminPlatform: React.FC = () => {
|
||||
name="type_name"
|
||||
label="类型名称"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入类型名称' }, { max: 100, message: '类型名称不能超过100个字符' }]}
|
||||
rules={[
|
||||
{ required: true, message: '请输入类型名称' },
|
||||
{ max: 100, message: '类型名称不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: '100%' }} placeholder="请输入类型名称" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
@@ -266,12 +334,13 @@ const AdminPlatform: React.FC = () => {
|
||||
listType="picture-card"
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const res = await uploadImage(file as File);
|
||||
const uploadFile = assertUploadFile(file);
|
||||
const res = await uploadImage(uploadFile);
|
||||
console.log(res);
|
||||
createForm.setFieldsValue({ thumb: res.url });
|
||||
onSuccess(res);
|
||||
} catch (e: any) {
|
||||
onError(e);
|
||||
onSuccess?.(res);
|
||||
} catch (e) {
|
||||
onError?.(normalizeUploadError(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -301,7 +370,16 @@ const AdminPlatform: React.FC = () => {
|
||||
<p><strong>开户类型:</strong> {currentOpenType.open_type}</p>
|
||||
<p><strong>类型名称:</strong> {currentOpenType.type_name}</p>
|
||||
<p><strong>描述:</strong> {currentOpenType.description}</p>
|
||||
<p><strong>缩略图:</strong> {currentOpenType.thumb ? <img src={currentOpenType.thumb} alt="thumb" style={{ width: 120, height: 80, objectFit: 'cover' }} /> : '-'}</p>
|
||||
<p>
|
||||
<strong>缩略图:</strong>{' '}
|
||||
{currentOpenType.thumb ? (
|
||||
<img
|
||||
src={currentOpenType.thumb}
|
||||
alt="thumb"
|
||||
style={{ width: 120, height: 80, objectFit: 'cover' }}
|
||||
/>
|
||||
) : '-'}
|
||||
</p>
|
||||
<p><strong>创建时间:</strong> {formatDate(currentOpenType.createdAt)}</p>
|
||||
<p><strong>更新时间:</strong> {formatDate(currentOpenType.updatedAt)}</p>
|
||||
</div>
|
||||
@@ -332,7 +410,10 @@ const AdminPlatform: React.FC = () => {
|
||||
<Form.Item
|
||||
name="type_name"
|
||||
label="类型名称"
|
||||
rules={[{ required: true, message: '请输入类型名称' }, { max: 100, message: '类型名称不能超过100个字符' }]}
|
||||
rules={[
|
||||
{ required: true, message: '请输入类型名称' },
|
||||
{ max: 100, message: '类型名称不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入类型名称" />
|
||||
</Form.Item>
|
||||
@@ -349,14 +430,19 @@ const AdminPlatform: React.FC = () => {
|
||||
>
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
defaultFileList={currentOpenType?.thumb ? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }] : []}
|
||||
defaultFileList={
|
||||
currentOpenType?.thumb
|
||||
? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }]
|
||||
: []
|
||||
}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const res = await uploadImage(file);
|
||||
const uploadFile = assertUploadFile(file);
|
||||
const res = await uploadImage(uploadFile);
|
||||
updateForm.setFieldsValue({ thumb: res.url });
|
||||
onSuccess(res);
|
||||
} catch (e: any) {
|
||||
onError(e);
|
||||
onSuccess?.(res);
|
||||
} catch (e) {
|
||||
onError?.(normalizeUploadError(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -374,4 +460,4 @@ const AdminPlatform: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPlatform;
|
||||
export default AdminPlatform;
|
||||
@@ -104,6 +104,7 @@ export interface AdminUser {
|
||||
isActive: boolean;
|
||||
isAdmin: boolean;
|
||||
userType: string;
|
||||
frontendUserKind: 'internal' | 'external';
|
||||
createdAt: string;
|
||||
lastLoginAt?: string;
|
||||
allowedMenus?: string[] | null;
|
||||
@@ -278,6 +279,7 @@ export interface GenerationAIEngineSnapshot {
|
||||
name?: string;
|
||||
provider?: string;
|
||||
modelName?: string;
|
||||
model?: string;
|
||||
supportedModels?: string[];
|
||||
defaultSize?: string;
|
||||
selectedSize?: string;
|
||||
@@ -673,3 +675,98 @@ export interface VideoPromptSchemaPreviewOut {
|
||||
runtimeSchema: Record<string, any>;
|
||||
timePlan: Record<string, any>[];
|
||||
}
|
||||
|
||||
|
||||
export interface AdminCreditRecordSummary {
|
||||
totalRecharge: number;
|
||||
totalConsume: number;
|
||||
totalRefund: number;
|
||||
transactionCount: number;
|
||||
generationCount: number;
|
||||
generationAttemptCount: number;
|
||||
imageGenerationCount: number;
|
||||
videoGenerationCount: number;
|
||||
imageConsume: number;
|
||||
videoConsume: number;
|
||||
textConsume: number;
|
||||
analysisConsume: number;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface AdminCreditRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
userType?: string;
|
||||
userTypeLabel?: string;
|
||||
frontendUserKind?: string;
|
||||
frontendUserKindLabel?: string;
|
||||
type: string;
|
||||
recordType: string;
|
||||
recordTypeLabel?: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
description?: string;
|
||||
relatedId?: string;
|
||||
bizKey?: string;
|
||||
refundForBizKey?: string;
|
||||
ownerType?: string;
|
||||
ownerId?: string;
|
||||
ownerDeleted?: boolean;
|
||||
ownerDeletedAt?: string;
|
||||
attemptNo?: number;
|
||||
chargeKind?: string;
|
||||
chargeKindLabel?: string;
|
||||
chargeAction?: string;
|
||||
creditSubject?: string;
|
||||
creditSubjectLabel?: string;
|
||||
mediaType?: string;
|
||||
mediaTypeLabel?: string;
|
||||
billingScene?: string;
|
||||
billingSceneLabel?: string;
|
||||
sourceModule?: string;
|
||||
sourceModuleLabel?: string;
|
||||
sourceProjectId?: string;
|
||||
sourceStepId?: string;
|
||||
sourceStepCode?: string;
|
||||
sourceStepCodeLabel?: string;
|
||||
tokenUsageId?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
engineType?: string;
|
||||
engineId?: string;
|
||||
engineName?: string;
|
||||
engineProvider?: string;
|
||||
engineModelName?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface AdminCreditRecordListResponse {
|
||||
items: AdminCreditRecord[];
|
||||
total: number;
|
||||
summary: AdminCreditRecordSummary;
|
||||
}
|
||||
|
||||
export interface AdminCreditRecordQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userType?: string;
|
||||
frontendUserKind?: string;
|
||||
recordType?: string;
|
||||
type?: string;
|
||||
creditSubject?: string;
|
||||
mediaType?: string;
|
||||
chargeKind?: string;
|
||||
sourceModule?: string;
|
||||
sourceStepCode?: string;
|
||||
billingScene?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module 'xlsx-js-style' {
|
||||
export const utils: any;
|
||||
export function writeFile(workbook: any, filename: string, options?: any): void;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as XLSX from 'xlsx-js-style';
|
||||
|
||||
export type ExcelCellValue = string | number | boolean | Date | null | undefined;
|
||||
|
||||
export interface StyledExcelColumn<T = any> {
|
||||
title: string;
|
||||
key?: string;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
numFmt?: string;
|
||||
render: (row: T, rowIndex: number) => ExcelCellValue;
|
||||
}
|
||||
|
||||
export interface StyledExcelExportOptions<T = any> {
|
||||
filename: string;
|
||||
sheetName: string;
|
||||
title?: string;
|
||||
metadataRows?: ExcelCellValue[][];
|
||||
summaryRows?: ExcelCellValue[][];
|
||||
columns: StyledExcelColumn<T>[];
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
const BLACK_BORDER = {
|
||||
top: { style: 'thin', color: { rgb: '000000' } },
|
||||
right: { style: 'thin', color: { rgb: '000000' } },
|
||||
bottom: { style: 'thin', color: { rgb: '000000' } },
|
||||
left: { style: 'thin', color: { rgb: '000000' } },
|
||||
};
|
||||
|
||||
const BASE_ALIGNMENT = {
|
||||
vertical: 'center',
|
||||
wrapText: true,
|
||||
};
|
||||
|
||||
const TITLE_STYLE = {
|
||||
font: { bold: true, sz: 16, color: { rgb: 'FFFFFF' } },
|
||||
fill: { fgColor: { rgb: '111827' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const SECTION_LABEL_STYLE = {
|
||||
font: { bold: true, color: { rgb: '111827' } },
|
||||
fill: { fgColor: { rgb: 'F3F4F6' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const SECTION_VALUE_STYLE = {
|
||||
font: { color: { rgb: '111827' } },
|
||||
fill: { fgColor: { rgb: 'FFFFFF' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'left' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const HEADER_STYLE = {
|
||||
font: { bold: true, color: { rgb: 'FFFFFF' } },
|
||||
fill: { fgColor: { rgb: '374151' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const BODY_STYLE = {
|
||||
font: { color: { rgb: '111827' } },
|
||||
fill: { fgColor: { rgb: 'FFFFFF' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'left' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
function normalizeCellValue(value: ExcelCellValue): ExcelCellValue {
|
||||
if (value === null || value === undefined) return '';
|
||||
return value;
|
||||
}
|
||||
|
||||
function visualLength(value: ExcelCellValue): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
const text = value instanceof Date ? value.toISOString() : String(value);
|
||||
const lines = text.split(/\r?\n/);
|
||||
return Math.max(...lines.map((line) => Array.from(line).reduce((len, ch) => len + (ch.charCodeAt(0) > 255 ? 2 : 1), 0)), 0);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function estimateColumnWidths(rows: ExcelCellValue[][], columns: StyledExcelColumn[]): { wch: number }[] {
|
||||
const totalColumns = Math.max(columns.length, ...rows.map((row) => row.length));
|
||||
return Array.from({ length: totalColumns }, (_, index) => {
|
||||
const config = columns[index];
|
||||
if (config?.width) return { wch: config.width };
|
||||
|
||||
const maxLength = rows.reduce<number>((max, row) => Math.max(max, visualLength(row[index])), visualLength(config?.title));
|
||||
const minWidth = config?.minWidth ?? 10;
|
||||
const maxWidth = config?.maxWidth ?? 42;
|
||||
return { wch: clamp(Math.ceil(maxLength * 1.15) + 2, minWidth, maxWidth) };
|
||||
});
|
||||
}
|
||||
|
||||
function estimateRowHeight(row: ExcelCellValue[], colWidths: { wch: number }[], baseHeight = 20): number {
|
||||
const maxLines = row.reduce<number>((max, cell, index) => {
|
||||
const width = Math.max(8, colWidths[index]?.wch || 12);
|
||||
const text = cell === null || cell === undefined ? '' : String(cell);
|
||||
const explicitLines = text.split(/\r?\n/);
|
||||
const wrappedLines = explicitLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visualLength(line) / Math.max(8, width - 2))), 0);
|
||||
return Math.max(max, wrappedLines);
|
||||
}, 1);
|
||||
|
||||
return clamp(baseHeight + (maxLines - 1) * 16, baseHeight, 120);
|
||||
}
|
||||
|
||||
function applyCellStyle(cell: any, style: any, numFmt?: string): void {
|
||||
cell.s = {
|
||||
...style,
|
||||
alignment: { ...style.alignment },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
if (numFmt) cell.z = numFmt;
|
||||
}
|
||||
|
||||
function isNumericCell(value: ExcelCellValue): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function exportStyledExcel<T>(options: StyledExcelExportOptions<T>): void {
|
||||
const { filename, sheetName, title, metadataRows = [], summaryRows = [], columns, rows } = options;
|
||||
const totalColumns = Math.max(1, columns.length);
|
||||
const aoa: ExcelCellValue[][] = [];
|
||||
let titleRowIndex = -1;
|
||||
let headerRowIndex = -1;
|
||||
|
||||
if (title) {
|
||||
titleRowIndex = aoa.length;
|
||||
aoa.push([title, ...Array.from({ length: totalColumns - 1 }, () => '')]);
|
||||
}
|
||||
|
||||
metadataRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
|
||||
summaryRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
|
||||
|
||||
if (metadataRows.length || summaryRows.length) {
|
||||
aoa.push(Array.from({ length: totalColumns }, () => ''));
|
||||
}
|
||||
|
||||
headerRowIndex = aoa.length;
|
||||
aoa.push(columns.map((column) => column.title));
|
||||
rows.forEach((row, rowIndex) => {
|
||||
aoa.push(columns.map((column) => normalizeCellValue(column.render(row, rowIndex))));
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(aoa);
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
|
||||
const colWidths = estimateColumnWidths(aoa, columns);
|
||||
|
||||
worksheet['!cols'] = colWidths;
|
||||
worksheet['!rows'] = aoa.map((row, index) => {
|
||||
if (index === titleRowIndex) return { hpt: 34 };
|
||||
if (index === headerRowIndex) return { hpt: 28 };
|
||||
if (row.every((cell) => cell === '')) return { hpt: 10 };
|
||||
return { hpt: estimateRowHeight(row, colWidths) };
|
||||
});
|
||||
|
||||
if (titleRowIndex >= 0 && totalColumns > 1) {
|
||||
worksheet['!merges'] = worksheet['!merges'] || [];
|
||||
worksheet['!merges'].push({
|
||||
s: { r: titleRowIndex, c: 0 },
|
||||
e: { r: titleRowIndex, c: totalColumns - 1 },
|
||||
});
|
||||
}
|
||||
|
||||
worksheet['!autofilter'] = {
|
||||
ref: XLSX.utils.encode_range({
|
||||
s: { r: headerRowIndex, c: 0 },
|
||||
e: { r: Math.max(headerRowIndex, aoa.length - 1), c: totalColumns - 1 },
|
||||
}),
|
||||
};
|
||||
|
||||
for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) {
|
||||
for (let colIndex = range.s.c; colIndex <= range.e.c; colIndex += 1) {
|
||||
const address = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex });
|
||||
const cell = worksheet[address] || { t: 's', v: '' };
|
||||
worksheet[address] = cell;
|
||||
|
||||
if (rowIndex === titleRowIndex) {
|
||||
applyCellStyle(cell, TITLE_STYLE);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rowIndex === headerRowIndex) {
|
||||
applyCellStyle(cell, HEADER_STYLE);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rowIndex < headerRowIndex) {
|
||||
applyCellStyle(cell, colIndex === 0 ? SECTION_LABEL_STYLE : SECTION_VALUE_STYLE, isNumericCell(cell.v) ? '#,##0.00' : undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
const column = columns[colIndex];
|
||||
const align = column?.align || (isNumericCell(cell.v) ? 'right' : 'left');
|
||||
applyCellStyle(cell, {
|
||||
...BODY_STYLE,
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: align },
|
||||
}, column?.numFmt || (isNumericCell(cell.v) ? '#,##0.00' : undefined));
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
XLSX.writeFile(workbook, filename);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
Reference in New Issue
Block a user