会员积分改版V1

This commit is contained in:
2026-08-11 09:24:18 +08:00
parent fe24e51b97
commit b9fd07f293
111 changed files with 9355 additions and 3900 deletions
+6 -2
View File
@@ -21,7 +21,9 @@ import AdminVideoEngines from './pages/AdminVideoEngines';
import AdminImageEngines from './pages/AdminImageEngines';
import AdminCreditRatios from './pages/AdminCreditRatios';
import AdminMenuConfig from './pages/AdminMenuConfig';
import AdminRechargePackages from './pages/AdminRechargePackages';
import AdminCreditProducts from './pages/AdminCreditProducts';
import AdminLlmBillingPolicies from './pages/AdminLlmBillingPolicies';
import AdminLlmBillingExecutions from './pages/AdminLlmBillingExecutions';
import AdminOperationLogs from './pages/AdminOperationLogs';
import AdminOauthAppList from './pages/AdminOauthAppList';
import AdminGenerationRecords from './pages/AdminGenerationRecords';
@@ -94,7 +96,9 @@ const App = () => {
<Route path="image-engines" element={<AdminImageEngines />} />
<Route path="industries" element={<AdminIndustries />} />
<Route path="menu-configs" element={<AdminMenuConfig />} />
<Route path="recharge-packages" element={<AdminRechargePackages />} />
<Route path="credit-products" element={<AdminCreditProducts />} />
<Route path="llm-billing-policies" element={<AdminLlmBillingPolicies />} />
<Route path="llm-billing-executions" element={<AdminLlmBillingExecutions />} />
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
+84 -1
View File
@@ -17,7 +17,7 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload, CreditProduct,
} from '../types';
import type {
@@ -1092,3 +1092,86 @@ export async function adminGetPrivatePortraitConfig(userId: string): Promise<Pri
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
}
// ── Dynamic Credit Products ───────────────────────────────
function normalizeCreditProduct(raw: CreditProduct): CreditProduct {
return {
...raw,
renewalEnabled: raw.renewalEnabled === true,
tierRank: raw.tierRank == null ? null : Number(raw.tierRank),
monthlyGrantCredits: Number(raw.monthlyGrantCredits || 0),
firstPurchasePrice: Number(raw.firstPurchasePrice || 0),
regularPrice: Number(raw.regularPrice || 0),
activityPrice: raw.activityPrice == null ? null : Number(raw.activityPrice),
price: Number(raw.price || 0),
grantCredits: Number(raw.grantCredits || 0),
sortOrder: Number(raw.sortOrder || 0),
isActive: raw.isActive === true,
};
}
export async function getCreditProducts(productType?: 'subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : '';
const products = await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`);
return products.map(normalizeCreditProduct);
}
export async function createCreditProduct(payload: Record<string, unknown>): Promise<CreditProduct> {
return normalizeCreditProduct(await api.post<CreditProduct>('/admin/credit-management/products', payload));
}
export async function updateCreditProduct(id: string, payload: Record<string, unknown>): Promise<CreditProduct> {
return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}`, payload));
}
export async function setCreditProductRenewal(id: string, renewalEnabled: boolean): Promise<CreditProduct> {
return normalizeCreditProduct(await api.put<CreditProduct>(
`/admin/credit-management/products/${id}/renewal`,
{ renewal_enabled: renewalEnabled },
));
}
export async function disableCreditProduct(id: string): Promise<void> {
await api.delete(`/admin/credit-management/products/${id}`);
}
export async function getAdminUserCreditSummary(userId: string): Promise<any> {
return api.get(`/admin/credit-management/users/${userId}/summary`);
}
export async function getAdminUserCreditBalances(userId: string, page = 1, pageSize = 50, status?: string): Promise<any[]> {
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
if (status) params.set('status', status);
return api.get(`/admin/credit-management/users/${userId}/balances?${params.toString()}`);
}
export async function adminGrantCredits(userId: string, payload: Record<string, unknown>): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/grant`, payload);
}
export async function adminDeductCredits(userId: string, payload: Record<string, unknown>): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/deduct`, payload);
}
// ── LLM Fixed Pre-deduct ──────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
return api.get('/admin/llm-billing/policies');
}
export async function createLlmBillingPolicy(payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.post('/admin/llm-billing/policies', payload);
}
export async function updateLlmBillingPolicy(id: string, payload: Record<string, unknown>): Promise<import('../types').LlmBillingPolicy> {
return api.put(`/admin/llm-billing/policies/${id}`, payload);
}
export async function getLlmBillingExecutions(params?: { page?: number; pageSize?: number; sceneCode?: string; status?: string; userId?: string }): Promise<{ items: import('../types').LlmBillingExecution[]; total: number }> {
const query = new URLSearchParams();
query.set('page', String(params?.page || 1));
query.set('page_size', String(params?.pageSize || 20));
if (params?.sceneCode) query.set('scene_code', params.sceneCode);
if (params?.status) query.set('status', params.status);
if (params?.userId) query.set('user_id', params.userId);
return api.get(`/admin/llm-billing/executions?${query.toString()}`);
}
@@ -0,0 +1,389 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd';
import { EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct, disableCreditProduct, getCreditProducts, setCreditProductRenewal, updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
const formatMoney = (value?: number | null): string => {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState<CreditProduct | null>(null);
const [open, setOpen] = useState(false);
const [renewalEnabled, setRenewalEnabled] = useState(true);
const [form] = Form.useForm();
const replaceItem = (product: CreditProduct) => {
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
};
const load = async () => {
setLoading(true);
try {
setItems(await getCreditProducts(type));
} catch (error: any) {
message.error(error?.message || '加载积分商品失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, [type]);
const openEditor = (item?: CreditProduct) => {
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
setEditing(item || null);
setRenewalEnabled(nextRenewalEnabled);
setOpen(true);
form.resetFields();
if (item) {
form.setFieldsValue({
productCode: item.productCode,
name: item.name,
description: item.description || undefined,
featuresText: (item.features || []).join('\n'),
tierCode: item.tierCode,
tierRank: item.tierRank,
billingCycle: item.billingCycle,
monthlyGrantCredits: item.monthlyGrantCredits,
firstPurchasePrice: item.firstPurchasePrice,
regularPrice: item.regularPrice,
activityPrice: item.activityPrice,
activityRange: item.activityStartAt && item.activityEndAt
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
: undefined,
renewalEnabled: nextRenewalEnabled,
price: item.price,
grantCredits: item.grantCredits,
sortOrder: item.sortOrder,
isActive: item.isActive,
});
return;
}
form.setFieldsValue({
productCode: undefined,
name: undefined,
description: undefined,
featuresText: undefined,
isActive: true,
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: nextRenewalEnabled,
});
};
const save = async () => {
try {
const values = await form.validateFields();
const activityRange = values.activityRange || [];
const payload: Record<string, unknown> = {
product_code: values.productCode,
product_type: type,
name: values.name,
description: values.description || null,
features: String(values.featuresText || '')
.split('\n')
.map((value) => value.trim())
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (type === 'subscription') {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
monthly_grant_credits: values.monthlyGrantCredits,
first_purchase_price: values.firstPurchasePrice,
regular_price: values.regularPrice,
activity_price: values.activityPrice ?? null,
activity_start_at: activityRange[0]?.toISOString() || null,
activity_end_at: activityRange[1]?.toISOString() || null,
renewal_enabled: renewalEnabled,
price: values.regularPrice,
});
} else {
Object.assign(payload, {
price: values.price,
grant_credits: values.grantCredits,
});
}
const savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (type === 'subscription' && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
if (editing) {
replaceItem(savedProduct);
} else {
setItems((current) => [...current, savedProduct].sort((a, b) => {
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
}));
}
message.success(editing ? '商品已更新' : '商品已创建');
setOpen(false);
setEditing(null);
setRenewalEnabled(true);
form.resetFields();
} catch (error: any) {
if (error?.errorFields) return;
message.error(error?.message || '保存失败');
}
};
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
const previous = product.renewalEnabled === true;
const optimistic = { ...product, renewalEnabled: enabled };
replaceItem(optimistic);
if (editing?.id === product.id) {
setEditing(optimistic);
setRenewalEnabled(enabled);
form.setFieldValue('renewalEnabled', enabled);
}
try {
const savedProduct = await setCreditProductRenewal(product.id, enabled);
if (savedProduct.renewalEnabled !== enabled) {
throw new Error('续费开关保存结果与提交值不一致');
}
replaceItem(savedProduct);
if (editing?.id === product.id) {
setEditing(savedProduct);
setRenewalEnabled(savedProduct.renewalEnabled === true);
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
}
message.success(enabled ? '已开启续费' : '已关闭续费');
} catch (error: any) {
const reverted = { ...product, renewalEnabled: previous };
replaceItem(reverted);
if (editing?.id === product.id) {
setEditing(reverted);
setRenewalEnabled(previous);
form.setFieldValue('renewalEnabled', previous);
}
message.error(error?.message || '更新续费状态失败');
}
};
const columns = useMemo(() => type === 'subscription' ? [
{
title: '套餐',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{
title: '等级',
key: 'tier',
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
},
{
title: '周期',
dataIndex: 'billingCycle',
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
},
{
title: '每月积分',
dataIndex: 'monthlyGrantCredits',
render: (value?: number) => Number(value || 0).toLocaleString(),
},
{
title: '首充价',
dataIndex: 'firstPurchasePrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '原价',
dataIndex: 'regularPrice',
render: (value?: number | null) => formatMoney(value),
},
{
title: '活动价',
key: 'activity',
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
? '-'
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
},
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => (
<Switch
checked={enabled === true}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => void toggleRenewal(row, checked)}
/>
),
},
{
title: '状态',
dataIndex: 'isActive',
render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title="确认下架该商品?"
onConfirm={async () => {
await disableCreditProduct(row.id);
message.success('已下架');
await load();
}}
>
<Button type="link" danger icon={<StopOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
title: '增值包',
key: 'name',
render: (_: unknown, row: CreditProduct) => <>
<Typography.Text strong>{row.name}</Typography.Text>
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
</>,
},
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', render: () => '1 个自然月' },
{ title: '状态', dataIndex: 'isActive', render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag> },
{
title: '操作',
key: 'action',
render: (_: unknown, row: CreditProduct) => <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title="确认下架该商品?"
onConfirm={async () => {
await disableCreditProduct(row.id);
message.success('已下架');
await load();
}}
>
<Button type="link" danger icon={<StopOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items]);
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
{type === 'subscription' ? '新增订阅套餐' : '新增积分增值包'}
</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
<Modal
open={open}
title={editing ? '编辑积分商品' : '新增积分商品'}
onOk={save}
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
width={760}
destroyOnClose
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="productCode" label="商品编码" rules={[{ required: true }]}>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
<Input style={{ width: 260 }} />
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{type === 'subscription' ? <>
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
{ value: 'quarterly', label: '季' },
{ value: 'yearly', label: '年' },
]}
/>
</Form.Item>
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16}>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去首订资格的用户不会在客户端看到该套餐,也不能通过接口续费或升级购买。"
>
<Switch
checked={renewalEnabled}
checkedChildren="开启"
unCheckedChildren="关闭"
onChange={(checked) => {
setRenewalEnabled(checked);
form.setFieldValue('renewalEnabled', checked);
}}
/>
</Form.Item>
</Space>
</> : <Space align="start" size={16}>
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
<InputNumber min={0.01} />
</Form.Item>
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item label="有效期"><Input value="1 个自然月" disabled /></Form.Item>
</Space>}
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminCreditProducts;
@@ -3,12 +3,11 @@ import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
getCreditRatios, saveCreditRatio,
deleteCreditRatio, getSystemConfigs,
updateSystemConfig, getGenerationAiEngines,
deleteCreditRatio, getGenerationAiEngines,
} from '../api';
import type { GenerationAiEngineOption } from '../types';
@@ -54,18 +53,14 @@ const AdminCreditRatios: React.FC = () => {
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm<CreditRatioFormValues>();
const [textRate, setTextRate] = useState<number>(10);
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
const [savingTextRate, setSavingTextRate] = useState(false);
const genType = Form.useWatch('genType', form) || 'video';
const selectedEngineId = Form.useWatch('modelConfigId', form);
const load = async () => {
setLoading(true);
try {
const [ratioData, sysConfigs, enginesData] = await Promise.all([
const [ratioData, enginesData] = await Promise.all([
getCreditRatios(),
getSystemConfigs(),
getGenerationAiEngines(),
]);
@@ -81,11 +76,6 @@ const AdminCreditRatios: React.FC = () => {
setRatios(ratioData);
setEngines([...videoEngines, ...imageEngines]);
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
if (textCfg) {
setTextRate(Number(textCfg.value) || 10);
setTextRateConfig({ id: textCfg.id });
}
} catch {
message.error('加载积分比例失败');
} finally {
@@ -171,18 +161,6 @@ const AdminCreditRatios: React.FC = () => {
}
};
const handleSaveTextRate = async () => {
if (!textRateConfig) return;
setSavingTextRate(true);
try {
await updateSystemConfig(textRateConfig.id, String(textRate));
message.success('文字积分费率已更新');
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setSavingTextRate(false);
}
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
@@ -338,40 +316,6 @@ const AdminCreditRatios: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Text Credit Rate */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
ceil(token数 x / 1000)1
</Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Typography.Text>1000 token消耗积分</Typography.Text>
<Space.Compact style={{ width: 160 }}>
<InputNumber
min={0}
max={1000}
step={0.01}
value={textRate}
onChange={(v) => setTextRate(v || 0)}
size="large"
style={{ width: '100%' }}
/>
<Typography.Text></Typography.Text>
</Space.Compact>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
1000 token = {textRate} 500 token = {(500 * textRate / 1000).toFixed(4)}
</Typography.Text>
</div>
</Card>
{/* Generation Credit Ratios */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
@@ -35,6 +35,8 @@ const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React
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 /> },
};
@@ -58,6 +60,8 @@ const recordTypeOptions = [
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '回退' },
{ value: 'expire', label: '过期' },
{ value: 'revoke', label: '撤销' },
{ value: 'team_internal', label: '团队内部' },
];
@@ -280,10 +284,13 @@ const AdminCreditRecords: React.FC = () => {
{ 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: 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: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
{ title: '有效余额变动', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceDelta ?? 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 },
@@ -295,6 +302,7 @@ const AdminCreditRecords: React.FC = () => {
{ 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: 24, maxWidth: 48, render: (r) => (r.allocations || []).map(a => `${a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join('') },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -345,10 +353,13 @@ const AdminCreditRecords: React.FC = () => {
{ 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.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
{ 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: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
{ title: '业务积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
{ title: '有效变动', dataIndex: 'balanceDelta', width: 120, render: (v: number | undefined, r: AdminCreditRecord) => { const value = v ?? r.amount; return <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> },
@@ -417,7 +428,27 @@ const AdminCreditRecords: React.FC = () => {
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 2160 }}
expandable={{
rowExpandable: (record) => Boolean(record.allocations?.length),
expandedRowRender: (record) => (
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationAction', width: 150 },
{ title: '积分来源', dataIndex: 'sourceType', width: 160, render: (v: string) => v || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 260, render: (v: string) => v || '-' },
{ title: '积分数量', dataIndex: 'amount', width: 120, render: (v: number) => n(v) },
{ title: '生效时间', dataIndex: 'validFrom', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
{ title: '过期边界', dataIndex: 'expiresAt', width: 190, render: (v: string) => v ? formatDate(v) : '-' },
]}
scroll={{ x: 1070 }}
/>
),
}}
scroll={{ x: 2520 }}
/>
</Card>
</div>
@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
import { HistoryOutlined } from '@ant-design/icons';
import { getLlmBillingExecutions } from '../api';
import type { LlmBillingExecution } from '../types';
import { formatDate, formatDatePrecise } from '../utils/formatDate';
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pre_deducted: { label: '已预扣', color: 'blue' },
processing: { label: '处理中', color: 'processing' },
succeeded: { label: '业务成功', color: 'success' },
final_failed: { label: '最终失败', color: 'error' },
refunded: { label: '已退款', color: 'purple' },
refund_failed: { label: '退款失败', color: 'red' },
};
const AdminLlmBillingExecutions: React.FC = () => {
const [items, setItems] = useState<LlmBillingExecution[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [status, setStatus] = useState<string>();
const [userId, setUserId] = useState('');
const [loading, setLoading] = useState(false);
const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
useEffect(() => { load(); }, [page, status]);
return <Card title={<Space><HistoryOutlined />LLM调用与积分审计</Space>} extra={<Space><Input.Search allowClear placeholder="用户ID" value={userId} onChange={(e) => setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} /><Select allowClear placeholder="最终状态" value={status} onChange={(v) => { setStatus(v); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value, config]) => ({ value, label: config.label }))} /></Space>}>
<Table rowKey="id" loading={loading} dataSource={items} pagination={{ current: page, pageSize: 20, total, onChange: setPage }} expandable={{ expandedRowRender: (record) => <div>
<Timeline items={(record.calls || []).map((call) => ({
color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
children: <div>
<Typography.Text strong>{formatDatePrecise(call.requestStartedAt)}  {call.callSequence} {call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}</Typography.Text>
<div>{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}</div>
<div>{call.modelNameSnapshot || '-'} {call.durationMs == null ? '-' : `${call.durationMs}ms`}</div>
{call.tokenUnavailableReason
? <div>Token{call.tokenUnavailableReason}</div>
: <div> Token{call.inputTokens ?? 0}  Token{call.outputTokens ?? 0}  Token{call.totalTokens ?? 0}</div>}
{call.errorMessage && <Typography.Text type="danger">{call.errorMessage}</Typography.Text>}
{call.postprocessStatus === 'succeeded' && <div></div>}
{call.postprocessError && <div>{call.postprocessError}</div>}
</div>,
}))} />
<Descriptions bordered size="small" column={4} items={[
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot },
{ key: 'credit', label: '固定预扣', children: record.preDeductCredits },
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount}` },
{ key: 'tokens', label: '累计 Token', children: record.totalTokens },
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
{ key: 'refundE', label: '过期积分退回', children: record.refundExpiredCredits },
{ key: 'error', label: '最终错误', children: record.finalErrorMessage || '-' },
]} />
</div> }} columns={[
{ title: '功能', dataIndex: 'sceneNameSnapshot' },
{ title: '用户', dataIndex: 'userId' },
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
{ title: '模型', dataIndex: 'modelNameSnapshot' },
{ title: '预扣', dataIndex: 'preDeductCredits' },
{ title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
{ title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } },
{ title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
]} />
</Card>;
};
export default AdminLlmBillingExecutions;
@@ -0,0 +1,51 @@
import React, { useEffect, useState } from 'react';
import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
import type { LlmBillingPolicy } from '../types';
const SCENES: Array<{ value: string; label: string }> = [
{ value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
{ value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
{ value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
];
const AdminLlmBillingPolicies: React.FC = () => {
const [items, setItems] = useState<LlmBillingPolicy[]>([]);
const [editing, setEditing] = useState<LlmBillingPolicy | null>(null);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const load = async () => { setLoading(true); try { setItems(await getLlmBillingPolicies()); } finally { setLoading(false); } };
useEffect(() => { load(); }, []);
const edit = (item?: LlmBillingPolicy) => { setEditing(item || null); setOpen(true); form.resetFields(); form.setFieldsValue(item ? { scene_code: item.sceneCode, pre_deduct_credits: item.preDeductCredits, is_active: item.isActive } : { is_active: true, pre_deduct_credits: 5 }); };
const save = async () => {
try {
const values = await form.validateFields();
if (editing) await updateLlmBillingPolicy(editing.id, values); else await createLlmBillingPolicy(values);
message.success('已保存'); setOpen(false); load();
} catch (error: any) { if (!error?.errorFields) message.error(error?.message || '保存失败'); }
};
return <Card title={<Space><RobotOutlined />LLM固定预扣配置</Space>} extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button>}>
<Table rowKey="id" loading={loading} dataSource={items} columns={[
{ title: '功能', dataIndex: 'sceneName' },
{ title: '场景编码', dataIndex: 'sceneCode' },
{ title: '固定预扣积分', dataIndex: 'preDeductCredits' },
{ title: '版本', dataIndex: 'version' },
{ title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
{ title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}></Button> },
]} />
<Modal open={open} title={editing ? '编辑LLM场景预扣' : '新增LLM场景预扣'} onOk={save} onCancel={() => setOpen(false)}>
<Form form={form} layout="vertical">
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}><Select disabled={!!editing} options={SCENES} /></Form.Item>
<Form.Item name="pre_deduct_credits" label="固定预扣积分" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
</Card>;
};
export default AdminLlmBillingPolicies;
+2 -4
View File
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
return <Tag color="blue">{labelMap[v] || v}</Tag>;
},
},
{
@@ -160,7 +159,7 @@ const AdminModels: React.FC = () => {
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
@@ -1,253 +1 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
interface PackageItem {
id: string;
name: string;
credits: number;
price: number;
bonusCredits: number;
totalCredits: number;
description: string | null;
packageType: string;
isGift: boolean;
isActive: boolean;
sortOrder: number;
}
const TYPE_COLORS: Record<string, string> = {
normal: 'blue',
gift: 'green',
promo: 'purple',
};
const TYPE_LABELS: Record<string, string> = {
normal: '常规',
gift: '赠送',
promo: '促销',
};
const AdminRechargePackages: React.FC = () => {
const [packages, setPackages] = useState<PackageItem[]>([]);
const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
try {
const data = await getRechargePackages();
setPackages(data.map((item: any) => ({
id: item.id,
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
description: item.description,
packageType: item.package_type ?? item.packageType ?? 'normal',
isGift: item.is_gift ?? item.isGift ?? false,
isActive: item.is_active ?? item.isActive ?? true,
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
})));
} catch {
message.error('加载充值套餐失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
const payload = {
name: values.name,
credits: values.credits,
price: values.price,
bonus_credits: values.bonusCredits || 0,
description: values.description || null,
package_type: values.packageType || 'normal',
is_gift: values.isGift || false,
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (modal.item?.id) {
await saveRechargePackage({ id: modal.item.id, ...payload });
message.success('已更新');
} else {
await saveRechargePackage(payload);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
try {
await deleteRechargePackage(id);
message.success('已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (item?: PackageItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
name: item.name,
credits: item.credits,
price: item.price,
bonusCredits: item.bonusCredits,
description: item.description,
packageType: item.packageType,
isGift: item.isGift,
isActive: item.isActive,
sortOrder: item.sortOrder,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
}
};
const columns = [
{
title: '套餐名称', key: 'name', width: 160,
render: (_: any, r: PackageItem) => (
<div>
<Typography.Text strong>{r.name}</Typography.Text>
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
</div>
),
},
{
title: '基础积分', dataIndex: 'credits', width: 100,
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
},
{
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
render: (v: number) => v > 0
? <Tag color="green">+{v.toLocaleString()}</Tag>
: <Typography.Text type="secondary">-</Typography.Text>,
},
{
title: '总积分', key: 'total', width: 100,
render: (_: any, r: PackageItem) => (
<Typography.Text strong style={{ color: '#6366f1' }}>
{(r.credits + r.bonusCredits).toLocaleString()}
</Typography.Text>
),
},
{
title: '价格(元)', dataIndex: 'price', width: 100,
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'packageType', width: 80,
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: PackageItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{packages.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={packages}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
<Input placeholder="例如:进阶包" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 'normal', label: '常规' },
{ value: 'gift', label: '赠送' },
{ value: 'promo', label: '促销' },
]} />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input placeholder="套餐描述(可选)" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
<InputNumber size="large" style={{ width: '100%' }} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminRechargePackages;
export { default } from './AdminCreditProducts';
+1 -104
View File
@@ -48,14 +48,6 @@ const AdminSettings: React.FC = () => {
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
// LLM 预扣积分默认值
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
if (!formValues.llm_billing_enabled) formValues.llm_billing_enabled = 'true';
if (!formValues.llm_hold_credits_default) formValues.llm_hold_credits_default = '5';
if (!formValues.llm_hold_credits_generation_record_prompt) formValues.llm_hold_credits_generation_record_prompt = '5';
if (!formValues.llm_hold_credits_module_image_prompt) formValues.llm_hold_credits_module_image_prompt = '5';
if (!formValues.llm_hold_credits_module_video_prompt) formValues.llm_hold_credits_module_video_prompt = '10';
if (!formValues.llm_hold_credits_shot_video_analysis) formValues.llm_hold_credits_shot_video_analysis = '10';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -70,80 +62,13 @@ const AdminSettings: React.FC = () => {
const handleSave = async () => {
try {
const values = await form.validateFields();
const llmBillingEnabled = !['0', 'false', 'no', 'off', 'disabled'].includes(
String(values.llm_billing_enabled ?? 'true').trim().toLowerCase(),
);
if (llmBillingEnabled) {
const holdKeys = [
'optimize_hold_credits',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
];
const invalidKey = holdKeys.find((key) => {
const numericValue = Number(values[key]);
return !Number.isFinite(numericValue) || numericValue <= 0;
});
if (invalidKey) {
message.error('启用 LLM 统一计费时,所有预扣积分必须大于 0');
return;
}
}
setSaving(true);
const llmManagedKeys = new Set([
'optimize_hold_credits',
'llm_billing_enabled',
'llm_hold_credits_default',
'llm_hold_credits_generation_record_prompt',
'llm_hold_credits_module_image_prompt',
'llm_hold_credits_module_video_prompt',
'llm_hold_credits_shot_video_analysis',
]);
for (const config of configs) {
if (llmManagedKeys.has(config.key)) continue;
const newVal = values[config.key];
if (newVal !== undefined && String(newVal) !== config.value) {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
const saveManagedConfig = async (key: string, value: unknown, description: string) => {
if (value === undefined || value === null || value === '') return;
const normalizedValue = String(value);
const existing = configs.find(c => c.key === key);
if (existing) {
if (normalizedValue !== existing.value) await updateSystemConfig(existing.id, normalizedValue);
} else {
await createSystemConfig(key, normalizedValue, description);
}
};
const enabledConfig = [
'llm_billing_enabled',
values.llm_billing_enabled,
'是否启用 LLM 统一预扣与真实扣费结算',
] as const;
const llmHoldConfigs = [
['optimize_hold_credits', values.optimize_hold_credits, '提示词理解预扣积分数量(防止并发超卖)'],
['llm_hold_credits_default', values.llm_hold_credits_default, 'LLM 默认预扣积分数量'],
['llm_hold_credits_generation_record_prompt', values.llm_hold_credits_generation_record_prompt, 'AI创作提示词优化预扣积分数量'],
['llm_hold_credits_module_image_prompt', values.llm_hold_credits_module_image_prompt, '模块图片 AI 提词优化预扣积分数量'],
['llm_hold_credits_module_video_prompt', values.llm_hold_credits_module_video_prompt, '模块视频 AI 提词优化预扣积分数量'],
['llm_hold_credits_shot_video_analysis', values.llm_hold_credits_shot_video_analysis, '拆镜视频分析预扣积分数量'],
] as const;
// 关闭时先关开关,随后允许保存 0;启用时先保存正数预扣,最后再打开开关。
if (!llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
for (const [key, value, description] of llmHoldConfigs) {
await saveManagedConfig(key, value, description);
}
if (llmBillingEnabled) {
await saveManagedConfig(...enabledConfig);
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -254,7 +179,6 @@ const AdminSettings: React.FC = () => {
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'其他配置': configs.filter(c => c.key === 'operation_manual'),
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits' || c.key.startsWith('llm_')),
};
const getFieldDescription = (config: SystemConfig): string => {
@@ -270,13 +194,6 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: '兼容旧配置。新 LLM 配置为空时回退使用该值',
llm_billing_enabled: '是否启用 LLM 统一预扣、释放预扣和真实扣费结算',
llm_hold_credits_default: 'LLM 场景默认预扣积分,场景配置为空时使用',
llm_hold_credits_generation_record_prompt: 'AI创作提示词优化发起前预扣积分',
llm_hold_credits_module_image_prompt: '爆款开头/拆镜复刻图片 AI 提词优化发起前预扣积分',
llm_hold_credits_module_video_prompt: '爆款开头/拆镜复刻视频 AI 提词优化发起前预扣积分',
llm_hold_credits_shot_video_analysis: '拆镜原视频/片段视频分析发起前预扣积分',
};
return descMap[config.key] || config.description || '';
};
@@ -406,7 +323,7 @@ const AdminSettings: React.FC = () => {
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits' || config.key.startsWith('llm_hold_credits')) {
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
@@ -467,26 +384,6 @@ const AdminSettings: React.FC = () => {
{getFieldComponent(config)}
</Form.Item>
))}
{/* AI创作预扣积分 - 固定显示 */}
<Form.Item
name="optimize_hold_credits"
label={<span style={{ fontWeight: 500 }}></span>}
extra="兼容旧配置。新 LLM 场景配置为空时回退使用该值"
>
<Input type="number" min={0} placeholder="默认5" size="large" />
</Form.Item>
{[
['llm_billing_enabled', '启用 LLM 统一计费', 'true 表示启用,false 表示关闭'],
['llm_hold_credits_default', 'LLM 默认预扣积分', '默认5'],
['llm_hold_credits_generation_record_prompt', 'AI创作提词预扣积分', '默认5'],
['llm_hold_credits_module_image_prompt', '模块图片提词预扣积分', '默认5'],
['llm_hold_credits_module_video_prompt', '模块视频提词预扣积分', '默认10'],
['llm_hold_credits_shot_video_analysis', '拆镜视频分析预扣积分', '默认10'],
].map(([name, label, extra]) => (
<Form.Item key={name} name={name} label={<span style={{ fontWeight: 500 }}>{label}</span>} extra={extra}>
{name === 'llm_billing_enabled' ? <Input placeholder="true / false" size="large" /> : <Input type="number" min={1} placeholder={extra} size="large" />}
</Form.Item>
))}
</div>
</Form>
),
+141 -37
View File
@@ -6,12 +6,15 @@ import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons';
import {
adjustCredits,
adminDeductCredits,
adminGrantCredits,
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
deleteUserResourceCapacity,
getAdminUsers,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
getTeamOptions,
getSystemConfigs,
@@ -66,6 +69,12 @@ const AdminUsers: React.FC = () => {
const [teamFilter, setTeamFilter] = useState<string>('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailModal, setCreditDetailModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
const [creditBalanceStatus, setCreditBalanceStatus] = useState<string>('');
const [creditOperation, setCreditOperation] = useState<'grant' | 'deduct'>('grant');
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<string>('frontend');
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
@@ -164,12 +173,44 @@ const AdminUsers: React.FC = () => {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.description);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
if (creditOperation === 'grant') {
await adminGrantCredits(user.id, {
amount: values.amount,
description: values.description,
validity_unit: values.validityUnit || 'month',
validity_value: values.validityValue || 1,
credit_level: 'general',
});
} else {
await adminDeductCredits(user.id, { amount: values.amount, description: values.description });
}
message.success(`${creditOperation === 'grant' ? '增加' : '扣除'} ${values.amount} 积分`);
setCreditModal({ open: false, user: null });
setCreditOperation('grant');
form.resetFields();
load();
} catch { /* validation */ }
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '积分操作失败');
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
setCreditDetailLoading(true);
try {
const [summary, balances] = await Promise.all([
getAdminUserCreditSummary(user.id),
getAdminUserCreditBalances(user.id, 1, 200, status || undefined),
]);
setCreditSummary(summary);
setCreditBalances(balances || []);
} catch (e: any) {
message.error(e?.message || '加载用户积分明细失败');
} finally {
setCreditDetailLoading(false);
}
};
const handleToggleStatus = async (user: AdminUser) => {
@@ -493,10 +534,20 @@ const AdminUsers: React.FC = () => {
<Space size={4} wrap>
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
onClick={() => {
setCreditOperation('grant');
setCreditModal({ open: true, user: r });
form.setFieldsValue({ amount: undefined, description: '', validityUnit: 'month', validityValue: 1 });
}}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => openCreditDetailModal(r)}>
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -685,50 +736,103 @@ const AdminUsers: React.FC = () => {
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
onCancel={() => { setCreditModal({ open: false, user: null }); setCreditOperation('grant'); form.resetFields(); }}
okText="确认" cancelText="取消" width={480}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 /
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 /
</Button>
</Space>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
<Form form={form} layout="vertical" initialValues={{ validityUnit: 'month', validityValue: 1 }}>
<Form.Item label="操作类型">
<Select
value={creditOperation}
onChange={(value) => setCreditOperation(value)}
options={[
{ value: 'grant', label: '增加积分' },
{ value: 'deduct', label: '扣除积分' },
]}
/>
</Form.Item>
<Form.Item name="description" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Form.Item name="amount" label="积分数量" rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} size="large" placeholder="请输入正数积分数量" />
</Form.Item>
{creditOperation === 'grant' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<Form.Item name="validityUnit" label="有效期单位" rules={[{ required: true }]}>
<Select options={[{ value: 'day', label: '天' }, { value: 'month', label: '自然月' }]} />
</Form.Item>
<Form.Item name="validityValue" label="有效期数值" rules={[{ required: true }]}>
<InputNumber min={1} max={120} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
)}
<Form.Item name="description" label="原因" rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
footer={null}
width={1180}
onCancel={() => {
setCreditDetailModal({ open: false, user: null });
setCreditSummary(null);
setCreditBalances([]);
setCreditBalanceStatus('');
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }}>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.availableCredits || creditSummary?.credits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.nextExpiringCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 15, fontWeight: 600 }}>{creditSummary?.nextLastUsableAt ? formatDate(creditSummary.nextLastUsableAt) : '-'}</div></Card>
</div>
<Space>
<Typography.Text strong></Typography.Text>
<Select
value={creditBalanceStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'scheduled', label: '未生效' },
{ value: 'active', label: '有效' },
{ value: 'consumed', label: '已消费' },
{ value: 'expired', label: '已过期' },
{ value: 'revoked', label: '已撤销' },
]}
onChange={(value) => creditDetailModal.user && openCreditDetailModal(creditDetailModal.user, value)}
/>
</Space>
<Table
size="small"
loading={creditDetailLoading}
rowKey="id"
pagination={false}
dataSource={creditBalances}
columns={[
{ title: '来源', dataIndex: 'sourceType', width: 150, render: (v: string) => v || '-' },
{ title: '来源ID', dataIndex: 'sourceId', width: 220, ellipsis: true, render: (v: string) => v || '-' },
{ title: '积分等级', dataIndex: 'creditLevel', width: 100, render: (v: string) => v === 'promotional' ? '活动积分' : '普通积分' },
{ title: '发放', dataIndex: 'grantAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '剩余', dataIndex: 'unspentAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已消费', dataIndex: 'consumedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已过期', dataIndex: 'expiredAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '已撤销', dataIndex: 'revokedAmount', width: 100, render: (v: number) => Number(v || 0).toLocaleString() },
{ title: '生效时间', dataIndex: 'validFrom', width: 170, render: (v: string) => formatDate(v) },
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 180, render: (v: string) => formatDate(v) },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{v}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
</Space>
</Modal>
<Modal
title={<Space><TeamOutlined /> - {teamModal.user?.username}</Space>}
open={teamModal.open}
@@ -893,7 +997,7 @@ const AdminUsers: React.FC = () => {
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
</Form.Item>
{createType === 'frontend' && (
<Form.Item name="credits" label="初始积分" initialValue={0}>
<Form.Item name="credits" label="初始积分(一个自然月有效)" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} size="large" />
</Form.Item>
)}
+116
View File
@@ -884,6 +884,23 @@ export interface AdminCreditRecordSummary {
outputTokens: number;
}
export interface AdminCreditRecordAllocation {
id: string;
creditBalanceId: string;
sourceAllocationId?: string | null;
allocationAction: string;
amount: number;
creditLevel?: string;
sourceType?: string;
sourceId?: string;
validFrom?: string;
expiresAt?: string;
unspentBefore?: number;
unspentAfter?: number;
consumedBefore?: number;
consumedAfter?: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -901,6 +918,8 @@ export interface AdminCreditRecord {
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
balanceDelta?: number;
expiredAmount?: number;
description?: string;
relatedId?: string;
bizKey?: string;
@@ -920,6 +939,12 @@ export interface AdminCreditRecord {
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sceneNameSnapshot?: string;
requestTime?: string;
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
@@ -1426,3 +1451,94 @@ export interface VideoUpscaleConfigSavePayload {
}>;
};
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'credit_addon';
export type SubscriptionBillingCycle = 'monthly' | 'quarterly' | 'yearly';
export interface CreditProduct {
id: string;
productCode: string;
productType: CreditProductType;
name: string;
description?: string | null;
features?: string[];
tierCode?: string | null;
tierRank?: number | null;
billingCycle?: SubscriptionBillingCycle | null;
monthlyGrantCredits?: number;
grantCount?: number;
firstPurchasePrice?: number;
regularPrice?: number;
activityPrice?: number | null;
activityStartAt?: string | null;
activityEndAt?: string | null;
renewalEnabled: boolean;
price: number;
grantCredits?: number;
validityMonths?: number | null;
creditLevel: 'promotional' | 'general';
currency: string;
isActive: boolean;
sortOrder: number;
}
export interface LlmBillingPolicy {
id: string;
sceneCode: string;
sceneName: string;
preDeductCredits: number;
isActive: boolean;
version: number;
createdBy?: string | null;
updatedBy?: string | null;
createdAt?: string;
updatedAt?: string;
}
export interface LlmCallAttempt {
id: string;
callSequence: number;
retrySequence: number;
modelConfigId?: string | null;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
providerRequestId?: string | null;
requestStartedAt: string;
responseReceivedAt?: string | null;
durationMs?: number | null;
status: string;
inputTokens?: number | null;
outputTokens?: number | null;
totalTokens?: number | null;
httpStatus?: number | null;
errorMessage?: string | null;
tokenUnavailableReason?: string | null;
postprocessStatus?: string | null;
postprocessError?: string | null;
}
export interface LlmBillingExecution {
id: string;
userId: string;
sceneCode: string;
sceneNameSnapshot: string;
ownerType: string;
ownerId: string;
businessAttemptNo: number;
modelNameSnapshot?: string | null;
providerSnapshot?: string | null;
requestTime: string;
preDeductCredits: number;
status: string;
totalCallCount: number;
successfulCallCount: number;
failedCallCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
refundAvailableCredits: number;
refundExpiredCredits: number;
finalErrorMessage?: string | null;
calls: LlmCallAttempt[];
}
+20
View File
@@ -36,3 +36,23 @@ export function formatDate(iso: string | null | undefined): string {
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
export function formatDatePrecise(iso: string | null | undefined): string {
if (!iso) return '-';
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
const s = iso.trim();
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
let target = utcMs;
if (tz === 'Z') target += CST_OFFSET * 60000;
else if (tz) {
const sign = tz[0] === '+' ? 1 : -1;
const compact = tz.slice(1).replace(':', '');
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
}
const d = new Date(target);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
}