600 lines
35 KiB
TypeScript
600 lines
35 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||
} from 'antd';
|
||
import {
|
||
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||
} from '@ant-design/icons';
|
||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||
import dayjs from 'dayjs';
|
||
import { getCreditRecords, getTeamOptions } from '../api';
|
||
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
|
||
import { formatDate } from '../utils/formatDate';
|
||
|
||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||
|
||
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||
totalRecharge: 0,
|
||
totalConsume: 0,
|
||
totalRefund: 0,
|
||
totalCharge: 0,
|
||
totalHold: 0,
|
||
totalRefundReal: 0,
|
||
totalHoldRelease: 0,
|
||
netConsume: 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 /> },
|
||
expire: { text: '过期', color: 'orange', icon: <RollbackOutlined /> },
|
||
revoke: { text: '撤销', color: 'volcano', icon: <RollbackOutlined /> },
|
||
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||
};
|
||
|
||
|
||
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
|
||
charge: { text: '真实消费', color: 'red' },
|
||
refund: { text: '真实退款', color: 'blue' },
|
||
pre_deduct: { text: '历史固定预扣', color: 'default' },
|
||
hold: { text: '历史预扣占用', color: 'gold' },
|
||
hold_release: { text: '历史预扣释放', color: 'green' },
|
||
};
|
||
|
||
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: '回退' },
|
||
{ value: 'expire', label: '过期' },
|
||
{ value: 'revoke', label: '撤销' },
|
||
{ value: 'team_internal', 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: 'team_internal', 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: '管理员调整' },
|
||
{ value: 'team_internal', label: '团队内部转移' },
|
||
];
|
||
|
||
|
||
const chargeActionOptions = [
|
||
{ value: '', label: '全部交易动作' },
|
||
{ value: 'charge', label: '真实消费' },
|
||
{ value: 'refund', label: '真实退款' },
|
||
{ value: 'pre_deduct', label: '历史固定预扣' },
|
||
{ value: 'hold', label: '历史预扣占用' },
|
||
{ value: 'hold_release', 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: 'team', 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: 'team_internal_transfer', 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 {};
|
||
}
|
||
|
||
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
|
||
const label = item.tierLabel || item.tierCode || '未知等级';
|
||
const code = item.tierCode && item.tierCode !== label ? `(${item.tierCode})` : '';
|
||
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
|
||
return `${label}${code}${rank}`;
|
||
}
|
||
|
||
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
|
||
return [
|
||
item.productTypeLabel,
|
||
item.productName,
|
||
subscriptionTierText(item),
|
||
item.billingCycleLabel,
|
||
].filter(Boolean).join(' · ');
|
||
}
|
||
|
||
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
|
||
const range = item.periodValidFrom && item.periodExpiresAt
|
||
? `${formatDate(item.periodValidFrom)} ~ ${formatDate(item.periodExpiresAt)}`
|
||
: '-';
|
||
return `${item.periodLabel || '周期未知'}|${range}`;
|
||
}
|
||
|
||
function semanticAllocationText(record: AdminCreditRecord): string {
|
||
return (record.allocations || []).map((item) => {
|
||
const tier = item.tierLabel || item.tierCode
|
||
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `(${item.tierCode})` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
|
||
: '未知等级';
|
||
const subscription = item.subscriptionNo
|
||
? `${item.subscriptionNo}|${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
|
||
: '非订阅资金';
|
||
const period = item.periodLabel
|
||
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}~${formatDate(item.periodExpiresAt)}` : ''}`
|
||
: '无月度周期';
|
||
return `${item.creditScopeLabel || '其他资金域'}|${item.allocationActionLabel || '资金变动'}|${subscription}|${period}|${n(item.amount)}积分`;
|
||
}).join(';');
|
||
}
|
||
|
||
const AdminCreditRecords: React.FC = () => {
|
||
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 [userScope, setUserScope] = useState('');
|
||
const [teamFilter, setTeamFilter] = useState('');
|
||
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
|
||
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||
const [recordType, setRecordType] = useState('');
|
||
const [creditSubject, setCreditSubject] = useState('');
|
||
const [mediaType, setMediaType] = useState('');
|
||
const [chargeKind, setChargeKind] = useState('');
|
||
const [chargeAction, setChargeAction] = 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,
|
||
teamId: teamFilter || undefined,
|
||
subscriptionNo: subscriptionNoFilter || undefined,
|
||
recordType: recordType || undefined,
|
||
creditSubject: creditSubject || undefined,
|
||
mediaType: mediaType || undefined,
|
||
chargeKind: chargeKind || undefined,
|
||
chargeAction: chargeAction || 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, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await getCreditRecords(query);
|
||
setRecords(res.items || []);
|
||
setTotal(res.total || 0);
|
||
setSummary(res.summary || DEFAULT_SUMMARY);
|
||
} catch (e: any) {
|
||
message.error(e?.message || '加载积分记录失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => { load(); }, [query]);
|
||
|
||
useEffect(() => {
|
||
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||
}, []);
|
||
|
||
const handleReset = () => {
|
||
setUserScope('');
|
||
setTeamFilter('');
|
||
setSubscriptionNoFilter('');
|
||
setRecordType('');
|
||
setCreditSubject('');
|
||
setMediaType('');
|
||
setChargeKind('');
|
||
setChargeAction('');
|
||
setSourceModule('');
|
||
setSourceStepCode('');
|
||
setBillingScene('');
|
||
setUserNameFilter('');
|
||
setDateRange([null, null]);
|
||
setPage(1);
|
||
};
|
||
|
||
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: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
|
||
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
|
||
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
|
||
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
|
||
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join(';') || '-' },
|
||
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}|${subscriptionProductText(item)}`).join(';') || '-' },
|
||
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}|${subscriptionPeriodText(item)}`).join(';') || '-' },
|
||
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
|
||
{ 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.sceneNameSnapshot || 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: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
|
||
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
|
||
{ title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0})` },
|
||
{ 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: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
|
||
{ 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') || '不限'}`],
|
||
['订阅实例筛选', subscriptionNoFilter || '不限'],
|
||
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
||
['导出条数', totalRows],
|
||
],
|
||
summaryRows: [
|
||
['总充值', exportSummary.totalRecharge],
|
||
['总真实消费', exportSummary.totalConsume],
|
||
['总真实退款', exportSummary.totalRefund],
|
||
['净消耗(总消费 − 总退款,≥ 0)', exportSummary.netConsume],
|
||
['交易笔数', 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: 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: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text> },
|
||
{
|
||
title: '资金构成', key: 'fundingScope', width: 190,
|
||
render: (_: any, r: AdminCreditRecord) => (
|
||
<Space size={[4, 4]} wrap>
|
||
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue">团队积分 {n(r.teamAllocationAmount)}</Tag>}
|
||
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple">个人积分 {n(r.personalAllocationAmount)}</Tag>}
|
||
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary">无资金分摊</Typography.Text>}
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
|
||
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
|
||
<div>
|
||
{(r.subscriptionUsages || []).map((item, index) => (
|
||
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
|
||
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
|
||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
|
||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · 分摊 {n(item.amount)} 积分</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : <Typography.Text type="secondary">非订阅资金</Typography.Text>,
|
||
},
|
||
{
|
||
title: '月度周期', key: 'subscriptionPeriods', width: 300,
|
||
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
|
||
<div>
|
||
{(r.subscriptionUsages || []).map((item, index) => (
|
||
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
|
||
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
|
||
<div style={{ fontSize: 12, color: '#64748b' }}>
|
||
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ~ ${formatDate(item.periodExpiresAt)}` : '-'}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : '-',
|
||
},
|
||
{ 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: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary">历史</Typography.Text>; } },
|
||
{ 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.sceneNameSnapshot || 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: (value: number) => <Typography.Text strong style={{ color: value > 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}</Typography.Text> },
|
||
{ title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? <Tag color="orange">{n(v)}</Tag> : '-' },
|
||
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
|
||
{ title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) => <div>{n(r.llmCallCount || 0)} 次<div style={{ fontSize: 12, color: '#94a3b8' }}>成 {n(r.llmSuccessCallCount || 0)} / 败 {n(r.llmFailedCallCount || 0)}</div></div> },
|
||
{ 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>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 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 style={{ minWidth: 0 }}>
|
||
<div style={{ color: '#94a3b8' }}>总消费</div>
|
||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
|
||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>仅统计真实消费流水</div>
|
||
</div>
|
||
</Space>
|
||
</Card>
|
||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ color: '#94a3b8' }}>总退款</div>
|
||
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
|
||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>仅统计真实退款流水</div>
|
||
</div>
|
||
</Space>
|
||
</Card>
|
||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: 'linear-gradient(135deg, #faf5ff 0%, #eef2ff 100%)' }}>
|
||
<Space><DollarOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
||
<div>
|
||
<div style={{ color: '#6366f1' }}>净消耗(实际用掉)</div>
|
||
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
|
||
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}>总消费 − 总退款(≥ 0)</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>
|
||
|
||
<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={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||
<Select
|
||
value={teamFilter}
|
||
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||
style={{ width: 170 }}
|
||
options={[
|
||
{ value: '', label: '全部团队' },
|
||
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||
]}
|
||
/>
|
||
<Input placeholder="订阅实例号(PS/TS)" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
|
||
<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={chargeAction} onChange={(v) => { setPage(1); setChargeAction(v); }} style={{ width: 140 }} options={chargeActionOptions} />
|
||
<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 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}
|
||
rowKey="id"
|
||
loading={loading}
|
||
pagination={{
|
||
current: page,
|
||
pageSize,
|
||
total,
|
||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||
showSizeChanger: true,
|
||
showTotal: (t) => `共 ${t} 条记录`,
|
||
}}
|
||
expandable={{
|
||
rowExpandable: (record) => Boolean(record.allocations?.length),
|
||
expandedRowRender: (record) => (
|
||
<Table
|
||
size="small"
|
||
pagination={false}
|
||
rowKey="id"
|
||
dataSource={record.allocations || []}
|
||
columns={[
|
||
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
|
||
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
|
||
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
|
||
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
|
||
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
|
||
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
|
||
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
|
||
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
|
||
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `(${a.tierCode})` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
|
||
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
|
||
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
|
||
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ~ ${formatDate(a.periodExpiresAt)}` : '-' },
|
||
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ~ ${formatDate(a.expiresAt)}` : '-' },
|
||
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
|
||
]}
|
||
scroll={{ x: 2390 }}
|
||
/>
|
||
),
|
||
}}
|
||
scroll={{ x: 3350 }}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AdminCreditRecords;
|