团队积分V1

This commit is contained in:
2026-08-14 15:23:46 +08:00
parent 516106c045
commit fac54b5667
70 changed files with 6670 additions and 4649 deletions
+36 -84
View File
@@ -209,6 +209,13 @@ export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize
return api.get(`/admin/users?${params.toString()}`);
}
export async function getAdminTeamDetail(teamId: string): Promise<AdminTeam> { return api.get(`/admin/teams/${teamId}`); }
export async function getAdminTeamSubscriptions(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/subscriptions`); }
export async function getAdminTeamMemberUsage(teamId: string, subscriptionId?: string): Promise<any[]> {
const q = subscriptionId ? `?subscription_id=${encodeURIComponent(subscriptionId)}` : ''; return api.get(`/admin/teams/${teamId}/member-usage${q}`);
}
export async function getAdminTeamManagerHistory(teamId: string): Promise<any[]> { return api.get(`/admin/teams/${teamId}/manager-history`); }
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
@@ -427,6 +434,7 @@ export async function getCreditRecords(filters?: AdminCreditRecordQueryParams):
setMaybe(params, 'user_type', filters?.userType);
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
setMaybe(params, 'team_id', filters?.teamId);
setMaybe(params, 'subscription_no', filters?.subscriptionNo);
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
setMaybe(params, 'credit_subject', filters?.creditSubject);
setMaybe(params, 'media_type', filters?.mediaType);
@@ -632,42 +640,23 @@ export async function batchUpdatePaymentConfigs(configs: Record<string, string>)
await api.put('/admin/payment-configs/batch', configs);
}
export async function getPaymentStats(params?: {
paymentMethod?: string;
status?: string;
startDate?: string;
endDate?: string;
}): Promise<{
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: any[];
}> {
const searchParams = new URLSearchParams();
if (params?.paymentMethod) searchParams.set('payment_method', params.paymentMethod);
if (params?.status) searchParams.set('status', params.status);
if (params?.startDate) searchParams.set('start_date', params.startDate);
if (params?.endDate) searchParams.set('end_date', params.endDate);
const queryString = searchParams.toString();
const url = queryString ? `/admin/payment-stats?${queryString}` : '/admin/payment-stats';
return api.get(url);
export async function getPaymentStats(params?: { paymentMethod?: string; orderSource?: string; status?: string; startDate?: string; endDate?: string; }): Promise<any> {
const q = new URLSearchParams();
if (params?.paymentMethod) q.set('payment_method', params.paymentMethod); if (params?.orderSource) q.set('order_source', params.orderSource);
if (params?.status) q.set('status', params.status); if (params?.startDate) q.set('start_date', params.startDate); if (params?.endDate) q.set('end_date', params.endDate);
return api.get(`/admin/payment-stats${q.toString() ? `?${q.toString()}` : ''}`);
}
export async function getAdminPaymentOrders(params?: { method?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
export async function getAdminPaymentOrders(params?: { method?: string; orderSource?: string; status?: string; phone?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.method) qs.set('payment_method', params.method);
if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone);
if (params?.startDate) qs.set('start_date', params.startDate);
if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
if (params?.method) qs.set('payment_method', params.method); if (params?.orderSource) qs.set('order_source', params.orderSource); if (params?.status) qs.set('status', params.status);
if (params?.phone) qs.set('phone', params.phone); if (params?.startDate) qs.set('start_date', params.startDate); if (params?.endDate) qs.set('end_date', params.endDate);
if (params?.page) qs.set('page', String(params.page)); if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/admin/payment-orders?${qs.toString()}`);
}
export async function refundPaymentOrder(orderNo: string): Promise<void> {
await api.post(`/admin/payment-orders/${orderNo}/refund`);
}
// 退款接口后端仍保留用于兼容旧调用,但当前版本固定返回“暂未开放订单退款”。
export async function refundPaymentOrder(orderNo: string): Promise<void> { await api.post(`/admin/payment-orders/${orderNo}/refund`); }
// ── Invoice Management ───────────────────────────────────
@@ -1371,63 +1360,26 @@ export async function adminUpdatePrivatePortraitConfig(userId: string, limit: nu
// ── 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,
};
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, isDeleted: raw.isDeleted === 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 getCreditProducts(productType?: 'subscription' | 'team_subscription' | 'credit_addon'): Promise<CreditProduct[]> {
const query = productType ? `?product_type=${encodeURIComponent(productType)}` : ''; return (await api.get<CreditProduct[]>(`/admin/credit-management/products${query}`)).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);
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 setCreditProductStatus(id: string, isActive: boolean): Promise<CreditProduct> { return normalizeCreditProduct(await api.put<CreditProduct>(`/admin/credit-management/products/${id}/status`, { is_active: isActive })); }
export async function softDeleteCreditProduct(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 getAdminUserSubscriptions(userId: string): Promise<any[]> { return api.get(`/admin/credit-management/users/${userId}/subscriptions`); }
export async function createAdminOfflineSubscription(userId: string, payload: { productId: string; quantity: number; paymentMethod: 'bank_transfer' | 'cash' | 'other'; actualPaidAmount?: number; offlineTradeNo?: string; offlinePaymentDetail?: string; remark?: string; }): Promise<any> {
return api.post(`/admin/credit-management/users/${userId}/offline-subscriptions`, { product_id: payload.productId, quantity: payload.quantity, payment_method: payload.paymentMethod, actual_paid_amount: payload.actualPaidAmount, offline_trade_no: payload.offlineTradeNo || null, offline_payment_detail: payload.offlinePaymentDetail || null, remark: payload.remark || null });
}
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 场景积分配置 ───────────────────────────────────────
export async function getLlmBillingPolicies(): Promise<import('../types').LlmBillingPolicy[]> {
+129 -57
View File
@@ -3,10 +3,17 @@ 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 {
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import {
createCreditProduct, disableCreditProduct, getCreditProducts, setCreditProductRenewal, updateCreditProduct,
createCreditProduct,
getCreditProducts,
setCreditProductRenewal,
setCreditProductStatus,
softDeleteCreditProduct,
updateCreditProduct,
} from '../api';
import type { CreditProduct, CreditProductType } from '../types';
@@ -17,6 +24,8 @@ const formatMoney = (value?: number | null): string => {
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
};
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
const AdminCreditProducts: React.FC = () => {
const [type, setType] = useState<CreditProductType>('subscription');
const [items, setItems] = useState<CreditProduct[]>([]);
@@ -85,7 +94,7 @@ const AdminCreditProducts: React.FC = () => {
sortOrder: 0,
billingCycle: 'monthly',
tierRank: 1,
renewalEnabled: nextRenewalEnabled,
renewalEnabled: true,
validityMonths: 1,
});
};
@@ -95,8 +104,6 @@ const AdminCreditProducts: React.FC = () => {
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 || '')
@@ -105,14 +112,23 @@ const AdminCreditProducts: React.FC = () => {
.filter(Boolean),
credit_level: 'general',
currency: 'CNY',
is_active: values.isActive ?? true,
sort_order: values.sortOrder ?? 0,
};
if (type === 'subscription') {
if (!editing) {
payload.product_code = values.productCode;
payload.product_type = type;
payload.is_active = values.isActive ?? true;
}
if (isSubscriptionType(type)) {
if (!editing) {
Object.assign(payload, {
tier_code: values.tierCode,
tier_rank: values.tierRank,
billing_cycle: values.billingCycle,
});
}
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,
@@ -129,11 +145,16 @@ const AdminCreditProducts: React.FC = () => {
validity_months: values.validityMonths,
});
}
const savedProduct = editing
let savedProduct = editing
? await updateCreditProduct(editing.id, payload)
: await createCreditProduct(payload);
if (type === 'subscription' && savedProduct.renewalEnabled !== renewalEnabled) {
if (editing && savedProduct.isActive !== (values.isActive === true)) {
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
}
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
}
@@ -190,7 +211,27 @@ const AdminCreditProducts: React.FC = () => {
}
};
const columns = useMemo(() => type === 'subscription' ? [
const toggleStatus = async (row: CreditProduct) => {
try {
const saved = await setCreditProductStatus(row.id, !row.isActive);
replaceItem(saved);
message.success(saved.isActive ? '已上架' : '已下架');
} catch (error: any) {
message.error(error?.message || '商品状态更新失败');
}
};
const removeProduct = async (row: CreditProduct) => {
try {
await softDeleteCreditProduct(row.id);
message.success('商品已软删除,商品编码永久保留且不能恢复');
await load();
} catch (error: any) {
message.error(error?.message || '商品删除失败');
}
};
const columns = useMemo(() => isSubscriptionType(type) ? [
{
title: '套餐',
key: 'name',
@@ -234,36 +275,45 @@ const AdminCreditProducts: React.FC = () => {
{
title: '允许续费',
dataIndex: 'renewalEnabled',
render: (enabled: boolean, row: CreditProduct) => (
<Switch
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary">-</Typography.Text>
: <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>,
key: 'status',
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</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>,
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
] : [
{
@@ -277,37 +327,53 @@ const AdminCreditProducts: React.FC = () => {
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
{ title: '状态', dataIndex: 'isActive', render: (enabled: boolean) => <Tag color={enabled ? 'green' : 'default'}>{enabled ? '上架' : '下架'}</Tag> },
{
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
</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>,
render: (_: unknown, row: CreditProduct) => row.isDeleted
? <Typography.Text type="secondary"></Typography.Text>
: <Space>
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}></Button>
<Popconfirm
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
onConfirm={() => void toggleStatus(row)}
>
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{row.isActive ? '下架' : '上架'}
</Button>
</Popconfirm>
<Popconfirm
title="确认软删除该商品?"
description="软删除后不能恢复,商品编码永久保留且不能复用。"
onConfirm={() => void removeProduct(row)}
>
<Button type="link" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>,
},
], [type, items]);
], [type, items, editing]);
const createButtonLabel = type === 'subscription'
? '新增个人订阅套餐'
: type === 'team_subscription'
? '新增团队订阅套餐'
: '新增积分增值包';
return <Card
title={<Space><ShoppingOutlined /></Space>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>
{type === 'subscription' ? '新增订阅套餐' : '新增积分增值包'}
</Button>}
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
>
<Tabs
activeKey={type}
onChange={(key) => setType(key as CreditProductType)}
items={[
{ key: 'subscription', label: '订阅套餐' },
{ key: 'subscription', label: '个人订阅套餐' },
{ key: 'team_subscription', label: '团队订阅套餐' },
{ key: 'credit_addon', label: '积分增值包' },
]}
/>
@@ -322,7 +388,12 @@ const AdminCreditProducts: React.FC = () => {
>
<Form form={form} layout="vertical">
<Space align="start" style={{ width: '100%' }} size={16}>
<Form.Item name="productCode" label="商品编码" rules={[{ required: true }]}>
<Form.Item
name="productCode"
label="商品编码"
rules={[{ required: true }]}
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
>
<Input disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
@@ -331,12 +402,13 @@ const AdminCreditProducts: React.FC = () => {
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
</Space>
{type === 'subscription' ? <>
{isSubscriptionType(type) ? <>
<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="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
<Select
disabled={!!editing}
style={{ width: 140 }}
options={[
{ value: 'monthly', label: '月' },
@@ -349,7 +421,7 @@ const AdminCreditProducts: React.FC = () => {
<InputNumber min={0.01} />
</Form.Item>
</Space>
<Space align="start" size={16}>
<Space align="start" size={16} wrap>
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} />
</Form.Item>
@@ -360,7 +432,7 @@ const AdminCreditProducts: React.FC = () => {
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
<Form.Item
label="允许续费"
tooltip="关闭后,已失去首订资格的用户不会在客户端看到该套餐,也不能通过接口续费或升级购买。"
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
>
<Switch
checked={renewalEnabled}
+107 -11
View File
@@ -8,7 +8,7 @@ import {
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
import dayjs from 'dayjs';
import { getCreditRecords, getTeamOptions } from '../api';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -182,6 +182,44 @@ function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType
return {};
}
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
const label = item.tierLabel || item.tierCode || '未知等级';
const code = item.tierCode && item.tierCode !== label ? `${item.tierCode}` : '';
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
return `${label}${code}${rank}`;
}
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
return [
item.productTypeLabel,
item.productName,
subscriptionTierText(item),
item.billingCycleLabel,
].filter(Boolean).join(' · ');
}
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
const range = item.periodValidFrom && item.periodExpiresAt
? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}`
: '-';
return `${item.periodLabel || '周期未知'}${range}`;
}
function semanticAllocationText(record: AdminCreditRecord): string {
return (record.allocations || []).map((item) => {
const tier = item.tierLabel || item.tierCode
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `${item.tierCode}` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
: '未知等级';
const subscription = item.subscriptionNo
? `${item.subscriptionNo}${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
: '非订阅资金';
const period = item.periodLabel
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}${formatDate(item.periodExpiresAt)}` : ''}`
: '无月度周期';
return `${item.creditScopeLabel || '其他资金域'}${item.allocationActionLabel || '资金变动'}${subscription}${period}${n(item.amount)}积分`;
}).join('');
}
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
@@ -194,6 +232,7 @@ const AdminCreditRecords: React.FC = () => {
const [userScope, setUserScope] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
const [recordType, setRecordType] = useState('');
const [creditSubject, setCreditSubject] = useState('');
@@ -211,6 +250,7 @@ const AdminCreditRecords: React.FC = () => {
pageSize,
userName: userNameFilter || undefined,
teamId: teamFilter || undefined,
subscriptionNo: subscriptionNoFilter || undefined,
recordType: recordType || undefined,
creditSubject: creditSubject || undefined,
mediaType: mediaType || undefined,
@@ -222,7 +262,7 @@ const AdminCreditRecords: React.FC = () => {
startDate: dateRange[0]?.format('YYYY-MM-DD'),
endDate: dateRange[1]?.format('YYYY-MM-DD'),
...buildScope(userScope),
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
const load = async () => {
setLoading(true);
@@ -247,6 +287,7 @@ const AdminCreditRecords: React.FC = () => {
const handleReset = () => {
setUserScope('');
setTeamFilter('');
setSubscriptionNoFilter('');
setRecordType('');
setCreditSubject('');
setMediaType('');
@@ -285,6 +326,12 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join('') || '-' },
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionProductText(item)}`).join('') || '-' },
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}${subscriptionPeriodText(item)}`).join('') || '-' },
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
@@ -308,7 +355,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.sourceTypeLabel || a.sourceType || '-'}:${a.sourceId || '-'}=${a.amount}`).join('') },
{ title: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
];
@@ -319,6 +366,7 @@ const AdminCreditRecords: React.FC = () => {
title: '积分流水汇总',
metadataRows: [
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'}${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
['订阅实例筛选', subscriptionNoFilter || '不限'],
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
['导出条数', totalRows],
],
@@ -356,6 +404,45 @@ const AdminCreditRecords: React.FC = () => {
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary"></Typography.Text> },
{
title: '资金构成', key: 'fundingScope', width: 190,
render: (_: any, r: AdminCreditRecord) => (
<Space size={[4, 4]} wrap>
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue"> {n(r.teamAllocationAmount)}</Tag>}
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple"> {n(r.personalAllocationAmount)}</Tag>}
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary"></Typography.Text>}
</Space>
),
},
{
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · {n(item.amount)} </div>
</div>
))}
</div>
) : <Typography.Text type="secondary"></Typography.Text>,
},
{
title: '月度周期', key: 'subscriptionPeriods', width: 300,
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
<div>
{(r.subscriptionUsages || []).map((item, index) => (
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
<div style={{ fontSize: 12, color: '#64748b' }}>
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ${formatDate(item.periodExpiresAt)}` : '-'}
</div>
</div>
))}
</div>
) : '-',
},
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary"></Typography.Text>; } },
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
@@ -442,6 +529,7 @@ const AdminCreditRecords: React.FC = () => {
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
]}
/>
<Input placeholder="订阅实例号(PS/TS" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
@@ -482,18 +570,26 @@ const AdminCreditRecords: React.FC = () => {
rowKey="id"
dataSource={record.allocations || []}
columns={[
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string, r: any) => v || r.allocationAction || '-' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string, r: any) => v || r.sourceType || '-' },
{ 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) : '-' },
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `${a.tierCode}` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ${formatDate(a.periodExpiresAt)}` : '-' },
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ${formatDate(a.expiresAt)}` : '-' },
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
]}
scroll={{ x: 1070 }}
scroll={{ x: 2390 }}
/>
),
}}
scroll={{ x: 2520 }}
scroll={{ x: 3350 }}
/>
</Card>
</div>
+61 -311
View File
@@ -1,324 +1,74 @@
import React, { useEffect, useState } from 'react';
import {
Card, Col, Input, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
} from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import {
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined, SearchOutlined
} from '@ant-design/icons';
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
import { formatDate } from '../utils/formatDate';
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { getAdminPaymentOrders, getPaymentStats } from '../api';
const { Option } = Select;
const { RangePicker } = DatePicker;
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
const METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败' };
const AdminPaymentStats: React.FC = () => {
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
const [orderPage, setOrderPage] = useState(1);
const [orderPageSize, setOrderPageSize] = useState(10);
const [orderTotal, setOrderTotal] = useState(0);
const [filters, setFilters] = useState<{
paymentMethod?: string;
status?: string;
phone?: string;
startDate: string;
endDate: string;
}>({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
const [stats, setStats] = useState<any>(null);
const [orders, setOrders] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [filters, setFilters] = useState<any>({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') });
const load = async () => {
try {
setLoading(true);
const [statsData, ordersData] = await Promise.all([
getPaymentStats(filters),
getAdminPaymentOrders({
...filters,
phone: filters.phone,
page: orderPage,
pageSize: orderPageSize,
}),
]);
setStats({ ...statsData, recent: ordersData.items || [] });
setOrderTotal(ordersData.total);
} catch {
message.error('加载支付统计失败');
} finally {
setLoading(false);
}
};
const load = useCallback(async () => {
setLoading(true);
try {
const [s, o] = await Promise.all([
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
]);
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
} finally { setLoading(false); }
}, [filters, page, pageSize]);
useEffect(() => { void load(); }, [load]);
useEffect(() => { load(); }, [filters, orderPage, orderPageSize]);
const columns = [
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
{ title: '用户', width: 150, render: (_: any, r: any) => <div>{r.username || '-'}<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
{ title: '来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
{ title: '商品', dataIndex: 'productNameSnapshot', width: 170, ellipsis: true, render: (v: string, r: any) => `${v || '-'}${Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}` },
{ title: '支付方式', dataIndex: 'paymentMethod', width: 135, render: (v: string, r: any) => r.paymentMethodLabel || METHOD_LABELS[v] || '其他支付方式' },
{ title: '系统报价', dataIndex: 'quotedAmountSnapshot', width: 110, align: 'right' as const, render: (v: number) => v == null ? '-' : `¥${Number(v).toFixed(2)}` },
{ title: '实际成交', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <b>¥{Number(v || 0).toFixed(2)}</b> },
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
{ title: '履约', dataIndex: 'fulfillmentStatusLabel', width: 100, render: (v: string) => v || '-' },
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
// 当前版本退款按钮明确不开放。后端退款 API 仍保留兼容入口并固定返回中文阻止提示,后续重新开放时再恢复此处按钮。
];
const handleOrderPageChange = (p: number, ps: number) => {
setOrderPage(p);
setOrderPageSize(ps);
};
const reset = () => { setFilters({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') }); setPage(1); };
const today = stats?.today || {};
const month = stats?.month || {};
const bySource = stats?.bySource || {};
const handleReset = () => {
setFilters({
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setOrderPage(1);
};
const handleRefund = async (orderNo: string) => {
try {
setLoading(true);
await refundPaymentOrder(orderNo);
message.success('退款成功');
await load();
} catch (e: any) {
message.error(e?.response?.data?.detail || '退款失败');
} finally {
setLoading(false);
}
};
const handleDateChange = (dates: any) => {
if (dates && dates.length === 2) {
setFilters(prev => ({
...prev,
startDate: dates[0].format('YYYY-MM-DD'),
endDate: dates[1].format('YYYY-MM-DD'),
}));
}
};
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
};
const methodConfig: Record<string, { color: string; label: string }> = {
alipay: { color: 'blue', label: '支付宝' },
wechat: { color: 'green', label: '微信' },
};
const columns = [
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
render: (m: string) => {
const c = methodConfig[m] || { color: 'default', label: m };
return <Tag color={c.color}>{c.label}</Tag>;
},
},
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
},
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => {
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
},
},
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
{
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
},
{
title: '操作',
key: 'action',
width: 120,
render: (_: any, record: any) => {
if (record.status === 'paid') {
return (
<Popconfirm
title="确认要退款该订单吗?"
description="退款后积分会扣除,金额会原路返回"
onConfirm={() => handleRefund(record.orderNo)}
okText="确认"
cancelText="取消"
>
<Button type="link" danger size="small" icon={<UndoOutlined />}>
退
</Button>
</Popconfirm>
);
}
return null;
},
},
];
if (!stats) {
return <div style={{ padding: 24, color: '#94a3b8' }}></div>;
}
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
const monthInfo = stats.month || { count: 0, amount: 0 };
return (
<ConfigProvider locale={zhCN}>
<div>
{/* Summary cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="今日收入"
value={stats.today.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
suffix="元"
valueStyle={{ color: '#10b981', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{stats.today.paidCount}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={12}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Statistic
title="本月累计"
value={monthInfo.paidAmount}
precision={2}
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
suffix="元"
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{monthInfo.paidCount}
</Typography.Text>
</Card>
</Col>
</Row>
{/* Status breakdown */}
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<Space><DollarOutlined /></Space>}>
<Row gutter={16}>
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
const c = statusConfig[s];
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
return (
<Col span={6} key={s}>
<div style={{
padding: 16, borderRadius: 10,
background: '#fafbff', border: '1px solid #f0f0f5',
}}>
<Space>
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
</Space>
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
¥{info.amount.toFixed(2)}
</div>
</div>
</Col>
);
})}
</Row>
</Card>
{/* Recent orders table */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
title={<Space><DollarOutlined /></Space>}>
{/* Filters */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.paymentMethod}
onChange={(value) => { setFilters(prev => ({ ...prev, paymentMethod: value })); setOrderPage(1); }}
>
<Option value="alipay"></Option>
<Option value="wechat"></Option>
</Select>
</Col>
<Col xs={24} sm={8} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Select
placeholder="全部"
allowClear
style={{ width: '100%' }}
value={filters.status}
onChange={(value) => { setFilters(prev => ({ ...prev, status: value })); setOrderPage(1); }}
>
<Option value="paid"></Option>
<Option value="pending"></Option>
<Option value="cancelled"></Option>
<Option value="refunded">退</Option>
</Select>
</Col>
<Col xs={24} sm={8} md={5}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<Input
placeholder="搜索手机号"
allowClear
value={filters.phone}
onChange={(e) => setFilters(prev => ({ ...prev, phone: e.target.value }))}
onPressEnter={() => { setOrderPage(1); load(); }}
suffix={<SearchOutlined style={{ color: '#94a3b8' }} />}
/>
</Col>
<Col xs={24} sm={12} md={7}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}></Typography.Text>
<RangePicker
style={{ width: '100%' }}
value={[
dayjs(filters.startDate),
dayjs(filters.endDate),
]}
onChange={handleDateChange}
/>
</Col>
<Col xs={24} sm={12} md={4} style={{ display: 'flex', alignItems: 'flex-end' }}>
<Button icon={<ReloadOutlined />} onClick={handleReset} style={{ marginBottom: 0 }}>
</Button>
</Col>
</Row>
<Table
columns={columns}
dataSource={stats.recent || []}
rowKey="id"
loading={loading}
pagination={{
current: orderPage,
pageSize: orderPageSize,
total: orderTotal,
onChange: handleOrderPageChange,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 1000 }}
size="middle"
/>
</Card>
</div>
</ConfigProvider>
);
return <ConfigProvider locale={zhCN}><div>
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
<Col xs={24} md={8}><Card><Statistic title="今日总真实收入" value={Number(today.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(today.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(today.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="本月总真实收入" value={Number(month.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线 {Number(month.onlinePaidAmount || 0).toFixed(2)} · 线 {Number(month.offlinePaidAmount || 0).toFixed(2)} </Typography.Text></Card></Col>
<Col xs={24} md={8}><Card><Statistic title="当前筛选总收入" value={Number(stats?.totalIncome?.amount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">{Number(stats?.totalIncome?.count || 0)} </Typography.Text></Card></Col>
</Row>
<Card title="收入来源" style={{ marginBottom: 16 }}><Row gutter={16}>{['online_payment','admin_offline'].map((source) => <Col span={12} key={source}><div style={{ padding: 14, background: '#fafafa', borderRadius: 10 }}><Tag color={source === 'online_payment' ? 'blue' : 'purple'}>{bySource[source]?.label || SOURCE_LABELS[source]}</Tag><div style={{ fontSize: 24, fontWeight: 700, marginTop: 8 }}>¥{Number(bySource[source]?.amount || 0).toFixed(2)}</div><div style={{ color: '#94a3b8' }}>{Number(bySource[source]?.count || 0)} </div></div></Col>)}</Row></Card>
<Card title="订单列表">
<Space wrap style={{ marginBottom: 16 }}>
<Select allowClear placeholder="订单来源" style={{ width: 160 }} value={filters.orderSource} onChange={(v) => { setFilters((x:any) => ({...x, orderSource:v})); setPage(1); }} options={Object.entries(SOURCE_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="支付方式" style={{ width: 150 }} value={filters.paymentMethod} onChange={(v) => { setFilters((x:any) => ({...x, paymentMethod:v})); setPage(1); }} options={Object.entries(METHOD_LABELS).map(([value,label]) => ({value,label}))} />
<Select allowClear placeholder="订单状态" style={{ width: 130 }} value={filters.status} onChange={(v) => { setFilters((x:any) => ({...x, status:v})); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value,label]) => ({value,label}))} />
<Input allowClear placeholder="手机号" style={{ width: 150 }} value={filters.phone} onChange={(e) => setFilters((x:any) => ({...x, phone:e.target.value}))} onPressEnter={() => { setPage(1); void load(); }} suffix={<SearchOutlined />} />
<RangePicker value={[dayjs(filters.startDate), dayjs(filters.endDate)]} onChange={(v) => { setFilters((x:any) => ({...x, startDate:v?.[0]?.format('YYYY-MM-DD'), endDate:v?.[1]?.format('YYYY-MM-DD')})); setPage(1); }} />
<Button icon={<ReloadOutlined />} onClick={reset}></Button>
</Space>
<Table rowKey="id" loading={loading} columns={columns} dataSource={orders} scroll={{ x: 1450 }} pagination={{ current:page, pageSize, total, showSizeChanger:true, showTotal:(t)=>`${t}`, onChange:(p,ps)=>{setPage(p);setPageSize(ps);} }} />
</Card>
</div></ConfigProvider>;
};
export default AdminPaymentStats;
+66 -365
View File
@@ -1,378 +1,79 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
import {
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
} from '../api';
import type { AdminTeam, AdminUser } from '../types';
import { formatDate } from '../utils/formatDate';
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
const editStatusOptions = [
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '禁用' },
];
function statusLabel(status?: string): string {
if (status === 'active') return '启用';
if (status === 'disabled') return '禁用';
return status || '-';
}
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
const AdminTeams: React.FC = () => {
const [items, setItems] = useState<AdminTeam[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('');
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
const [form] = Form.useForm();
const [items, setItems] = useState<AdminTeam[]>([]); const [loading,setLoading]=useState(false); const [page,setPage]=useState(1); const [pageSize,setPageSize]=useState(20); const [total,setTotal]=useState(0);
const [keyword,setKeyword]=useState(''); const [status,setStatus]=useState<string>(); const [form]=Form.useForm(); const [edit,setEdit]=useState<AdminTeam|null>(null); const [editOpen,setEditOpen]=useState(false);
const [detail,setDetail]=useState<AdminTeam|null>(null); const [members,setMembers]=useState<AdminUser[]>([]); const [subscriptions,setSubscriptions]=useState<any[]>([]); const [usage,setUsage]=useState<any[]>([]); const [history,setHistory]=useState<any[]>([]); const [detailLoading,setDetailLoading]=useState(false);
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
const load=useCallback(async()=>{setLoading(true);try{const r=await getAdminTeams(query);setItems(r.items||[]);setTotal(Number(r.total||0));}catch(e:any){message.error(e?.message||'团队列表加载失败');}finally{setLoading(false);}},[query]);
useEffect(()=>{void load();},[load]);
// 管理人弹窗状态
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
const [managerLoading, setManagerLoading] = useState(false);
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
const openEdit=(team?:AdminTeam)=>{const item=team||null;setEdit(item);form.resetFields();form.setFieldsValue(item?{name:item.name,code:item.code,description:item.description,status:item.status,sortOrder:item.sortOrder}:{status:'active',sortOrder:0});setEditOpen(true);};
const save=async()=>{try{const v=await form.validateFields();await saveAdminTeam({id:edit?.id,name:v.name,code:v.code||null,description:v.description||null,status:v.status,sort_order:Number(v.sortOrder||0)});message.success(edit?'团队已更新':'团队已创建');setEditOpen(false);await load();}catch(e:any){if(!e?.errorFields)message.error(e?.message||'保存失败');}};
const openDetail=async(team:AdminTeam)=>{setDetail(team);setDetailLoading(true);try{const [m,s,u,h]=await Promise.all([getTeamMembersForAdmin(team.id,1,500),getAdminTeamSubscriptions(team.id),getAdminTeamMemberUsage(team.id),getAdminTeamManagerHistory(team.id)]);setMembers(m.items||[]);setSubscriptions(s||[]);setUsage(u||[]);setHistory(h||[]);}catch(e:any){message.error(e?.message||'团队详情加载失败');}finally{setDetailLoading(false);}};
// 查看成员弹窗状态
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
const [membersLoading, setMembersLoading] = useState(false);
const query = useMemo(() => ({
page,
pageSize,
keyword: keyword || undefined,
status: status || undefined,
}), [page, pageSize, keyword, status]);
const load = async () => {
setLoading(true);
try {
const res = await getAdminTeams(query);
setItems(res.items || []);
setTotal(res.total || 0);
} catch (e: any) {
message.error(e?.message || '加载团队列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [query]);
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ status: 'active', sortOrder: 0 });
setModal({ open: true, item: null });
};
const openEdit = (item: AdminTeam) => {
form.setFieldsValue({
name: item.name,
code: item.code || '',
description: item.description || '',
status: item.status || 'active',
sortOrder: item.sortOrder || 0,
});
setModal({ open: true, item });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await saveAdminTeam({
id: modal.item?.id,
name: values.name,
code: values.code || null,
description: values.description || null,
status: values.status || 'active',
sort_order: values.sortOrder || 0,
});
message.success(modal.item ? '团队已更新' : '团队已创建');
setModal({ open: false, item: null });
form.resetFields();
load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: AdminTeam) => {
try {
await deleteAdminTeam(item.id);
message.success('团队已删除');
load();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
try {
const res = await getTeamMembersForAdmin(teamId);
return res.items || [];
} catch (e: any) {
message.error(e?.message || '加载成员失败');
return [];
}
};
const openManagerModal = async (team: AdminTeam) => {
setManagerModal({ open: true, team });
setSelectedManagerId(team.managerId || null);
setManagerLoading(true);
const data = await loadTeamMembers(team.id);
setManagerMembers(data);
setManagerLoading(false);
};
const openMembersModal = async (team: AdminTeam) => {
setMembersModal({ open: true, team });
setMembersLoading(true);
const data = await loadTeamMembers(team.id);
setTeamMembers(data);
setMembersLoading(false);
};
const handleSetManager = async () => {
if (!managerModal.team) return;
try {
setManagerLoading(true);
await setTeamManager(managerModal.team.id, selectedManagerId);
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
setManagerModal({ open: false, team: null });
load();
} catch (e: any) {
message.error(e?.message || '设置失败');
} finally {
setManagerLoading(false);
}
};
const columns = [
{
title: '团队名称',
dataIndex: 'name',
width: 220,
render: (v: string, r: AdminTeam) => (
<Space>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
<TeamOutlined />
</div>
<div>
<Typography.Text strong>{v}</Typography.Text>
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
</div>
</Space>
),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
},
{
title: '成员数',
dataIndex: 'memberCount',
width: 100,
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
},
{
title: '管理人',
key: 'manager',
width: 140,
render: (_: any, r: AdminTeam) => (
<Typography.Text style={{ fontSize: 13 }}>
{r.managerName || <span style={{ color: '#94a3b8' }}></span>}
</Typography.Text>
),
},
{
title: '排序',
dataIndex: 'sortOrder',
width: 90,
render: (v: number) => v ?? 0,
},
{
title: '备注',
dataIndex: 'description',
ellipsis: true,
render: (v: string) => v || '-',
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作',
key: 'action',
width: 230,
fixed: 'right' as const,
render: (_: any, r: AdminTeam) => (
<Space size={4}>
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}></Button>
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}></Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm
title="确定删除该团队?"
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
onConfirm={() => handleDelete(r)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
const columns=[
{title:'团队',dataIndex:'name',width:220,render:(v:string,r:AdminTeam)=><Space><TeamOutlined/><div><b>{v}</b><div style={{fontSize:12,color:'#94a3b8'}}>{r.code||'-'}</div></div></Space>},
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
{title:'排序',dataIndex:'sortOrder',width:80},
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
{title:'操作',fixed:'right' as const,width:220,render:(_:any,r:AdminTeam)=><Space><Button size="small" icon={<EyeOutlined/>} onClick={()=>openDetail(r)}></Button><Button size="small" icon={<EditOutlined/>} onClick={()=>openEdit(r)}>{r.status==='disabled'?'重新启用':'编辑'}</Button><Popconfirm title="确认软删除团队?" description="仅在无有效团队订阅、无未完成团队订单且无成员时允许删除。" onConfirm={async()=>{try{await deleteAdminTeam(r.id);message.success('团队已删除');await load();}catch(e:any){message.error(e?.message||'删除失败');}}}><Button 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, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Input
placeholder="搜索团队名称/编码/备注"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={keyword}
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
onPressEnter={load}
style={{ width: 260 }}
allowClear
/>
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
<Button icon={<ReloadOutlined />} onClick={load}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
const subColumns=[
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
];
const memberColumns=[{title:'用户名',dataIndex:'username'},{title:'手机号',dataIndex:'phone',render:(v:string)=>v||'-'},{title:'个人积分',dataIndex:'personalCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队可用',dataIndex:'teamAvailableCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队冻结',dataIndex:'teamFrozenCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'设为队长',render:(_:any,r:AdminUser)=>r.id===detail?.managerId? <Tag color="purple"></Tag>:<Popconfirm title="确认更换队长?" description="只有团队启用、所有团队订阅结束且没有未完成团队订单时才能更换。" onConfirm={async()=>{if(!detail)return;try{await setTeamManager(detail.id,r.id);message.success('队长已更换');await openDetail({...detail,managerId:r.id,managerName:r.username});await load();}catch(e:any){message.error(e?.message||'更换队长失败');}}}><Button size="small" disabled={detail?.status==='disabled'} icon={<UserSwitchOutlined/>}></Button></Popconfirm>}];
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 个团队`,
}}
scroll={{ x: 1100 }}
/>
</Card>
return <div><Card><Space wrap style={{marginBottom:16}}><Input allowClear value={keyword} onChange={e=>{setKeyword(e.target.value);setPage(1);}} placeholder="团队名称/编码/备注" prefix={<SearchOutlined/>} style={{width:260}}/><Select allowClear value={status} onChange={v=>{setStatus(v);setPage(1);}} placeholder="全部状态" style={{width:130}} options={[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/><Button icon={<ReloadOutlined/>} onClick={load}></Button><Button type="primary" icon={<PlusOutlined/>} onClick={()=>openEdit()}></Button></Space><Table rowKey="id" loading={loading} columns={columns} dataSource={items} scroll={{x:1250}} pagination={{current:page,pageSize,total,showSizeChanger:true,onChange:(p,ps)=>{setPage(p);setPageSize(ps);}}}/></Card>
<Modal
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
open={modal.open}
confirmLoading={saving}
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 maxLength={128} placeholder="请输入团队名称" size="large" />
</Form.Item>
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
<Select size="large" options={editStatusOptions} />
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}>
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
</Form.Item>
</Form>
</Modal>
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
<Form form={form} layout="vertical"><Form.Item name="name" label="团队名称" rules={[{required:true}]}><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="code" label="团队编码"><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="description" label="备注"><Input.TextArea rows={3} disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="status" label="状态" rules={[{required:true}]}><Select options={edit?.status==='disabled'?[{value:'active',label:'重新启用'}]:[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={999999} disabled={edit?.status==='disabled'} style={{width:'100%'}}/></Form.Item></Form>
</Modal>
{/* 设置管理人弹窗 */}
<Modal
title={<Space><UserOutlined /></Space>}
open={managerModal.open}
confirmLoading={managerLoading}
onOk={handleSetManager}
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ marginTop: 16 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
{managerModal.team?.name || '-'}
</Typography.Text>
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
</Typography.Text>
<Select
style={{ width: '100%' }}
placeholder="选择管理人(可清空取消)"
value={selectedManagerId}
onChange={(v) => setSelectedManagerId(v || null)}
allowClear
loading={managerLoading}
optionFilterProp="label"
options={managerMembers.map((m) => ({
value: m.id,
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
}))}
showSearch
/>
</div>
</Modal>
{/* 查看成员弹窗 */}
<Modal
title={<Space><UserOutlined /> - {membersModal.team?.name}</Space>}
open={membersModal.open}
onCancel={() => setMembersModal({ open: false, team: null })}
footer={null}
width={600}
>
<div style={{ marginTop: 8 }}>
{membersModal.team && (
<Table
size="small"
rowKey="id"
loading={membersLoading}
dataSource={teamMembers}
pagination={false}
scroll={{ y: 400 }}
columns={[
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
},
]}
locale={{ emptyText: '该团队暂无成员' }}
/>
)}
</div>
</Modal>
</div>
);
<Modal width={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
<Tabs items={[
{key:'subscriptions',label:'订阅与席位',children:<Table loading={detailLoading} rowKey={(r:any)=>r.subscription?.id} dataSource={subscriptions} columns={subColumns} pagination={false} scroll={{x:1120}} expandable={{expandedRowRender:(r:any)=><Table rowKey="id" size="small" pagination={false} dataSource={r.seats||[]} columns={[{title:'席位用户',dataIndex:'username'},{title:'月额度',dataIndex:'monthlyAllocatedCredits',align:'right' as const},{title:'本期净消耗',dataIndex:'currentPeriodUsedCredits',align:'right' as const},{title:'本期剩余',dataIndex:'currentPeriodRemainingCredits',align:'right' as const},{title:'状态',dataIndex:'statusLabel'}]}/>}}/>},
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
{key:'usage',label:'成员月净消耗',children:<Table
loading={detailLoading}
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
dataSource={usage}
pagination={false}
scroll={{x:1400}}
columns={[
{title:'成员',dataIndex:'username',width:120},
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
{title:'等级编码',width:190,render:(_:any,r:any)=><span>{r.tierLabel||'未知等级'}{r.tierCode?`${r.tierCode}`:''}{r.tierRank?` / ${r.tierRank}`:''}</span>},
{title:'套餐周期',dataIndex:'billingCycleLabel',width:110,render:(v:string)=><Tag>{v||'未知周期'}</Tag>},
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`${r.periodSequence}个月`:'历史周期')},
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ${fmt(r.periodExpiresAt)}`},
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
]}
/>},
{key:'history',label:'队长任期历史',children:<Table loading={detailLoading} rowKey="id" dataSource={history} pagination={false} columns={[{title:'队长',dataIndex:'managerName'},{title:'开始',dataIndex:'startedAt',render:(v:string)=>fmt(v)},{title:'结束',dataIndex:'endedAt',render:(v:string)=>v?fmt(v):<Tag color="green"></Tag>}]}/>},
]}/>
</Modal>
</div>;
};
export default AdminTeams;
+147 -9
View File
@@ -11,8 +11,10 @@ import {
adminGetPrivatePortraitConfig,
adminUpdatePrivatePortraitConfig,
createUser,
createAdminOfflineSubscription,
deleteUserResourceCapacity,
getAdminUsers,
getCreditProducts,
getAdminUserCreditBalances,
getAdminUserCreditSummary,
getMenuConfigs,
@@ -29,7 +31,7 @@ import {
updateUserMenus,
updateUserAdminStatus,
} from '../api';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, CreditProduct, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const TEAM_UNASSIGNED_VALUE = '__none__';
@@ -71,6 +73,9 @@ const AdminUsers: React.FC = () => {
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 [offlineModal, setOfflineModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [offlineProducts, setOfflineProducts] = useState<CreditProduct[]>([]);
const [offlineSaving, setOfflineSaving] = useState(false);
const [creditDetailLoading, setCreditDetailLoading] = useState(false);
const [creditSummary, setCreditSummary] = useState<any>(null);
const [creditBalances, setCreditBalances] = useState<any[]>([]);
@@ -100,6 +105,9 @@ const AdminUsers: React.FC = () => {
const [capacityForm] = Form.useForm();
const [teamForm] = Form.useForm();
const [portraitForm] = Form.useForm();
const [offlineForm] = Form.useForm();
const offlineProductId = Form.useWatch('productId', offlineForm);
const selectedOfflineProduct = offlineProducts.find((item) => item.id === offlineProductId);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
@@ -203,6 +211,56 @@ const AdminUsers: React.FC = () => {
}
};
const openOfflineModal = async (user: AdminUser) => {
try {
const products = await getCreditProducts();
const available = products.filter((item) =>
(item.productType === 'subscription' || item.productType === 'team_subscription')
&& item.isActive
&& !item.isDeleted
);
setOfflineProducts(available);
offlineForm.resetFields();
offlineForm.setFieldsValue({ quantity: 1, paymentMethod: 'bank_transfer' });
setOfflineModal({ open: true, user });
} catch (e: any) {
message.error(e?.message || '加载可成交套餐失败');
}
};
const handleOfflineSubscription = async () => {
const user = offlineModal.user;
if (!user) return;
try {
const values = await offlineForm.validateFields();
const product = offlineProducts.find((item) => item.id === values.productId);
if (!product) {
message.error('请选择有效套餐');
return;
}
const quantity = product.productType === 'team_subscription' ? Number(values.quantity || 2) : 1;
setOfflineSaving(true);
await createAdminOfflineSubscription(user.id, {
productId: product.id,
quantity,
paymentMethod: values.paymentMethod,
actualPaidAmount: values.actualPaidAmount === undefined || values.actualPaidAmount === null ? undefined : Number(values.actualPaidAmount),
offlineTradeNo: values.offlineTradeNo,
offlinePaymentDetail: values.offlinePaymentDetail,
remark: values.remark,
});
message.success('线下订阅成交已完成,订单、套餐与首期权益已同步生效');
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
await load();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '线下订阅成交失败');
} finally {
setOfflineSaving(false);
}
};
const openCreditDetailModal = async (user: AdminUser, status = '') => {
setCreditDetailModal({ open: true, user });
setCreditBalanceStatus(status);
@@ -480,11 +538,15 @@ const AdminUsers: React.FC = () => {
),
},
...(!isAdminTab ? [{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
title: '积分余额', dataIndex: 'credits', width: 210, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number, r: AdminUser) => (
<div style={{ lineHeight: 1.7 }}>
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> {Number(v || 0).toLocaleString()}</Typography.Text>
<div style={{ color: '#64748b', fontSize: 12 }}>
{Number(r.personalCredits || 0).toLocaleString()} / {Number(r.teamAvailableCredits || 0).toLocaleString()}
</div>
{Number(r.teamFrozenCredits || 0) > 0 && <div style={{ color: '#f59e0b', fontSize: 12 }}> {Number(r.teamFrozenCredits || 0).toLocaleString()}</div>}
</div>
),
}] : []),
{
@@ -551,7 +613,7 @@ const AdminUsers: React.FC = () => {
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
title: '操作', key: 'action', width: 650, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4} wrap>
{!isAdminTab && (
@@ -570,6 +632,11 @@ const AdminUsers: React.FC = () => {
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => openOfflineModal(r)}>
线
</Button>
)}
{!isAdminTab && (
<Button type="link" size="small" icon={<DatabaseOutlined />}
onClick={() => openCapacityModal(r)}>
@@ -806,6 +873,74 @@ const AdminUsers: React.FC = () => {
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined />线 - {offlineModal.user?.username}</Space>}
open={offlineModal.open}
onOk={handleOfflineSubscription}
confirmLoading={offlineSaving}
okText="确认成交"
cancelText="取消"
width={620}
onCancel={() => {
setOfflineModal({ open: false, user: null });
offlineForm.resetFields();
}}
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 使 0
</Typography.Paragraph>
<Form form={offlineForm} layout="vertical" initialValues={{ quantity: 1, paymentMethod: 'bank_transfer' }}>
<Form.Item name="productId" label="成交套餐" rules={[{ required: true, message: '请选择成交套餐' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="请选择个人订阅或团队订阅套餐"
options={offlineProducts.map((item) => ({
value: item.id,
label: `${item.productType === 'team_subscription' ? '团队订阅' : '个人订阅'}${item.name}`,
}))}
onChange={(value) => {
const product = offlineProducts.find((item) => item.id === value);
offlineForm.setFieldValue('quantity', product?.productType === 'team_subscription' ? 2 : 1);
}}
/>
</Form.Item>
{selectedOfflineProduct?.productType === 'team_subscription' && (
<Form.Item
name="quantity"
label="团队席位数量"
rules={[{ required: true, message: '请输入团队席位数量' }]}
extra="后台单张团队订阅允许 2~1000 席;成交后数量永久按订单快照固定。"
>
<InputNumber min={2} max={1000} precision={0} style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item
name="actualPaidAmount"
label="实际成交总额(元)"
extra="留空使用后端定价器计算出的系统报价;填写后以该整单总额作为实际收入。0 元属于合法真实成交,不按赠送处理。"
>
<InputNumber min={0} precision={2} stringMode style={{ width: '100%' }} placeholder="留空则使用系统报价" />
</Form.Item>
<Form.Item name="paymentMethod" label="线下收款方式" rules={[{ required: true, message: '请选择收款方式' }]}>
<Select options={[
{ value: 'bank_transfer', label: '银行转账' },
{ value: 'cash', label: '现金' },
{ value: 'other', label: '其他' },
]} />
</Form.Item>
<Form.Item name="offlineTradeNo" label="线下流水/凭证号(选填)">
<Input maxLength={128} placeholder="可填写银行流水号、收款凭证号等" />
</Form.Item>
<Form.Item name="offlinePaymentDetail" label="具体收款方式(选填)">
<Input maxLength={128} placeholder="未填写且选择“其他”时,系统显示“其他-线下收款”" />
</Form.Item>
<Form.Item name="remark" label="成交备注(选填)">
<Input.TextArea rows={3} maxLength={500} showCount placeholder="填写商务成交、售后置换等必要说明" />
</Form.Item>
</Form>
</Modal>
<Modal
title={<Space><WalletOutlined /> - {creditDetailModal.user?.username}</Space>}
open={creditDetailModal.open}
@@ -820,7 +955,10 @@ const AdminUsers: React.FC = () => {
>
<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?.personalCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamAvailableCredits || 0).toLocaleString()}</div></Card>
<Card size="small"><Typography.Text type="secondary"></Typography.Text><div style={{ fontSize: 22, fontWeight: 700 }}>{Number(creditSummary?.teamFrozenCredits || 0).toLocaleString()}</div></Card>
<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>
@@ -857,7 +995,7 @@ const AdminUsers: React.FC = () => {
{ 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, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || v || '-'}</Tag> },
{ title: '状态', dataIndex: 'status', width: 90, render: (v: string, r: any) => <Tag color={v === 'active' ? 'green' : v === 'expired' ? 'orange' : v === 'revoked' ? 'red' : 'default'}>{r.statusLabel || '其他状态'}</Tag> },
]}
scroll={{ x: 1410, y: 460 }}
/>
+72 -42
View File
@@ -143,12 +143,16 @@ export interface AdminTeam {
code?: string | null;
description?: string | null;
status: AdminTeamStatus;
statusLabel?: string;
isReadOnly?: boolean;
teamCreditFrozen?: boolean;
sortOrder: number;
memberCount: number;
createdAt: string;
updatedAt?: string | null;
managerId?: string | null;
managerName?: string | null;
firstSubscriptionPaidAt?: string | null;
}
export interface AdminTeamOption {
@@ -186,6 +190,9 @@ export interface AdminUser {
email: string;
phone?: string;
credits: number;
personalCredits?: number;
teamAvailableCredits?: number;
teamFrozenCredits?: number;
isActive: boolean;
isAdmin: boolean;
userType: string;
@@ -249,26 +256,21 @@ export interface AdminStats {
}
export interface PaymentStats {
byStatus: Record<string, { count: number; amount: number }>;
today: { paidCount: number; paidAmount: number };
month: { paidCount: number; paidAmount: number };
recent: PaymentOrder[];
byStatus: Record<string, { label?: string; count: number; amount: number }>;
bySource: Record<string, { label?: string; count: number; amount: number }>;
totalIncome: { label?: string; count: number; amount: number };
today: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
month: { paidCount: number; paidAmount: number; onlinePaidCount: number; onlinePaidAmount: number; offlinePaidCount: number; offlinePaidAmount: number };
recent?: PaymentOrder[];
}
export interface PaymentOrder {
id: string;
orderNo: string;
userId: string;
username?: string;
amount: number;
credits: number;
paymentMethod: string;
status: string;
tradeNo?: string;
paidAt?: string;
createdAt: string;
refundedAt?: string;
refundAmount?: number;
id: string; orderNo: string; userId: string; username?: string; phone?: string; amount: number; credits: number; quantity: number;
paymentMethod: string; paymentMethodLabel?: string; orderSource: 'online_payment' | 'admin_offline' | string; orderSourceLabel?: string;
status: string; statusLabel?: string; productId?: string | null; productType?: string | null; productNameSnapshot?: string | null;
quotedUnitPriceSnapshot?: number | null; quotedAmountSnapshot?: number | null; actualUnitPriceSnapshot?: number | null;
fulfillmentStatus?: string | null; fulfillmentStatusLabel?: string | null; tradeNo?: string | null; offlineTradeNo?: string | null; offlinePaymentDetail?: string | null; remark?: string | null;
paidAt?: string | null; createdAt: string; refundedAt?: string | null; refundAmount?: number | null; refundTradeNo?: string | null; refundEntitlementStatus?: string | null;
}
export interface ModelConfig {
@@ -910,6 +912,26 @@ export interface AdminCreditRecordAllocation {
amount: number;
creditLevel?: string;
creditLevelLabel?: string;
creditScope?: string;
creditScopeLabel?: string;
teamId?: string | null;
teamManagerId?: string | null;
subscriptionId?: string | null;
subscriptionNo?: string | null;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
seatId?: string | null;
sourceType?: string;
sourceTypeLabel?: string;
sourceId?: string;
@@ -921,6 +943,27 @@ export interface AdminCreditRecordAllocation {
consumedAfter?: number;
}
export interface AdminCreditRecordSubscriptionUsage {
creditScope?: string | null;
creditScopeLabel?: string | null;
subscriptionId?: string | null;
subscriptionNo: string;
productName?: string | null;
productType?: string | null;
productTypeLabel?: string | null;
tierCode?: string | null;
tierLabel?: string | null;
tierRank?: number | null;
billingCycle?: string | null;
billingCycleLabel?: string | null;
subscriptionPeriodId?: string | null;
periodSequence?: number | null;
periodLabel?: string | null;
periodValidFrom?: string | null;
periodExpiresAt?: string | null;
amount: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
@@ -964,6 +1007,11 @@ export interface AdminCreditRecord {
llmCallCount?: number;
llmSuccessCallCount?: number;
llmFailedCallCount?: number;
fundingScope?: string;
fundingScopeLabel?: string;
teamAllocationAmount?: number;
personalAllocationAmount?: number;
subscriptionUsages?: AdminCreditRecordSubscriptionUsage[];
allocations?: AdminCreditRecordAllocation[];
sourceModule?: string;
sourceModuleLabel?: string;
@@ -997,6 +1045,7 @@ export interface AdminCreditRecordQueryParams {
userType?: string;
frontendUserKind?: string;
teamId?: string;
subscriptionNo?: string;
recordType?: string;
type?: string;
creditSubject?: string;
@@ -1473,34 +1522,15 @@ export interface VideoUpscaleConfigSavePayload {
}
// ── Dynamic Credit Products / LLM Billing ─────────────────
export type CreditProductType = 'subscription' | 'credit_addon';
export type CreditProductType = 'subscription' | 'team_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;
id: string; productCode: string; productType: CreditProductType; productTypeLabel?: string; name: string; description?: string | null; features?: string[];
tierCode?: string | null; tierLabel?: string | null; tierRank?: number | null; billingCycle?: SubscriptionBillingCycle | null; billingCycleLabel?: string | null;
monthlyGrantCredits?: number; grantCount?: number; firstPurchasePrice?: number; regularPrice?: number; activityPrice?: number | null; activityStartAt?: string | null; activityEndAt?: string | null; renewalEnabled: boolean;
price: number; currentPrice?: number; grantCredits?: number; validityMonths?: number | null; creditLevel: 'promotional' | 'general'; creditLevelLabel?: string; currency: string;
isActive: boolean; isDeleted?: boolean; deletedAt?: string | null; statusLabel?: string; sortOrder: number;
}
export interface LlmBillingPolicy {