团队积分V1
This commit is contained in:
@@ -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[]> {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user