会员积分改版V1
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user