团队积分V1
This commit is contained in:
@@ -280,33 +280,22 @@ export async function retryGeneration(recordId: string): Promise<GenerationRecor
|
||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/retry`);
|
||||
}
|
||||
// ── Credits ───────────────────────────────────────────────
|
||||
export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; availableCredits: number; nextExpiringCredits: number; nextExpiresAt?: string | null; nextLastUsableAt?: string | null; totalGranted: number; totalConsumed: number; totalRefunded: number; totalExpired: number; records: CreditRecord[]; total: number }> {
|
||||
export async function getCredits(page = 1, pageSize = 20): Promise<{
|
||||
credits: number; availableCredits: number; personalCredits: number; teamAvailableCredits: number; teamFrozenCredits: number;
|
||||
nextExpiringCredits: number; nextExpiresAt?: string | null; nextLastUsableAt?: string | null;
|
||||
totalGranted: number; totalConsumed: number; totalRefunded: number; totalExpired: number; records: CreditRecord[]; total: number;
|
||||
}> {
|
||||
if (USE_MOCK) {
|
||||
const data = await mock.mockGetCredits();
|
||||
const records = data.records || [];
|
||||
const totalGranted = records
|
||||
.filter((record) => record.type === 'recharge' || record.type === 'refund')
|
||||
.reduce((sum, record) => sum + Math.max(0, Number(record.amount || 0)), 0);
|
||||
const totalConsumed = records
|
||||
.filter((record) => record.type === 'consume')
|
||||
.reduce((sum, record) => sum + Math.abs(Number(record.amount || 0)), 0);
|
||||
const totalGranted = records.filter((record) => record.type === 'recharge' || record.type === 'refund').reduce((sum, record) => sum + Math.max(0, Number(record.amount || 0)), 0);
|
||||
const totalConsumed = records.filter((record) => record.type === 'consume').reduce((sum, record) => sum + Math.abs(Number(record.amount || 0)), 0);
|
||||
return {
|
||||
credits: data.credits,
|
||||
availableCredits: data.credits,
|
||||
nextExpiringCredits: 0,
|
||||
nextExpiresAt: null,
|
||||
nextLastUsableAt: null,
|
||||
totalGranted,
|
||||
totalConsumed,
|
||||
totalRefunded: 0,
|
||||
totalExpired: 0,
|
||||
records,
|
||||
total: data.total,
|
||||
credits: data.credits, availableCredits: data.credits, personalCredits: data.credits, teamAvailableCredits: 0, teamFrozenCredits: 0,
|
||||
nextExpiringCredits: 0, nextExpiresAt: null, nextLastUsableAt: null, totalGranted, totalConsumed, totalRefunded: 0, totalExpired: 0, records, total: data.total,
|
||||
};
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
return api.get(`/credits?${params.toString()}`);
|
||||
}
|
||||
// ── Captcha ───────────────────────────────────────────────
|
||||
@@ -460,10 +449,11 @@ export async function getPaymentMethods(): Promise<{ alipay: boolean; wechat: bo
|
||||
return api.get('/payments/methods');
|
||||
}
|
||||
|
||||
export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise<any> {
|
||||
return api.post('/payments/recharge', { plan: planId, method });
|
||||
export async function createRechargeOrder(planId: string, method: string = 'wechat', quantity = 1): Promise<any> {
|
||||
return api.post('/payments/recharge', { plan: planId, method, quantity });
|
||||
}
|
||||
|
||||
|
||||
export async function getPaymentOrders(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
@@ -1337,87 +1327,50 @@ export async function getPrivatePortraitVirtualSelectableAssets(params: { projec
|
||||
}
|
||||
|
||||
// ── Team Management APIs ──────────────────────────────
|
||||
export async function getManagedTeam(): Promise<any> {
|
||||
return api.get('/team/managed');
|
||||
}
|
||||
|
||||
export async function getManagedTeam(): Promise<any> { return api.get('/team/managed'); }
|
||||
export async function getManagerAccessTeams(): Promise<any[]> { return api.get('/team/manager-access'); }
|
||||
export async function getTeamMembers(page = 1, pageSize = 20): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
return api.get(`/team/members?${params.toString()}`);
|
||||
return api.get(`/team/members?page=${page}&page_size=${pageSize}`);
|
||||
}
|
||||
|
||||
export async function transferCredits(memberId: string, amount: number, direction: string = "increase", description?: string): Promise<void> {
|
||||
await api.post(`/team/members/${memberId}/credits`, { target_user_id: memberId, amount, direction, description: description || null });
|
||||
export async function transferTeamManager(userId: string): Promise<void> { await api.put('/team/manager', { user_id: userId }); }
|
||||
export async function getTeamSubscriptions(): Promise<any[]> { return api.get('/team/subscriptions'); }
|
||||
export async function createTeamSeat(subscriptionId: string, userId: string, monthlyAllocatedCredits: number): Promise<any> {
|
||||
return api.post(`/team/subscriptions/${subscriptionId}/seats`, { user_id: userId, monthly_allocated_credits: monthlyAllocatedCredits });
|
||||
}
|
||||
|
||||
export async function getTeamInvitations(): Promise<any[]> {
|
||||
return api.get('/team/invitations');
|
||||
export async function updateTeamSeat(seatId: string, monthlyAllocatedCredits: number): Promise<any> {
|
||||
return api.put(`/team/seats/${seatId}`, { monthly_allocated_credits: monthlyAllocatedCredits });
|
||||
}
|
||||
|
||||
export async function cancelTeamSeat(seatId: string): Promise<void> { await api.delete(`/team/seats/${seatId}`); }
|
||||
export async function getTeamMemberUsage(subscriptionId?: string): Promise<any[]> {
|
||||
const p = new URLSearchParams(); if (subscriptionId) p.set('subscription_id', subscriptionId);
|
||||
return api.get(`/team/member-usage${p.toString() ? `?${p.toString()}` : ''}`);
|
||||
}
|
||||
export async function getTeamManagerHistory(): Promise<any[]> { return api.get('/team/manager-history'); }
|
||||
export async function getTeamInvitations(): Promise<any[]> { return api.get('/team/invitations'); }
|
||||
export async function createTeamInvitation(maxUses?: number, expiresAt?: string): Promise<any> {
|
||||
return api.post('/team/invitations', { max_uses: maxUses || null, expires_at: expiresAt || null });
|
||||
}
|
||||
|
||||
export async function revokeInvitation(invitationId: string): Promise<void> {
|
||||
await api.delete(`/team/invitations/${invitationId}`);
|
||||
}
|
||||
|
||||
export async function revokeInvitation(invitationId: string): Promise<void> { await api.delete(`/team/invitations/${invitationId}`); }
|
||||
export async function getPendingJoinRequests(status?: string): Promise<any[]> {
|
||||
const url = status ? `/team/join-requests?status=${status}` : '/team/join-requests';
|
||||
return api.get(url);
|
||||
return api.get(status ? `/team/join-requests?status=${encodeURIComponent(status)}` : '/team/join-requests');
|
||||
}
|
||||
|
||||
export async function handleJoinRequest(requestId: string, action: 'approve' | 'reject', note?: string): Promise<void> {
|
||||
await api.post(`/team/join-requests/${requestId}`, { action, note: note || null });
|
||||
}
|
||||
|
||||
export async function getJoinTeamInfo(code: string): Promise<any> {
|
||||
return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true });
|
||||
}
|
||||
|
||||
export async function getJoinTeamInfoPublic(code: string): Promise<any> {
|
||||
return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false);
|
||||
}
|
||||
|
||||
export async function submitJoinRequest(code: string): Promise<void> {
|
||||
await api.post('/team/join', { invitation_code: code });
|
||||
}
|
||||
|
||||
export async function getTeamCreditRecords(params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: string;
|
||||
phone?: string;
|
||||
recordType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<any> {
|
||||
export async function getJoinTeamInfo(code: string): Promise<any> { return api.get(`/team/join-info?code=${encodeURIComponent(code)}`, { auth: true, skipAuthRedirect: true }); }
|
||||
export async function getJoinTeamInfoPublic(code: string): Promise<any> { return api.get(`/team/join-info/public?code=${encodeURIComponent(code)}`, false); }
|
||||
export async function submitJoinRequest(code: string): Promise<void> { await api.post('/team/join', { invitation_code: code }); }
|
||||
export async function getTeamCreditRecords(params: { teamId?: string; page?: number; pageSize?: number; userId?: string; phone?: string; subscriptionId?: string; recordType?: string; startDate?: string; endDate?: string; }): Promise<any> {
|
||||
const p = new URLSearchParams();
|
||||
if (params.page) p.set('page', String(params.page));
|
||||
if (params.pageSize) p.set('page_size', String(params.pageSize));
|
||||
if (params.userId) p.set('user_id', params.userId);
|
||||
if (params.phone) p.set('phone', params.phone);
|
||||
if (params.recordType) p.set('record_type', params.recordType);
|
||||
if (params.startDate) p.set('start_date', params.startDate);
|
||||
if (params.endDate) p.set('end_date', params.endDate);
|
||||
if (params.teamId) p.set('team_id', params.teamId); if (params.page) p.set('page', String(params.page)); if (params.pageSize) p.set('page_size', String(params.pageSize));
|
||||
if (params.userId) p.set('user_id', params.userId); if (params.phone) p.set('phone', params.phone); if (params.subscriptionId) p.set('subscription_id', params.subscriptionId);
|
||||
if (params.recordType) p.set('record_type', params.recordType); if (params.startDate) p.set('start_date', params.startDate); if (params.endDate) p.set('end_date', params.endDate);
|
||||
return api.get(`/team/credit-records?${p.toString()}`);
|
||||
}
|
||||
|
||||
export function getTeamCreditExportUrl(params: {
|
||||
phone?: string;
|
||||
recordType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): string {
|
||||
const p = new URLSearchParams();
|
||||
if (params.phone) p.set('phone', params.phone);
|
||||
if (params.recordType) p.set('record_type', params.recordType);
|
||||
if (params.startDate) p.set('start_date', params.startDate);
|
||||
if (params.endDate) p.set('end_date', params.endDate);
|
||||
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000';
|
||||
return `${base}/api/team/credit-records/export?${p.toString()}`;
|
||||
export function getTeamCreditExportUrl(params: { teamId?: string; phone?: string; subscriptionId?: string; recordType?: string; startDate?: string; endDate?: string; }): string {
|
||||
const p = new URLSearchParams(); if (params.teamId) p.set('team_id', params.teamId); if (params.phone) p.set('phone', params.phone); if (params.subscriptionId) p.set('subscription_id', params.subscriptionId);
|
||||
if (params.recordType) p.set('record_type', params.recordType); if (params.startDate) p.set('start_date', params.startDate); if (params.endDate) p.set('end_date', params.endDate);
|
||||
const base = (import.meta as any).env?.VITE_API_BASE || 'http://localhost:8000'; return `${base}/api/team/credit-records/export?${p.toString()}`;
|
||||
}
|
||||
|
||||
// ── Dynamic credit products ───────────────────────────────
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tag, Button, Typography, Radio, Drawer, Tabs, Progress, Collapse, ConfigProvider } from 'antd';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, InputNumber, message, Tag, Button, Typography, Radio, Drawer, Tabs, Progress, Collapse, ConfigProvider } from 'antd';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
CrownFilled,
|
||||
FireOutlined,
|
||||
BankFilled,
|
||||
BankOutlined,
|
||||
CheckCircleFilled,
|
||||
WechatOutlined,
|
||||
AlipayCircleOutlined,
|
||||
@@ -444,21 +443,34 @@ const AppLayout: React.FC = () => {
|
||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
|
||||
const [subscriptionProducts, setSubscriptionProducts] = useState<CreditProduct[]>([]);
|
||||
const [currentSubscription, setCurrentSubscription] = useState<CreditProductCatalog['currentSubscription']>(null);
|
||||
const [teamSubscriptionProducts, setTeamSubscriptionProducts] = useState<CreditProduct[]>([]);
|
||||
const [selectedSubscriptionType, setSelectedSubscriptionType] = useState<'personal' | 'team'>('personal');
|
||||
const [activePersonalSubscriptions, setActivePersonalSubscriptions] = useState<CreditProductCatalog['activePersonalSubscriptions']>([]);
|
||||
const loadCreditCatalog = useCallback(async () => {
|
||||
try {
|
||||
const data = await getCreditProductCatalog();
|
||||
setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive));
|
||||
const personalProducts = (data.subscriptionProducts || []).filter((product) => product.isActive);
|
||||
const teamProducts = (data.teamSubscriptionProducts || []).filter((product) => product.isActive);
|
||||
setSubscriptionProducts(personalProducts);
|
||||
setTeamSubscriptionProducts(teamProducts);
|
||||
setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
|
||||
setCurrentSubscription(data.currentSubscription || null);
|
||||
setActivePersonalSubscriptions(data.activePersonalSubscriptions || []);
|
||||
if (teamProducts.length === 0) {
|
||||
setSelectedSubscriptionType('personal');
|
||||
}
|
||||
} catch {
|
||||
setSubscriptionProducts([]);
|
||||
setTeamSubscriptionProducts([]);
|
||||
setRechargeOptions([]);
|
||||
setCurrentSubscription(null);
|
||||
setActivePersonalSubscriptions([]);
|
||||
setSelectedSubscriptionType('personal');
|
||||
}
|
||||
}, []);
|
||||
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
|
||||
const visibleSubscriptionProducts = (selectedSubscriptionType === 'team' ? teamSubscriptionProducts : subscriptionProducts)
|
||||
.filter((item) => item.billingCycle === selectedPeriod)
|
||||
.sort((a, b) => ((a.tierRank || 0) as number) - ((b.tierRank || 0) as number));
|
||||
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
||||
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||
const [paying, setPaying] = useState(false);
|
||||
@@ -480,17 +492,9 @@ const AppLayout: React.FC = () => {
|
||||
isSubscription?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const [pendingProduct, setPendingProduct] = useState<{ id: string; product: any; } | null>(null);
|
||||
const [pendingProduct, setPendingProduct] = useState<{ id: string; product: CreditProduct; } | null>(null);
|
||||
const [purchaseQuantity, setPurchaseQuantity] = useState(1);
|
||||
const [qrRevealed, setQrRevealed] = useState(false);
|
||||
const [paymentTab, setPaymentTab] = useState<'alipay' | 'corporate'>('alipay');
|
||||
const [corporateTransferForm] = Form.useForm();
|
||||
const [corporateSubmitting, setCorporateSubmitting] = useState(false);
|
||||
|
||||
const COMPANY_BANK_INFO = {
|
||||
accountName: '深圳市某某科技有限公司',
|
||||
bankName: '招商银行',
|
||||
bankAccount: '7559 0188 1010 1001',
|
||||
};
|
||||
|
||||
const PENDING_ORDER_KEY = 'pending_payment_order';
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
@@ -873,15 +877,15 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
const handleDirectPurchase = useCallback(async (
|
||||
productId: string,
|
||||
product: { name?: string; price?: number; currentPrice?: number; monthlyGrantCredits?: number | string; credits?: number; grantCredits?: number; regularPrice?: number; originalPrice?: number; billingCycle?: string; tierCode?: string; description?: string; }
|
||||
product: CreditProduct
|
||||
) => {
|
||||
if (!enabledMethods.alipay && !enabledMethods.wechat) {
|
||||
message.error('暂无可用支付方式');
|
||||
return;
|
||||
}
|
||||
setPendingProduct({ id: productId, product });
|
||||
setPurchaseQuantity(product.productType === 'team_subscription' ? 2 : 1);
|
||||
setQrRevealed(false);
|
||||
setPaymentTab('alipay');
|
||||
setCurrentPaymentInfo(null);
|
||||
setCreditsModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
@@ -892,10 +896,10 @@ const AppLayout: React.FC = () => {
|
||||
setPaying(true);
|
||||
try {
|
||||
const { id: productId, product } = pendingProduct;
|
||||
const order = await createRechargeOrder(productId, paymentMethod);
|
||||
const order = await createRechargeOrder(productId, paymentMethod, purchaseQuantity);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
|
||||
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
|
||||
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0) * (product.productType === 'team_subscription' ? purchaseQuantity : 1);
|
||||
const originalPrice = product.regularPrice ?? product.originalPrice;
|
||||
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
|
||||
const isSubscription = !!product.billingCycle;
|
||||
@@ -938,51 +942,7 @@ const AppLayout: React.FC = () => {
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
}, [pendingProduct, paymentMethod]);
|
||||
|
||||
const submitCorporateTransfer = useCallback(async () => {
|
||||
if (!pendingProduct) return;
|
||||
try {
|
||||
const values: any = await corporateTransferForm.validateFields();
|
||||
setCorporateSubmitting(true);
|
||||
const { id: productId, product } = pendingProduct;
|
||||
const order = await createRechargeOrder(productId, 'corporate');
|
||||
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
|
||||
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
|
||||
const originalPrice = product.regularPrice ?? product.originalPrice;
|
||||
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
|
||||
const isSubscription = !!product.billingCycle;
|
||||
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
|
||||
const tierName = product.name || '积分充值';
|
||||
const displayTitle = isSubscription ? `${tierName} ${periodLabel}` : tierName;
|
||||
const paymentInfo = {
|
||||
price: finalPrice,
|
||||
credits,
|
||||
qrCode: '',
|
||||
method: 'corporate',
|
||||
title: displayTitle,
|
||||
originalPrice: originalPrice ? Number(originalPrice) : undefined,
|
||||
tierName,
|
||||
periodLabel,
|
||||
isSubscription,
|
||||
};
|
||||
void values;
|
||||
setCurrentPaymentInfo(paymentInfo);
|
||||
message.success('已提交对公转账信息,客服将在1-3个工作日内审核到账');
|
||||
setQrCodeModalOpen(false);
|
||||
setPendingProduct(null);
|
||||
corporateTransferForm.resetFields();
|
||||
await useAuthStore.getState().refreshUser();
|
||||
await loadCreditCatalog();
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
return;
|
||||
}
|
||||
message.error(err?.message || '提交失败,请重试');
|
||||
} finally {
|
||||
setCorporateSubmitting(false);
|
||||
}
|
||||
}, [pendingProduct, corporateTransferForm]);
|
||||
}, [pendingProduct, paymentMethod, purchaseQuantity]);
|
||||
|
||||
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
|
||||
stopPolling();
|
||||
@@ -1665,45 +1625,37 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
||||
<Typography.Title level={3} style={{ marginBottom: 8 }}>订阅套餐</Typography.Title>
|
||||
<Typography.Text type="secondary">订阅积分按自然月逐月发放;有效订阅只能升级同周期更高等级套餐,不能提前续费。</Typography.Text>
|
||||
<Typography.Text type="secondary">个人和团队订阅均可重复购买、多实例同时有效;季卡和年卡仅在订阅内部按月发放积分,不属于自动续费。</Typography.Text>
|
||||
</div>
|
||||
|
||||
{currentSubscription && (
|
||||
<div style={{ marginBottom: 16, padding: '14px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed', display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 18,
|
||||
}}>
|
||||
<CrownOutlined />
|
||||
{selectedSubscriptionType === 'personal' && activePersonalSubscriptions.length > 0 && (
|
||||
<div style={{ marginBottom: 16, padding: '12px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed' }}>
|
||||
<Typography.Text strong>当前有效个人订阅:{activePersonalSubscriptions.length} 张</Typography.Text>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{activePersonalSubscriptions.map((item) => (
|
||||
<Tag key={item.id} color="purple">{item.productName} · {item.billingCycleLabel || '其他周期'} · 到期 {new Date(item.expiresAt).toLocaleString('zh-CN', { hour12: false })}</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||
当前订阅:{currentSubscription.tierCode || '订阅套餐'}
|
||||
</Typography.Text>
|
||||
<Tag color="purple" style={{ fontSize: 11, margin: 0, borderRadius: 6 }}>
|
||||
{currentSubscription.billingCycle === 'monthly' ? '月' : currentSubscription.billingCycle === 'quarterly' ? '季' : '年'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 2 }}>
|
||||
已发放 <span style={{ color: '#6366f1', fontWeight: 600 }}>{currentSubscription.grantedCount}</span>/{currentSubscription.grantCount} 期
|
||||
· 每月 <span style={{ color: '#6366f1', fontWeight: 600 }}>{Number(currentSubscription.monthlyGrantCredits || 0).toLocaleString()}</span> 积分
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8' }}>
|
||||
订阅到期:{new Date(currentSubscription.expiresAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}
|
||||
</div>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(((currentSubscription.grantedCount || 0) / (currentSubscription.grantCount || 1)) * 100)}
|
||||
size="small"
|
||||
strokeColor={{ from: '#6366f1', to: '#8b5cf6' }}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 10 }}>
|
||||
<Tabs
|
||||
activeKey={selectedSubscriptionType}
|
||||
onChange={(key) => {
|
||||
setSelectedSubscriptionType(key as 'personal' | 'team');
|
||||
setSelectedPlan(null);
|
||||
}}
|
||||
items={[
|
||||
{ key: 'personal', label: '普通订阅' },
|
||||
...(teamSubscriptionProducts.length > 0 ? [{ key: 'team', label: '团队订阅' }] : []),
|
||||
]}
|
||||
centered
|
||||
size="large"
|
||||
tabBarStyle={{ marginBottom: 0 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 20 }}>
|
||||
<Radio.Group value={selectedPeriod} onChange={(e) => { setSelectedPeriod(e.target.value); setSelectedPlan(null); }} buttonStyle="solid">
|
||||
<Radio.Button value="monthly">月套餐</Radio.Button>
|
||||
@@ -1712,11 +1664,15 @@ const AppLayout: React.FC = () => {
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).length === 0 ? (
|
||||
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>暂无可订阅套餐,请联系管理员配置并上架套餐。</div>
|
||||
{visibleSubscriptionProducts.length === 0 ? (
|
||||
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>
|
||||
{selectedSubscriptionType === 'team'
|
||||
? '暂无可购买的团队订阅套餐,请联系管理员配置并上架套餐。'
|
||||
: '暂无可订阅套餐,请联系管理员配置并上架套餐。'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).sort((a, b) => ((a.tierRank || 0) as number) - ((b.tierRank || 0) as number)).map((product) => {
|
||||
{visibleSubscriptionProducts.map((product) => {
|
||||
const gradient = MEMBERSHIP_GRADIENTS[product.tierCode || ''] || MEMBERSHIP_GRADIENTS.default;
|
||||
const originalPrice = product.regularPrice || product.price;
|
||||
const currentPrice = product.currentPrice ?? product.price;
|
||||
@@ -1745,7 +1701,8 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: gradient.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 14 }}>{gradient.icon}</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>{product.name}</Typography.Text>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8' }}>{product.description || product.tierCode}</div>
|
||||
{product.productType === 'team_subscription' && <Tag color="blue" style={{ marginLeft: 6, fontSize: 10 }}>团队套餐</Tag>}
|
||||
<div style={{ fontSize: 11, color: '#94a3b8' }}>{product.description || product.tierLabel || '订阅套餐'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
@@ -1753,15 +1710,7 @@ const AppLayout: React.FC = () => {
|
||||
{hasDiscount && (
|
||||
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 6 }}>¥{originalPrice}</span>
|
||||
)}
|
||||
{product.canUpgrade && (
|
||||
<Tag color="orange" style={{ marginLeft: 6, fontSize: 10, padding: '0 6px', lineHeight: '16px' }}>升级价</Tag>
|
||||
)}
|
||||
</div>
|
||||
{product.canUpgrade && Number(product.deductionAmount || 0) > 0 && (
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4 }}>
|
||||
目标套餐¥{product.targetPrice},抵扣未生效¥{product.deductionAmount}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: '#64748b' }}>
|
||||
<span>{creditsLabel}</span>
|
||||
<span>共 {product.grantCount} 次</span>
|
||||
@@ -1972,8 +1921,6 @@ const AppLayout: React.FC = () => {
|
||||
setCurrentPaymentInfo(null);
|
||||
setPendingProduct(null);
|
||||
setQrRevealed(false);
|
||||
setPaymentTab('alipay');
|
||||
corporateTransferForm.resetFields();
|
||||
}}
|
||||
footer={null}
|
||||
width={760}
|
||||
@@ -2005,7 +1952,7 @@ const AppLayout: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: '#6366f1', lineHeight: 1.4 }}>
|
||||
¥{currentPaymentInfo?.price || pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0}
|
||||
¥{currentPaymentInfo?.price ?? (Number(pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0) * (pendingProduct?.product?.productType === 'team_subscription' ? purchaseQuantity : 1))}
|
||||
{((currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice) && Number(currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice || 0) > Number(currentPaymentInfo?.price || pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0)) && (
|
||||
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 8, fontWeight: 400 }}>
|
||||
¥{currentPaymentInfo?.originalPrice || pendingProduct?.product?.regularPrice}
|
||||
@@ -2023,10 +1970,16 @@ const AppLayout: React.FC = () => {
|
||||
{((currentPaymentInfo?.credits != null) ? currentPaymentInfo.credits : (pendingProduct?.product?.grantCredits || pendingProduct?.product?.monthlyGrantCredits)) ? `${Number(currentPaymentInfo?.credits || pendingProduct?.product?.grantCredits || pendingProduct?.product?.monthlyGrantCredits || 0).toLocaleString()} 积分` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
{pendingProduct?.product?.productType === 'team_subscription' && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', fontSize: 12 }}>
|
||||
<span style={{ color: '#64748b' }}>团队席位数量</span>
|
||||
{qrRevealed ? <span>{purchaseQuantity} 席</span> : <InputNumber min={2} max={20} precision={0} value={purchaseQuantity} onChange={(v) => setPurchaseQuantity(Number(v || 2))} />}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
|
||||
<span style={{ color: '#64748b' }}>续费信息</span>
|
||||
<span style={{ color: '#64748b' }}>购买方式</span>
|
||||
<span style={{ color: '#1a1a2e' }}>
|
||||
{(currentPaymentInfo?.isSubscription ?? !!pendingProduct?.product?.billingCycle) ? '次年按原价 可随时取消' : '一次性购买'}
|
||||
{(currentPaymentInfo?.isSubscription ?? !!pendingProduct?.product?.billingCycle) ? '独立订阅实例,不自动续费' : '一次性购买'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: 1, background: '#e2e8f0', margin: '8px 0' }} />
|
||||
@@ -2038,166 +1991,38 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 500, color: '#1a1a2e' }}>应付金额</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: '#6366f1' }}>
|
||||
¥{currentPaymentInfo?.price || pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0}
|
||||
¥{currentPaymentInfo?.price ?? (Number(pendingProduct?.product?.currentPrice || pendingProduct?.product?.price || 0) * (pendingProduct?.product?.productType === 'team_subscription' ? purchaseQuantity : 1))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:支付方式 */}
|
||||
{/* 右侧:在线支付方式。后台线下成交不在客户端创建。 */}
|
||||
<div style={{ width: 300, padding: 20, background: '#fff', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Tab 切换 */}
|
||||
<div style={{ display: 'flex', background: '#f3f4f6', borderRadius: 10, padding: 4, marginBottom: 20 }}>
|
||||
<div
|
||||
onClick={() => setPaymentTab('alipay')}
|
||||
style={{
|
||||
flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8,
|
||||
cursor: 'pointer', fontSize: 14, fontWeight: 600,
|
||||
background: paymentTab === 'alipay' ? '#fff' : 'transparent',
|
||||
color: paymentTab === 'alipay' ? '#1677ff' : '#64748b',
|
||||
boxShadow: paymentTab === 'alipay' ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<AlipayCircleOutlined style={{ color: paymentTab === 'alipay' ? '#1677ff' : '#94a3b8' }} />
|
||||
支付宝支付
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setPaymentTab('corporate')}
|
||||
style={{
|
||||
flex: 1, textAlign: 'center', padding: '8px 0', borderRadius: 8,
|
||||
cursor: 'pointer', fontSize: 14, fontWeight: 600,
|
||||
background: paymentTab === 'corporate' ? '#fff' : 'transparent',
|
||||
color: paymentTab === 'corporate' ? '#6366f1' : '#64748b',
|
||||
boxShadow: paymentTab === 'corporate' ? '0 2px 8px rgba(0,0,0,0.08)' : 'none',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
}}
|
||||
>
|
||||
<BankOutlined style={{ color: paymentTab === 'corporate' ? '#6366f1' : '#94a3b8' }} />
|
||||
对公转账
|
||||
</div>
|
||||
<Typography.Text strong style={{ marginBottom: 12 }}>选择支付方式</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 18 }}>
|
||||
{enabledMethods.alipay && <Button type={paymentMethod === 'alipay' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('alipay'); }} icon={<AlipayCircleOutlined />}>支付宝</Button>}
|
||||
{enabledMethods.wechat && <Button type={paymentMethod === 'wechat' ? 'primary' : 'default'} onClick={() => { if (!qrRevealed) setPaymentMethod('wechat'); }} icon={<WechatOutlined />}>微信支付</Button>}
|
||||
</div>
|
||||
|
||||
{/* 支付宝 Tab */}
|
||||
{paymentTab === 'alipay' && (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 18, color: '#1677ff' }} />
|
||||
<span style={{ fontSize: 14, color: '#64748b' }}>请用支付宝扫码支付</span>
|
||||
</div>
|
||||
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
||||
{qrRevealed && currentPaymentInfo?.qrCode ? (
|
||||
<QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" />
|
||||
) : (
|
||||
<div style={{ position: 'absolute', inset: 0, background: '#fff', borderRadius: 12, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, zIndex: 10 }}>
|
||||
<div style={{ fontSize: 13, color: '#64748b', textAlign: 'center', padding: '0 10px' }}>
|
||||
确认支付金额后<br/>生成二维码
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
loading={paying}
|
||||
onClick={confirmPayment}
|
||||
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #1677ff, #4096ff)', border: 'none', fontWeight: 600, height: 36, fontSize: 13, minWidth: 140 }}
|
||||
>
|
||||
确认使用支付宝支付
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{qrRevealed && (
|
||||
<div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>
|
||||
{countdown}秒后二维码失效
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ marginBottom: 12, color: '#64748b' }}>{paymentMethod === 'wechat' ? '请用微信扫码支付' : '请用支付宝扫码支付'}</div>
|
||||
<div style={{ position: 'relative', width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
|
||||
{qrRevealed && currentPaymentInfo?.qrCode ? <QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" /> : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginBottom: 12 }}>确认商品和金额后生成二维码</div>
|
||||
<Button type="primary" loading={paying} onClick={confirmPayment}>确认支付</Button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
|
||||
开通即代表同意<br/>《用户服务协议》
|
||||
</div>
|
||||
{qrRevealed && (
|
||||
<Button
|
||||
danger
|
||||
size="large"
|
||||
onClick={async () => {
|
||||
stopPolling();
|
||||
if (currentOrderNoRef.current) {
|
||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
|
||||
currentOrderNoRef.current = null;
|
||||
}
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setPendingProduct(null);
|
||||
setQrRevealed(false);
|
||||
message.info('已取消支付');
|
||||
}}
|
||||
style={{ borderRadius: 10, minWidth: 140, fontSize: 14, marginTop: 16 }}
|
||||
>
|
||||
取消支付
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 对公转账 Tab */}
|
||||
{paymentTab === 'corporate' && (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflowY: 'auto' }}>
|
||||
{/* 汇款信息 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 8, display: 'block', fontWeight: 600 }}>收款账户信息</Typography.Text>
|
||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 8, border: '1px solid #e2e8f0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12, marginBottom: 4 }}>
|
||||
<span style={{ color: '#64748b' }}>账户名称</span>
|
||||
<span style={{ color: '#1a1a2e', fontWeight: 500 }}>{COMPANY_BANK_INFO.accountName}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12, marginBottom: 4 }}>
|
||||
<span style={{ color: '#64748b' }}>开户银行</span>
|
||||
<span style={{ color: '#1a1a2e', fontWeight: 500, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<BankFilled style={{ color: '#c8161d', fontSize: 14 }} />
|
||||
{COMPANY_BANK_INFO.bankName}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
|
||||
<span style={{ color: '#64748b' }}>账号</span>
|
||||
<span style={{ color: '#1a1a2e', fontWeight: 500, fontFamily: 'monospace' }}>{COMPANY_BANK_INFO.bankAccount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 打款信息填写 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', marginBottom: 4, display: 'block', fontWeight: 600 }}>填写您的打款信息</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#ef4444', marginBottom: 8, display: 'block' }}>注:打款金额需与所标注金额一致,不然无法开通,银行信息需正确填写。</Typography.Text>
|
||||
<Form form={corporateTransferForm} layout="vertical" size="small" requiredMark={false}>
|
||||
<Form.Item name="accountName" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]} style={{ marginBottom: 10 }}>
|
||||
<Input placeholder="请输入付款方账户名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankName" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]} style={{ marginBottom: 10 }}>
|
||||
<Input placeholder="请输入开户银行" prefix={<BankOutlined style={{ color: '#94a3b8' }} />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccount" label="账号" rules={[{ required: true, message: '请输入账号' }]} style={{ marginBottom: 8 }}>
|
||||
<Input placeholder="请输入付款方账号" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
loading={corporateSubmitting}
|
||||
onClick={submitCorporateTransfer}
|
||||
style={{ borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none', fontWeight: 600, height: 38, fontSize: 14 }}
|
||||
>
|
||||
提交对公转账
|
||||
</Button>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
|
||||
提交后客服将在12小时内审核到账。如有疑问,请联系客服。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{qrRevealed && <div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>{countdown} 秒后二维码失效</div>}
|
||||
{qrRevealed && <Button danger style={{ marginTop: 14 }} onClick={async () => {
|
||||
stopPolling();
|
||||
if (currentOrderNoRef.current) { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { } currentOrderNoRef.current = null; }
|
||||
localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setPendingProduct(null); setQrRevealed(false); message.info('已取消支付');
|
||||
}}>取消支付</Button>}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>线上订单仅支持已启用的支付宝/微信支付;后台线下成交请联系客服处理。</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,186 +1,50 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Table, Tag, Empty, Spin, Typography, Row, Col, Pagination } from 'antd';
|
||||
import { WalletOutlined, PlusCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { Col, Empty, Pagination, Row, Spin, Table, Tag, Typography } from 'antd';
|
||||
import { LockOutlined, TeamOutlined, ThunderboltOutlined, WalletOutlined } from '@ant-design/icons';
|
||||
import { getCredits } from '../api';
|
||||
|
||||
const CreditRecordsPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [records, setRecords] = useState<any[]>([]);
|
||||
const [credits, setCredits] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalGranted, setTotalGranted] = useState(0);
|
||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
||||
const [data, setData] = useState<any>({ records: [], total: 0, credits: 0, personalCredits: 0, teamAvailableCredits: 0, teamFrozenCredits: 0, totalGranted: 0, totalConsumed: 0 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [page]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getCredits(page, pageSize);
|
||||
setRecords(data.records || []);
|
||||
setCredits(data.availableCredits ?? data.credits ?? 0);
|
||||
setTotal(data.total || 0);
|
||||
setTotalGranted(data.totalGranted ?? 0);
|
||||
setTotalConsumed(data.totalConsumed ?? 0);
|
||||
} catch {
|
||||
setRecords([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setData(await getCredits(page, pageSize)); } finally { setLoading(false); }
|
||||
};
|
||||
void load();
|
||||
}, [page, pageSize]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '变动类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120,
|
||||
render: (text: string) => {
|
||||
const typeConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
recharge: { color: '#10b981', label: '充值', bg: 'rgba(16,185,129,0.1)' },
|
||||
consume: { color: '#ef4444', label: '消费', bg: 'rgba(239,68,68,0.1)' },
|
||||
admin: { color: '#f59e0b', label: '管理员调整', bg: 'rgba(245,158,11,0.1)' },
|
||||
refund: { color: '#8b5cf6', label: '退款', bg: 'rgba(139,92,246,0.1)' },
|
||||
team_internal: { color: '#0958d9', label: '历史团队流转', bg: 'rgba(9,88,217,0.1)' },
|
||||
expire: { color: '#64748b', label: '积分过期', bg: 'rgba(100,116,139,0.1)' },
|
||||
revoke: { color: '#dc2626', label: '积分撤销', bg: 'rgba(220,38,38,0.1)' },
|
||||
};
|
||||
const config = typeConfig[text] || { color: '#64748b', label: text, bg: 'rgba(100,116,139,0.1)' };
|
||||
return (
|
||||
<Tag color={config.color} style={{ background: config.bg, fontWeight: 500, padding: '4px 12px' }}>
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '变动积分',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 140,
|
||||
align: 'right' as const,
|
||||
render: (value: number) => {
|
||||
const delta = Number(value ?? 0);
|
||||
return (
|
||||
<Typography.Text strong style={{ fontWeight: 700, color: delta > 0 ? '#10b981' : delta < 0 ? '#ef4444' : '#64748b', fontSize: 15 }}>
|
||||
{delta > 0 ? '+' : ''}{delta}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (_, record: any) => {
|
||||
const createdAt = record.createdAt || record.created_at || record.createTime || record.create_time;
|
||||
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
|
||||
},
|
||||
},
|
||||
{ title: '类型', dataIndex: 'typeLabel', width: 110, render: (v: string, r: any) => <Tag>{v || ({ recharge: '发放/充值', consume: '消费', refund: '业务退款', expire: '积分过期', revoke: '积分撤销', team_internal: '历史团队流转' } as any)[r.type] || '其他'}</Tag> },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '总变动', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <Typography.Text strong style={{ color: Number(v) < 0 ? '#ef4444' : '#10b981' }}>{Number(v) > 0 ? '+' : ''}{Number(v || 0)}</Typography.Text> },
|
||||
{ title: '个人积分', dataIndex: 'personalAmount', width: 110, align: 'right' as const, render: (v: number) => Number(v || 0) === 0 ? '-' : Number(v || 0) },
|
||||
{ title: '团队积分', dataIndex: 'teamAmount', width: 110, align: 'right' as const, render: (v: number) => Number(v || 0) === 0 ? '-' : Number(v || 0) },
|
||||
{ title: '业务场景', dataIndex: 'billingSceneLabel', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: 'Token', dataIndex: 'totalTokens', width: 100, align: 'right' as const, render: (v: number) => v == null ? '-' : Number(v).toLocaleString() },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||
];
|
||||
|
||||
const cards = [
|
||||
['总可消费积分', Number(data.credits || 0), <WalletOutlined />],
|
||||
['个人可用积分', Number(data.personalCredits || 0), <WalletOutlined />],
|
||||
['团队可用积分', Number(data.teamAvailableCredits || 0), <TeamOutlined />],
|
||||
['团队冻结积分', Number(data.teamFrozenCredits || 0), <LockOutlined />],
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<div style={{
|
||||
borderRadius: 16, padding: 24,
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #ea580c 100%)',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<WalletOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />
|
||||
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>可用积分</span>
|
||||
</div>
|
||||
<div style={{ color: '#fff', fontSize: 38, fontWeight: 800, lineHeight: 1 }}>
|
||||
{credits.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<div style={{
|
||||
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<PlusCircleOutlined style={{ color: '#10b981', fontSize: 14 }} />
|
||||
</div>
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计获得</span>
|
||||
</div>
|
||||
<div style={{ color: '#10b981', fontSize: 28, fontWeight: 800 }}>
|
||||
{totalGranted.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<div style={{
|
||||
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(239,68,68,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ThunderboltOutlined style={{ color: '#ef4444', fontSize: 14 }} />
|
||||
</div>
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计消耗</span>
|
||||
</div>
|
||||
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}>
|
||||
{totalConsumed.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||
{cards.map(([label, value, icon]: any) => <Col xs={12} lg={6} key={label}><div style={{ border: '1px solid #f0f0f5', background: '#fff', padding: 18, borderRadius: 14 }}><div style={{ color: '#64748b' }}>{icon} {label}</div><div style={{ fontSize: 26, fontWeight: 800, marginTop: 8 }}>{Number(value).toLocaleString()}</div></div></Col>)}
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<ThunderboltOutlined style={{ color: '#f59e0b', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>积分明细</Typography.Text>
|
||||
</div>
|
||||
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||
<Spin spinning={loading}>
|
||||
{records.length === 0 ? (
|
||||
<Empty
|
||||
description={<span style={{ color: '#94a3b8' }}>暂无积分变动记录</span>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
dataSource={records}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}><Typography.Title level={5} style={{ margin: 0 }}><ThunderboltOutlined /> 积分流水</Typography.Title><span style={{ color: '#94a3b8' }}>累计获得 {Number(data.totalGranted || 0).toLocaleString()} · 累计消耗 {Number(data.totalConsumed || 0).toLocaleString()}</span></div>
|
||||
{(data.records || []).length === 0 ? <Empty description="暂无积分变动记录" /> : <Table rowKey="id" columns={columns} dataSource={data.records || []} pagination={false} scroll={{ x: 1050 }} />}
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}><Pagination current={page} pageSize={pageSize} total={Number(data.total || 0)} onChange={(p, ps) => { setPage(p); setPageSize(ps); }} /></div>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditRecordsPage;
|
||||
export default CreditRecordsPage;
|
||||
|
||||
@@ -1,175 +1,78 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Col, Row, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getCredits } from '../api';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { formatDate, formatDatePrecise } from '../utils/formatDate';
|
||||
import type { CreditRecord } from '../types';
|
||||
|
||||
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 1200 }) => {
|
||||
const [display, setDisplay] = useState(0);
|
||||
const ref = useRef<number>(0);
|
||||
useEffect(() => {
|
||||
const start = display;
|
||||
const diff = value - start;
|
||||
if (diff === 0) return;
|
||||
const startTime = performance.now();
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setDisplay(start + diff * eased);
|
||||
if (progress < 1) ref.current = requestAnimationFrame(animate);
|
||||
};
|
||||
ref.current = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(ref.current);
|
||||
}, [value]);
|
||||
return <>{display.toLocaleString()}</>;
|
||||
};
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Col, Empty, Pagination, Row, Spin, Table, Tag, Typography } from 'antd';
|
||||
import { LockOutlined, TeamOutlined, WalletOutlined } from '@ant-design/icons';
|
||||
import { getCreditBalances, getCredits } from '../api';
|
||||
import { formatDatePrecise } from '../utils/formatDate';
|
||||
|
||||
const CreditsPage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [records, setRecords] = useState<CreditRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [summary, setSummary] = useState({ personalCredits: 0, teamAvailableCredits: 0, teamFrozenCredits: 0, credits: 0, nextExpiringCredits: 0, nextLastUsableAt: null as string | null });
|
||||
const [balances, setBalances] = useState<any[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [availableCredits, setAvailableCredits] = useState(0);
|
||||
const [nextExpiringCredits, setNextExpiringCredits] = useState(0);
|
||||
const [nextLastUsableAt, setNextLastUsableAt] = useState<string | null>(null);
|
||||
const [totalConsumed, setTotalConsumed] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getCredits(page, pageSize);
|
||||
setRecords(data.records);
|
||||
setTotal(data.total);
|
||||
setAvailableCredits(data.availableCredits ?? data.credits ?? 0);
|
||||
setNextExpiringCredits(data.nextExpiringCredits ?? 0);
|
||||
setNextLastUsableAt(data.nextLastUsableAt ?? null);
|
||||
setTotalConsumed(data.totalConsumed ?? 0);
|
||||
setLoading(false);
|
||||
try {
|
||||
const [creditData, balanceData] = await Promise.all([getCredits(1, 1), getCreditBalances(page, pageSize)]);
|
||||
setSummary({
|
||||
personalCredits: Number(creditData.personalCredits || 0),
|
||||
teamAvailableCredits: Number(creditData.teamAvailableCredits || 0),
|
||||
teamFrozenCredits: Number(creditData.teamFrozenCredits || 0),
|
||||
credits: Number(creditData.credits || creditData.availableCredits || 0),
|
||||
nextExpiringCredits: Number(creditData.nextExpiringCredits || 0),
|
||||
nextLastUsableAt: creditData.nextLastUsableAt || null,
|
||||
});
|
||||
setBalances(balanceData || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetch();
|
||||
void load();
|
||||
}, [page, pageSize]);
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
const cards = [
|
||||
{ title: '个人可用积分', value: summary.personalCredits, icon: <WalletOutlined />, note: '个人积分可正常用于业务消费' },
|
||||
{ title: '团队可用积分', value: summary.teamAvailableCredits, icon: <TeamOutlined />, note: '仅统计当前有效席位可消费额度' },
|
||||
{ title: '团队冻结积分', value: summary.teamFrozenCredits, icon: <LockOutlined />, note: '团队禁用期间继续发放和过期,但不可消费' },
|
||||
{ title: '总可消费积分', value: summary.credits, icon: <WalletOutlined />, note: '个人积分 + 当前可用团队积分' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100,
|
||||
render: (type: string) => {
|
||||
const labels: Record<string, { label: string; color: string; positive: boolean }> = {
|
||||
recharge: { label: '发放/充值', color: 'green', positive: true },
|
||||
consume: { label: '消费', color: 'orange', positive: false },
|
||||
refund: { label: '业务退款', color: 'purple', positive: true },
|
||||
expire: { label: '积分过期', color: 'default', positive: false },
|
||||
revoke: { label: '积分撤销', color: 'red', positive: false },
|
||||
team_internal: { label: '历史团队流转', color: 'blue', positive: false },
|
||||
};
|
||||
const config = labels[type] || { label: type, color: 'default', positive: false };
|
||||
return <Tag color={config.color} icon={config.positive ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>{config.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '有效积分变动', key: 'balanceDelta', width: 150,
|
||||
render: (_: unknown, record: CreditRecord) => {
|
||||
const delta = record.balanceDelta ?? record.amount ?? 0;
|
||||
const expired = record.expiredAmount ?? 0;
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text strong style={{ color: delta > 0 ? '#10b981' : delta < 0 ? '#ef4444' : '#64748b', fontSize: 15 }}>
|
||||
{delta > 0 ? '+' : ''}{delta}
|
||||
</Typography.Text>
|
||||
{expired > 0 && <div style={{ color: '#94a3b8', fontSize: 12 }}>已过期 {expired}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', key: 'description' },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
||||
render: (d: string) => <span className="date-display" translate="no" style={{ color: '#94a3b8' }}>{formatDate(d)}</span>,
|
||||
},
|
||||
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string, r: any) => <Tag color={r.creditScope === 'team' ? 'blue' : 'green'}>{v || (r.creditScope === 'team' ? '团队积分' : '个人积分')}</Tag> },
|
||||
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '-' },
|
||||
{ title: '来源', dataIndex: 'sourceTypeLabel', width: 150, render: (v: string) => v || '-' },
|
||||
{ title: '发放积分', dataIndex: 'grantAmount', width: 110, align: 'right' as const },
|
||||
{ title: '剩余积分', dataIndex: 'unspentAmount', width: 110, align: 'right' as const, render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text> },
|
||||
{ title: '状态', dataIndex: 'statusLabel', width: 100, render: (v: string) => <Tag>{v || '其他状态'}</Tag> },
|
||||
{ title: '最后可用时间', dataIndex: 'lastUsableAt', width: 190, render: (v: string) => v ? formatDatePrecise(v) : '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Balance cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<div className="animate-fadeInUp" style={{
|
||||
borderRadius: 16, padding: 24,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Space style={{ marginBottom: 10 }}>
|
||||
<WalletOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />
|
||||
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>当前可用积分</span>
|
||||
</Space>
|
||||
<div style={{ color: '#fff', fontSize: 38, fontWeight: 800, lineHeight: 1 }}>
|
||||
<AnimatedNumber value={availableCredits} />
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 18 }}>
|
||||
{cards.map((card) => (
|
||||
<Col xs={24} sm={12} lg={6} key={card.title}>
|
||||
<div style={{ background: '#fff', border: '1px solid #f0f0f5', borderRadius: 16, padding: 20, minHeight: 132 }}>
|
||||
<div style={{ display: 'flex', gap: 8, color: '#64748b', alignItems: 'center' }}>{card.icon}<span>{card.title}</span></div>
|
||||
<div style={{ fontSize: 30, fontWeight: 800, color: '#1e293b', margin: '10px 0 6px' }}>{card.value.toLocaleString()}</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{card.note}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<div className="animate-fadeInUp" style={{
|
||||
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5', animationDelay: '0.06s',
|
||||
}}>
|
||||
<Space style={{ marginBottom: 10 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ArrowUpOutlined style={{ color: '#10b981', fontSize: 14 }} />
|
||||
</div>
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>最近即将过期</span>
|
||||
</Space>
|
||||
<div style={{ color: '#f59e0b', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={nextExpiringCredits} /></div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12, marginTop: 6 }}>{nextLastUsableAt ? `最后可用:${formatDatePrecise(nextLastUsableAt)}` : '暂无即将过期积分'}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<div className="animate-fadeInUp" style={{
|
||||
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5', animationDelay: '0.12s',
|
||||
}}>
|
||||
<Space style={{ marginBottom: 10 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(239,68,68,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ArrowDownOutlined style={{ color: '#ef4444', fontSize: 14 }} />
|
||||
</div>
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计消耗</span>
|
||||
</Space>
|
||||
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalConsumed} /></div>
|
||||
</div>
|
||||
</Col>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
{/* Records */}
|
||||
<div className="animate-fadeInUp" style={{ animationDelay: '0.15s' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<ThunderboltOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>积分明细</Typography.Text>
|
||||
</div>
|
||||
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||
<Table columns={columns} dataSource={records} rowKey="id" loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 500 }} />
|
||||
{summary.nextExpiringCredits > 0 && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 14px', borderRadius: 10, background: '#fffbe6', color: '#8c6d1f' }}>
|
||||
最近将有 {summary.nextExpiringCredits.toLocaleString()} 积分到期,最后可用时间:{summary.nextLastUsableAt ? formatDatePrecise(summary.nextLastUsableAt) : '-'}。
|
||||
</div>
|
||||
)}
|
||||
<Typography.Title level={5}>个人积分批次</Typography.Title>
|
||||
{balances.length === 0 ? <Empty description="暂无个人积分批次" /> : <Table rowKey="id" columns={columns} dataSource={balances} pagination={false} scroll={{ x: 900 }} />}
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}>
|
||||
<Pagination current={page} pageSize={pageSize} onChange={(p, ps) => { setPage(p); setPageSize(ps); }} showSizeChanger pageSizeOptions={[20, 50, 100]} />
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,215 +1,49 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Table, Tag, Empty, Spin, Typography, Pagination, Select, DatePicker, Space } from 'antd';
|
||||
import { FileTextOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
|
||||
import { DatePicker, Empty, Pagination, Select, Spin, Table, Tag, Typography } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { getPaymentOrders } from '../api';
|
||||
|
||||
const PAYMENT_METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
|
||||
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
|
||||
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', fulfilled: '已履约', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败', processing: '处理中' };
|
||||
|
||||
const OrderRecordsPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [statusFilter, setStatusFilter] = useState<string>();
|
||||
const [dateRange, setDateRange] = useState<any>([null, null]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getPaymentOrders(page, pageSize, {
|
||||
statusFilter: statusFilter || undefined,
|
||||
startDate: dateRange[0]?.format?.('YYYY-MM-DD'),
|
||||
endDate: dateRange[1]?.format?.('YYYY-MM-DD'),
|
||||
});
|
||||
setOrders(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch {
|
||||
setOrders([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, statusFilter, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatusFilter('');
|
||||
setDateRange([null, null]);
|
||||
setPage(1);
|
||||
};
|
||||
const data = await getPaymentOrders(page, pageSize, { statusFilter, startDate: dateRange[0]?.format?.('YYYY-MM-DD'), endDate: dateRange[1]?.format?.('YYYY-MM-DD') });
|
||||
setOrders(data.items || []); setTotal(Number(data.total || 0));
|
||||
} finally { setLoading(false); }
|
||||
}, [page, pageSize, statusFilter, dateRange]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '订单号',
|
||||
key: 'orderNo',
|
||||
width: 200,
|
||||
render: (_, record: any) => {
|
||||
const orderNo = record.order_no || record.orderNo || record.id;
|
||||
return (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 13, color: '#64748b' }}>
|
||||
{orderNo}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '支付方式',
|
||||
key: 'paymentMethod',
|
||||
width: 100,
|
||||
render: (_, record: any) => {
|
||||
const method = record.payment_method || record.paymentMethod || 'wechat';
|
||||
const methodConfig: Record<string, { icon: React.ReactNode; label: string; color: string }> = {
|
||||
alipay: { icon: <AlipayCircleOutlined style={{ fontSize: 16 }} />, label: '支付宝', color: '#1677ff' },
|
||||
wechat: { icon: <WechatOutlined style={{ fontSize: 16 }} />, label: '微信支付', color: '#07c160' },
|
||||
};
|
||||
const config = methodConfig[method] || methodConfig.wechat;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ color: config.color }}>{config.icon}</span>
|
||||
<span style={{ fontSize: 13, color: '#374151' }}>{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
key: 'amount',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (_, record: any) => {
|
||||
const amount = record.amount || record.total_amount || 0;
|
||||
return (
|
||||
<Typography.Text strong style={{ fontWeight: 700, fontSize: 14, color: '#1e293b' }}>
|
||||
¥{amount}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '获得积分',
|
||||
key: 'credits',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
render: (_, record: any) => {
|
||||
const credits = record.credits || record.credit_amount || 0;
|
||||
return (
|
||||
<Typography.Text strong style={{ fontWeight: 600, fontSize: 14, color: '#f59e0b' }}>
|
||||
{credits}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (_, record: any) => {
|
||||
const status = record.status || 'pending';
|
||||
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
pending: { color: '#f59e0b', label: '待支付', bg: 'rgba(245,158,11,0.1)' },
|
||||
paid: { color: '#10b981', label: '已支付', bg: 'rgba(16,185,129,0.1)' },
|
||||
refunded: { color: '#94a3b8', label: '已退款', bg: 'rgba(148,163,184,0.1)' },
|
||||
failed: { color: '#ef4444', label: '支付失败', bg: 'rgba(239,68,68,0.1)' },
|
||||
cancelled: { color: '#64748b', label: '已取消', bg: 'rgba(100,116,139,0.1)' },
|
||||
processing: { color: '#0ea5e9', label: '处理中', bg: 'rgba(14,165,233,0.1)' },
|
||||
};
|
||||
const config = statusConfig[status] || { color: '#64748b', label: status, bg: 'rgba(100,116,139,0.1)' };
|
||||
return (
|
||||
<Tag color={config.color} style={{ background: config.bg, fontWeight: 500, padding: '4px 12px' }}>
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (_, record: any) => {
|
||||
const createdAt = record.created_at || record.createdAt;
|
||||
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
key: 'paidAt',
|
||||
width: 180,
|
||||
render: (_, record: any) => {
|
||||
const paidAt = record.paid_at || record.paidAt;
|
||||
return paidAt ? new Date(paidAt).toLocaleString('zh-CN') : '-';
|
||||
},
|
||||
},
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
|
||||
{ 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: 180, ellipsis: true, render: (v: string, r: any) => <span>{v || '-'}{Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}</span> },
|
||||
{ title: '支付方式', dataIndex: 'paymentMethod', width: 125, render: (v: string, r: any) => r.paymentMethodLabel || (v === 'other' && r.offlinePaymentDetail ? r.offlinePaymentDetail : PAYMENT_METHOD_LABELS[v]) || '其他支付方式' },
|
||||
{ title: '实际金额', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||
{ title: '支付时间', dataIndex: 'paidAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FileTextOutlined style={{ color: '#0ea5e9', fontSize: 16 }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>订单记录</Typography.Text>
|
||||
<span style={{ color: '#94a3b8', fontSize: 13 }}>共 {total} 条记录</span>
|
||||
</div>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Select
|
||||
placeholder="支付状态"
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
value={statusFilter || undefined}
|
||||
onChange={(v) => { setStatusFilter(v || ''); setPage(1); }}
|
||||
options={[
|
||||
{ value: 'pending', label: '待支付' },
|
||||
{ value: 'paid', label: '已支付' },
|
||||
{ value: 'refunded', label: '已退款' },
|
||||
{ value: 'failed', label: '支付失败' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
]}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => { setDateRange(dates); setPage(1); }}
|
||||
allowClear
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
{(statusFilter || dateRange[0] || dateRange[1]) && (
|
||||
<Typography.Link onClick={handleReset}>重置</Typography.Link>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||
<Spin spinning={loading}>
|
||||
{orders.length === 0 ? (
|
||||
<Empty
|
||||
description={<span style={{ color: '#94a3b8' }}>暂无订单记录</span>}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
dataSource={orders}
|
||||
columns={columns}
|
||||
rowKey="order_no"
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</div>
|
||||
return <div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 14 }}><FileTextOutlined /><Typography.Title level={5} style={{ margin: 0 }}>订单记录</Typography.Title><span style={{ color: '#94a3b8' }}>线下售后订单同样在此只读展示</span></div>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Select allowClear placeholder="订单状态" style={{ width: 140 }} value={statusFilter} onChange={(v) => { setStatusFilter(v); setPage(1); }} options={Object.entries(STATUS_LABELS).filter(([k]) => ['pending','paid','refunded','cancelled','expired','failed'].includes(k)).map(([value,label]) => ({ value,label }))} />
|
||||
<DatePicker.RangePicker value={dateRange} onChange={(v) => { setDateRange(v || [null, null]); setPage(1); }} placeholder={['开始日期','结束日期']} />
|
||||
</div>
|
||||
);
|
||||
<Spin spinning={loading}>{orders.length === 0 ? <Empty description="暂无订单记录" /> : <Table rowKey="id" columns={columns} dataSource={orders} pagination={false} scroll={{ x: 1150 }} />}</Spin>
|
||||
<div style={{ marginTop: 16, textAlign: 'right' }}><Pagination current={page} pageSize={pageSize} total={total} showSizeChanger onChange={(p, ps) => { setPage(p); setPageSize(ps); }} /></div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default OrderRecordsPage;
|
||||
export default OrderRecordsPage;
|
||||
|
||||
@@ -1,676 +1,239 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Empty, Form, Input, InputNumber, message, Modal, Pagination, Radio, Select, Segmented, Space, Table, Tabs, Tag, Tooltip, Typography,
|
||||
Alert, Button, Card, Col, DatePicker, Empty, Form, Input, InputNumber, message, Modal,
|
||||
Pagination, Popconfirm, Row, Select, Space, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import { DatePicker } from 'antd';
|
||||
import {
|
||||
DownloadOutlined, LockOutlined, PlusOutlined, ReloadOutlined,
|
||||
TeamOutlined, UserSwitchOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
CopyOutlined, DownloadOutlined, PlusOutlined, ReloadOutlined, UserOutlined, HistoryOutlined, WalletOutlined, BellOutlined, ClockCircleOutlined, CheckOutlined, CloseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
cancelTeamSeat, createTeamInvitation, createTeamSeat, getManagerAccessTeams, getManagedTeam,
|
||||
getPendingJoinRequests, getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations,
|
||||
getTeamManagerHistory, getTeamMembers, getTeamMemberUsage, getTeamSubscriptions,
|
||||
handleJoinRequest, revokeInvitation, transferTeamManager, updateTeamSeat,
|
||||
} from '../api';
|
||||
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember, TeamMemberUsage, TeamSeat, TeamSubscriptionManage } from '../types';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
import {
|
||||
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
|
||||
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest,
|
||||
} from '../api';
|
||||
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
const fmt = (value?: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||
|
||||
/* ── 工具函数 ────────────────────────────────────────── */
|
||||
function formatDateTime(value: any): string {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
const RECORD_TYPE_CONFIG: Record<string, { color: string; label: string }> = {
|
||||
recharge: { color: 'green', label: '充值' },
|
||||
consume: { color: 'red', label: '消费' },
|
||||
refund: { color: 'orange', label: '退款' },
|
||||
team_internal: { color: 'blue', label: '团队内部' },
|
||||
};
|
||||
|
||||
/* ── 主组件 ──────────────────────────────────────────── */
|
||||
const TeamManagementPage: React.FC = () => {
|
||||
const [team, setTeam] = useState<ManagedTeam | null>(null);
|
||||
const [teamLoading, setTeamLoading] = useState(false);
|
||||
|
||||
const loadTeam = useCallback(async () => {
|
||||
setTeamLoading(true);
|
||||
try {
|
||||
const data = await getManagedTeam();
|
||||
setTeam(data);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取团队信息失败');
|
||||
} finally {
|
||||
setTeamLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTeam(); }, [loadTeam]);
|
||||
|
||||
// ── Tab 1: 成员 ──
|
||||
const [accessTeams, setAccessTeams] = useState<any[]>([]);
|
||||
const [selectedFlowTeamId, setSelectedFlowTeamId] = useState<string>();
|
||||
const [members, setMembers] = useState<TeamMember[]>([]);
|
||||
const [membersTotal, setMembersTotal] = useState(0);
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
const [membersPage, setMembersPage] = useState(1);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
if (!team) return;
|
||||
setMembersLoading(true);
|
||||
try {
|
||||
const res = await getTeamMembers(membersPage);
|
||||
setMembers(res.items || []);
|
||||
setMembersTotal(res.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载成员失败');
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
}, [team, membersPage]);
|
||||
|
||||
useEffect(() => { loadMembers(); }, [loadMembers]);
|
||||
|
||||
// ── Tab 2: 邀请码 ──
|
||||
const [memberTotal, setMemberTotal] = useState(0);
|
||||
const [memberPage, setMemberPage] = useState(1);
|
||||
const [subscriptions, setSubscriptions] = useState<TeamSubscriptionManage[]>([]);
|
||||
const [usage, setUsage] = useState<TeamMemberUsage[]>([]);
|
||||
const [history, setHistory] = useState<any[]>([]);
|
||||
const [invitations, setInvitations] = useState<TeamInvitation[]>([]);
|
||||
const [invLoading, setInvLoading] = useState(false);
|
||||
const [invModal, setInvModal] = useState(false);
|
||||
const [invForm] = Form.useForm();
|
||||
const [invSaving, setInvSaving] = useState(false);
|
||||
|
||||
const loadInvitations = useCallback(async () => {
|
||||
setInvLoading(true);
|
||||
try {
|
||||
const data = await getTeamInvitations();
|
||||
setInvitations(data || []);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载邀请码失败');
|
||||
} finally {
|
||||
setInvLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadInvitations(); }, [loadInvitations]);
|
||||
|
||||
const handleCreateInvitation = async () => {
|
||||
try {
|
||||
const values = await invForm.validateFields();
|
||||
setInvSaving(true);
|
||||
await createTeamInvitation(values.maxUses || null, null);
|
||||
message.success('邀请码已生成');
|
||||
setInvModal(false);
|
||||
invForm.resetFields();
|
||||
loadInvitations();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '创建失败');
|
||||
} finally {
|
||||
setInvSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (invId: string) => {
|
||||
try {
|
||||
await revokeInvitation(invId);
|
||||
message.success('已撤销');
|
||||
loadInvitations();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '撤销失败');
|
||||
}
|
||||
};
|
||||
|
||||
const copyInviteLink = (link: string) => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
message.success('邀请链接已复制');
|
||||
}).catch(() => {
|
||||
fallbackCopy(link);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(link);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
message.success('邀请链接已复制');
|
||||
} catch {
|
||||
message.warning('复制失败,请手动复制');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
};
|
||||
|
||||
// ── Tab 3: 加入申请 ──
|
||||
const [requests, setRequests] = useState<TeamJoinRequest[]>([]);
|
||||
const [reqLoading, setReqLoading] = useState(false);
|
||||
const [reqStatusFilter, setReqStatusFilter] = useState<string>('pending');
|
||||
const [activeTab, setActiveTab] = useState('members');
|
||||
const initialNoticeShownRef = useRef(false);
|
||||
const lastRequestCountRef = useRef(0);
|
||||
const [flow, setFlow] = useState<any>({ items: [], total: 0 });
|
||||
const [flowPage, setFlowPage] = useState(1);
|
||||
const [flowType, setFlowType] = useState<string>();
|
||||
const [flowPhone, setFlowPhone] = useState('');
|
||||
const [flowSubscriptionId, setFlowSubscriptionId] = useState<string>();
|
||||
const [flowDates, setFlowDates] = useState<any>([null, null]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seatModal, setSeatModal] = useState<{ open: boolean; subscriptionId?: string; seat?: TeamSeat }>({ open: false });
|
||||
const [seatForm] = Form.useForm();
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
const [inviteForm] = Form.useForm();
|
||||
|
||||
const loadRequests = useCallback(async (status: string, isInitial = false) => {
|
||||
setReqLoading(true);
|
||||
const readOnly = !!team?.isReadOnly;
|
||||
const currentManager = !!team;
|
||||
|
||||
const loadBase = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getPendingJoinRequests(status);
|
||||
const currentCount = data?.length || 0;
|
||||
setRequests(data || []);
|
||||
|
||||
// 只有待处理状态才显示弹窗通知
|
||||
if (status === 'pending' && currentCount > 0) {
|
||||
if (isInitial && !initialNoticeShownRef.current) {
|
||||
initialNoticeShownRef.current = true;
|
||||
Modal.confirm({
|
||||
title: (
|
||||
<Space>
|
||||
<BellOutlined style={{ color: '#f59e0b' }} />
|
||||
待处理的加入申请
|
||||
</Space>
|
||||
),
|
||||
content: (
|
||||
<div>
|
||||
<p>您有 <strong style={{ color: '#ef4444' }}>{currentCount}</strong> 条待处理的团队加入申请。</p>
|
||||
<p style={{ color: '#64748b', fontSize: 13, marginBottom: 0 }}>请及时处理新成员的加入申请。</p>
|
||||
</div>
|
||||
),
|
||||
okText: '立即处理',
|
||||
cancelText: '稍后处理',
|
||||
onOk: () => {
|
||||
setActiveTab('requests');
|
||||
},
|
||||
});
|
||||
} else if (!isInitial && currentCount > lastRequestCountRef.current) {
|
||||
message.info({
|
||||
content: `有 ${currentCount - lastRequestCountRef.current} 条新的加入申请待处理`,
|
||||
duration: 5,
|
||||
});
|
||||
}
|
||||
const access = await getManagerAccessTeams().catch(() => []);
|
||||
setAccessTeams(access || []);
|
||||
const current = await getManagedTeam().catch(() => null);
|
||||
setTeam(current);
|
||||
const defaultTeam = current?.id || access?.[0]?.id;
|
||||
setSelectedFlowTeamId((old) => old || defaultTeam);
|
||||
if (current) {
|
||||
const [memberData, subData, usageData, historyData, invData, requestData] = await Promise.all([
|
||||
getTeamMembers(memberPage, 20), getTeamSubscriptions(), getTeamMemberUsage(), getTeamManagerHistory(), getTeamInvitations(), getPendingJoinRequests(),
|
||||
]);
|
||||
setMembers(memberData?.items || []); setMemberTotal(Number(memberData?.total || 0));
|
||||
setSubscriptions(subData || []); setUsage(usageData || []); setHistory(historyData || []);
|
||||
setInvitations(invData || []); setRequests(requestData || []);
|
||||
} else {
|
||||
setMembers([]); setSubscriptions([]); setUsage([]); setHistory([]); setInvitations([]); setRequests([]);
|
||||
}
|
||||
} finally { setLoading(false); }
|
||||
}, [memberPage]);
|
||||
|
||||
if (status === 'pending') {
|
||||
lastRequestCountRef.current = currentCount;
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载申请失败');
|
||||
} finally {
|
||||
setReqLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests('pending', true);
|
||||
}, [loadRequests]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!team) return;
|
||||
const interval = setInterval(() => {
|
||||
loadRequests('pending', false);
|
||||
}, 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [team, loadRequests]);
|
||||
|
||||
const handleRequest = async (requestId: string, action: 'approve' | 'reject', note?: string) => {
|
||||
const loadFlow = useCallback(async () => {
|
||||
if (!selectedFlowTeamId) { setFlow({ items: [], total: 0 }); return; }
|
||||
try {
|
||||
await handleJoinRequest(requestId, action, note);
|
||||
message.success(action === 'approve' ? '已通过' : '已拒绝');
|
||||
loadRequests(reqStatusFilter);
|
||||
loadMembers();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ── Tab 4: 团队积分变动 ──
|
||||
const [creditRecords, setCreditRecords] = useState<any[]>([]);
|
||||
const [creditTotal, setCreditTotal] = useState(0);
|
||||
const [creditSummary, setCreditSummary] = useState<any>(null);
|
||||
const [creditLoading, setCreditLoading] = useState(false);
|
||||
const [creditPage, setCreditPage] = useState(1);
|
||||
const [creditFilterType, setCreditFilterType] = useState<string>('');
|
||||
const [creditFilterPhone, setCreditFilterPhone] = useState<string>('');
|
||||
const [creditDateRange, setCreditDateRange] = useState<[string, string] | null>(() => {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
return [today, today];
|
||||
});
|
||||
|
||||
const loadCreditRecords = useCallback(async () => {
|
||||
setCreditLoading(true);
|
||||
try {
|
||||
const res = await getTeamCreditRecords({
|
||||
page: creditPage,
|
||||
pageSize: 10,
|
||||
phone: creditFilterPhone || undefined,
|
||||
recordType: creditFilterType || undefined,
|
||||
startDate: creditDateRange?.[0] || undefined,
|
||||
endDate: creditDateRange?.[1] || undefined,
|
||||
const data = await getTeamCreditRecords({
|
||||
teamId: selectedFlowTeamId, page: flowPage, pageSize: 20, phone: flowPhone || undefined,
|
||||
subscriptionId: flowSubscriptionId, recordType: flowType,
|
||||
startDate: flowDates[0]?.format?.('YYYY-MM-DD'), endDate: flowDates[1]?.format?.('YYYY-MM-DD'),
|
||||
});
|
||||
setCreditRecords(res.items || []);
|
||||
setCreditTotal(res.total || 0);
|
||||
setCreditSummary(res.summary || null);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载积分记录失败');
|
||||
} finally {
|
||||
setCreditLoading(false);
|
||||
}
|
||||
}, [creditPage, creditFilterType, creditFilterPhone, creditDateRange]);
|
||||
setFlow(data || { items: [], total: 0 });
|
||||
} catch (e: any) { message.error(e?.message || '团队流水加载失败'); }
|
||||
}, [selectedFlowTeamId, flowPage, flowPhone, flowSubscriptionId, flowType, flowDates]);
|
||||
|
||||
useEffect(() => { loadCreditRecords(); }, [loadCreditRecords]);
|
||||
useEffect(() => { void loadBase(); }, [loadBase]);
|
||||
useEffect(() => { void loadFlow(); }, [loadFlow]);
|
||||
|
||||
const resetCreditFilters = () => {
|
||||
setCreditFilterType('');
|
||||
setCreditFilterPhone('');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
setCreditDateRange([today, today]);
|
||||
setCreditPage(1);
|
||||
const openSeatCreate = (subscriptionId: string) => {
|
||||
seatForm.resetFields();
|
||||
const target = subscriptions.find((item) => item.subscription?.id === subscriptionId);
|
||||
const activeSeats = Math.max(1, Number(target?.seatLimit || 1) - Number(target?.activeSeatCount || 0));
|
||||
const defaultAmount = Number(target?.periodUnallocatedCredits || 0) > 0 ? Math.floor((Number(target?.periodUnallocatedCredits || 0) / activeSeats) * 100) / 100 : undefined;
|
||||
seatForm.setFieldsValue({ monthlyAllocatedCredits: defaultAmount });
|
||||
setSeatModal({ open: true, subscriptionId });
|
||||
};
|
||||
|
||||
const handleExportCredits = () => {
|
||||
const url = getTeamCreditExportUrl({
|
||||
phone: creditFilterPhone || undefined,
|
||||
recordType: creditFilterType || undefined,
|
||||
startDate: creditDateRange?.[0] || undefined,
|
||||
endDate: creditDateRange?.[1] || undefined,
|
||||
});
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
|
||||
fetch(url, { headers })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `团队积分_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
})
|
||||
.catch(() => message.error('导出失败'));
|
||||
const openSeatEdit = (seat: TeamSeat) => {
|
||||
seatForm.setFieldsValue({ userId: seat.userId, monthlyAllocatedCredits: seat.monthlyAllocatedCredits });
|
||||
setSeatModal({ open: true, subscriptionId: seat.subscriptionId, seat });
|
||||
};
|
||||
|
||||
const saveSeat = async () => {
|
||||
try {
|
||||
const values = await seatForm.validateFields();
|
||||
if (seatModal.seat) await updateTeamSeat(seatModal.seat.id, Number(values.monthlyAllocatedCredits));
|
||||
else await createTeamSeat(String(seatModal.subscriptionId), String(values.userId), Number(values.monthlyAllocatedCredits));
|
||||
message.success(seatModal.seat ? '席位额度已更新' : '席位已创建');
|
||||
setSeatModal({ open: false }); await loadBase();
|
||||
} catch (e: any) { if (!e?.errorFields) message.error(e?.message || '席位保存失败'); }
|
||||
};
|
||||
|
||||
const removeSeat = async (seatId: string) => {
|
||||
try { await cancelTeamSeat(seatId); message.success('席位已取消,未使用额度已释放'); await loadBase(); }
|
||||
catch (e: any) { message.error(e?.message || '席位取消失败'); }
|
||||
};
|
||||
|
||||
/* ── 表格列定义 ──────────────────────────────────────── */
|
||||
const memberColumns = [
|
||||
{ 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> },
|
||||
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: () => (
|
||||
<Tooltip title="当前版本积分暂未开放团队转账功能">
|
||||
<Button size="small" type="link" style={{ padding: 0, color: '#999' }} disabled>积分转账未开放</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{ title: '成员', dataIndex: 'username', render: (v: string, r: TeamMember) => <Space><span>{v}</span>{r.id === team?.managerId && <Tag color="purple">队长</Tag>}</Space> },
|
||||
{ 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: '操作', width: 110, render: (_: any, r: TeamMember) => r.id !== team?.managerId ? <Popconfirm title="确认将该成员设为新队长?" description="仅当所有团队订阅均已结束且没有未完成团队订单时可操作。" onConfirm={async () => { try { await transferTeamManager(r.id); message.success('团队队长已更换'); await loadBase(); } catch (e: any) { message.error(e?.message || '更换队长失败'); } }}><Button type="link" disabled={readOnly} icon={<UserSwitchOutlined />}>设为队长</Button></Popconfirm> : '-' },
|
||||
];
|
||||
|
||||
const buildInviteLink = (code: string) => {
|
||||
const base = window.location.origin;
|
||||
return `${base}/join-team?code=${code}`;
|
||||
};
|
||||
|
||||
const invColumns = [
|
||||
{ title: '邀请码', dataIndex: 'code', width: 200, render: (v: string) => <Typography.Text copyable style={{ fontFamily: 'monospace' }}>{v}</Typography.Text> },
|
||||
{
|
||||
title: '邀请链接', dataIndex: 'code',
|
||||
render: (v: string) => {
|
||||
const link = buildInviteLink(v);
|
||||
return (
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Typography.Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
wordBreak: 'break-all',
|
||||
fontFamily: 'monospace',
|
||||
color: '#64748b',
|
||||
}}
|
||||
>
|
||||
{link}
|
||||
</Typography.Text>
|
||||
<Tooltip title="复制链接">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyInviteLink(link)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '有效' : '已撤销'}</Tag> },
|
||||
{ title: '使用次数', key: 'uses', width: 100, render: (_: any, r: TeamInvitation) => `${r.useCount}${r.maxUses ? `/${r.maxUses}` : ''}` },
|
||||
{ title: '过期时间', dataIndex: 'expiresAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '永不过期' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 80,
|
||||
render: (_: any, r: TeamInvitation) => r.status === 'active' ? (
|
||||
<Button size="small" type="link" danger onClick={() => handleRevoke(r.id)}>撤销</Button>
|
||||
) : null,
|
||||
},
|
||||
const seatColumns = (subscription: TeamSubscriptionManage) => [
|
||||
{ title: '成员', dataIndex: 'username', render: (v: string) => v || '-' },
|
||||
{ title: '月分配额度', dataIndex: 'monthlyAllocatedCredits', align: 'right' as const },
|
||||
{ title: '本期净消耗', dataIndex: 'currentPeriodUsedCredits', align: 'right' as const },
|
||||
{ title: '本期剩余额度', dataIndex: 'currentPeriodRemainingCredits', align: 'right' as const },
|
||||
{ title: '状态', dataIndex: 'statusLabel', render: (v: string) => <Tag>{v || '其他状态'}</Tag> },
|
||||
{ title: '操作', width: 140, render: (_: any, seat: TeamSeat) => seat.status === 'active' ? <Space><Button size="small" onClick={() => openSeatEdit(seat)} disabled={readOnly}>改额度</Button><Popconfirm title="确认取消席位?" onConfirm={() => removeSeat(seat.id)}><Button size="small" danger disabled={readOnly}>取消</Button></Popconfirm></Space> : '-' },
|
||||
];
|
||||
|
||||
const renderStatusTag = (status: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'orange', text: '待处理' },
|
||||
approved: { color: 'green', text: '已通过' },
|
||||
rejected: { color: 'red', text: '已拒绝' },
|
||||
};
|
||||
const info = map[status] || { color: 'default', text: status };
|
||||
return <Tag color={info.color}>{info.text}</Tag>;
|
||||
};
|
||||
const subscriptionView = subscriptions.length === 0 ? <Empty description="暂无团队订阅" /> : <Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
{subscriptions.map((item) => {
|
||||
const sub = item.subscription || {};
|
||||
return <Card key={sub.id} title={<Space><WalletOutlined /><span>{sub.productNameSnapshot || '团队订阅套餐'}</span><Tag>{sub.billingCycleLabel || '订阅周期'}</Tag><Tag color={sub.status === 'active' ? 'green' : 'default'}>{sub.statusLabel || '其他状态'}</Tag></Space>} extra={<Button icon={<PlusOutlined />} disabled={readOnly || sub.status !== 'active' || item.activeSeatCount >= item.seatLimit} onClick={() => openSeatCreate(sub.id)}>分配席位</Button>}>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||||
<Col span={6}>席位:<b>{item.activeSeatCount}/{item.seatLimit}</b></Col><Col span={6}>本期总积分:<b>{Number(item.periodTotalCredits || 0).toLocaleString()}</b></Col>
|
||||
<Col span={6}>本期剩余资金:<b>{Number(item.periodUnspentCredits || 0).toLocaleString()}</b></Col><Col span={6}>未分配积分:<b>{Number(item.periodUnallocatedCredits || 0).toLocaleString()}</b></Col>
|
||||
<Col span={12}>订阅实例:<Typography.Text copyable={sub.subscriptionNo ? { text: sub.subscriptionNo } : false}>{sub.subscriptionNo || '-'}</Typography.Text></Col><Col span={12}>订阅到期:{fmt(sub.expiresAt)}</Col>
|
||||
<Col span={24}>本期:{fmt(item.currentPeriodStartAt)} ~ {fmt(item.currentPeriodExpiresAt)}</Col>
|
||||
</Row>
|
||||
<Table rowKey="id" size="small" pagination={false} columns={seatColumns(item)} dataSource={item.seats || []} scroll={{ x: 850 }} />
|
||||
</Card>;
|
||||
})}
|
||||
</Space>;
|
||||
|
||||
const reqColumns = [
|
||||
{ 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: 'status', width: 100, render: (v: string) => renderStatusTag(v) },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{ title: '处理时间', dataIndex: 'handledAt', width: 170, render: (v: string) => v ? formatDateTime(v) : '-' },
|
||||
{ title: '备注', dataIndex: 'note', width: 180, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作', key: 'action', width: 160,
|
||||
render: (_: any, r: TeamJoinRequest) => {
|
||||
if (r.status !== 'pending') return <span style={{ color: '#94a3b8', fontSize: 12 }}>已处理</span>;
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="link" icon={<CheckOutlined />} style={{ color: '#16a34a', padding: 0 }} onClick={() => handleRequest(r.id, 'approve')}>通过</Button>
|
||||
<Button size="small" type="link" icon={<CloseOutlined />} danger style={{ padding: 0 }} onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '拒绝申请',
|
||||
content: (
|
||||
<Form layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="note" label="拒绝原因(可选)">
|
||||
<Input.TextArea rows={2} placeholder="选填" id="reject-note-input" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: () => {
|
||||
const note = (document.getElementById('reject-note-input') as HTMLTextAreaElement)?.value || undefined;
|
||||
handleRequest(r.id, 'reject', note);
|
||||
},
|
||||
});
|
||||
}}>拒绝</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
const invitationColumns = [
|
||||
{ title: '邀请码', dataIndex: 'code', render: (v: string) => <Typography.Text copyable>{v}</Typography.Text> },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag>{v === 'active' ? '有效' : '已撤销'}</Tag> },
|
||||
{ title: '使用次数', render: (_: any, r: TeamInvitation) => `${r.useCount || 0}${r.maxUses ? ` / ${r.maxUses}` : ''}` },
|
||||
{ title: '过期时间', dataIndex: 'expiresAt', render: (v: string) => v ? fmt(v) : '长期有效' },
|
||||
{ title: '操作', render: (_: any, r: TeamInvitation) => r.status === 'active' ? <Button type="link" danger disabled={readOnly} onClick={async () => { try { await revokeInvitation(r.id); await loadBase(); message.success('邀请码已撤销'); } catch (e: any) { message.error(e?.message || '撤销失败'); } }}>撤销</Button> : '-' },
|
||||
];
|
||||
|
||||
const creditColumns = [
|
||||
{ title: '用户名', dataIndex: 'username', width: 120, render: (v: string) => <Typography.Text strong>{v || '-'}</Typography.Text> },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (_: any, r: any) => {
|
||||
const cfg = RECORD_TYPE_CONFIG[r.type] || { color: 'default', label: r.type || '-' };
|
||||
return <Tag color={cfg.color}>{cfg.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 110, align: 'right' as const,
|
||||
render: (_: any, r: any) => (
|
||||
<Typography.Text strong style={{ color: r.amount >= 0 ? '#10b981' : '#ef4444', fontSize: 14 }}>
|
||||
{r.amount >= 0 ? '+' : ''}{(r.amount ?? 0).toFixed(2)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balanceAfter', width: 100, align: 'right' as const, render: (v: number) => (v ?? 0).toFixed(2) },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true, minWidth: 160, render: (v: string) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
const requestColumns = [
|
||||
{ title: '申请人', dataIndex: 'username' }, { title: '手机号', dataIndex: 'phone', render: (v: string) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag>{({ pending: '待审批', approved: '已通过', rejected: '已拒绝' } as any)[v] || '其他状态'}</Tag> },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', render: (v: string) => fmt(v) },
|
||||
{ title: '操作', render: (_: any, r: TeamJoinRequest) => r.status === 'pending' ? <Space><Button size="small" type="primary" disabled={readOnly} onClick={async () => { try { await handleJoinRequest(r.id, 'approve'); await loadBase(); message.success('已通过申请'); } catch (e: any) { message.error(e?.message || '操作失败'); } }}>通过</Button><Button size="small" danger disabled={readOnly} onClick={async () => { try { await handleJoinRequest(r.id, 'reject'); await loadBase(); message.success('已拒绝申请'); } catch (e: any) { message.error(e?.message || '操作失败'); } }}>拒绝</Button></Space> : '-' },
|
||||
];
|
||||
|
||||
/* ── Tab 配置 ────────────────────────────────────────── */
|
||||
const tableWrapper: React.CSSProperties = { borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' };
|
||||
const paginationStyle: React.CSSProperties = { padding: '16px', textAlign: 'right' };
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'members',
|
||||
label: <Space><UserOutlined />成员列表{membersTotal > 0 && <span style={{ color: '#94a3b8', fontSize: 12 }}>({membersTotal})</span>}</Space>,
|
||||
children: (
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
loading={membersLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 800 }}
|
||||
locale={{ emptyText: <Empty description="暂无成员" /> }}
|
||||
/>
|
||||
{membersTotal > 0 && (
|
||||
<div style={paginationStyle}>
|
||||
<Pagination current={membersPage} pageSize={20} total={membersTotal} onChange={(p) => setMembersPage(p)} size="small" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
label: <Space><WalletOutlined />团队积分变动</Space>,
|
||||
children: (
|
||||
<div>
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Select
|
||||
value={creditFilterType || undefined}
|
||||
onChange={(v) => { setCreditFilterType(v || ''); setCreditPage(1); }}
|
||||
allowClear
|
||||
placeholder="交易类型"
|
||||
style={{ width: 130 }}
|
||||
options={[
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
{ value: 'refund', label: '退款' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="搜索手机号"
|
||||
value={creditFilterPhone}
|
||||
onChange={(e) => { setCreditFilterPhone(e.target.value); setCreditPage(1); }}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
/>
|
||||
<RangePicker
|
||||
value={creditDateRange ? [dayjs(creditDateRange[0]), dayjs(creditDateRange[1])] : undefined}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreditDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||||
} else {
|
||||
setCreditDateRange(null);
|
||||
}
|
||||
setCreditPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={resetCreditFilters}>重置</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}>导出 Excel</Button>
|
||||
</div>
|
||||
|
||||
{/* 汇总统计:净消耗 = 消费 - 退款,同时展示消费 / 退款 / 充值辅助详情 */}
|
||||
<div style={{ marginBottom: 12, padding: '12px 18px', background: '#f8f9fc', borderRadius: 10, display: 'flex', gap: 28, flexWrap: 'wrap', fontSize: 13, alignItems: 'center' }}>
|
||||
<span>
|
||||
总净消耗积分:
|
||||
<strong style={{ color: '#ef4444', fontSize: 16, marginLeft: 4 }}>
|
||||
{creditSummary?.netConsume ?? 0}
|
||||
</strong>
|
||||
</span>
|
||||
<span style={{ color: '#94a3b8' }}>|</span>
|
||||
<span>
|
||||
消费合计:
|
||||
<strong style={{ color: '#f97316', marginLeft: 4 }}>
|
||||
{creditSummary?.totalConsume ?? 0}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
退款合计:
|
||||
<strong style={{ color: '#10b981', marginLeft: 4 }}>
|
||||
− {creditSummary?.totalRefund ?? 0}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
充值合计:
|
||||
<strong style={{ color: '#6366f1', marginLeft: 4 }}>
|
||||
+ {creditSummary?.totalRecharge ?? 0}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={creditLoading}
|
||||
dataSource={creditRecords}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 950 }}
|
||||
columns={creditColumns}
|
||||
locale={{ emptyText: <Empty description="暂无积分记录" /> }}
|
||||
/>
|
||||
{creditTotal > 0 && (
|
||||
<div style={paginationStyle}>
|
||||
<Pagination current={creditPage} pageSize={10} total={creditTotal} onChange={(p) => setCreditPage(p)} size="small" showTotal={(t) => `共 ${t} 条`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'invitations',
|
||||
label: <Space><CopyOutlined />邀请管理</Space>,
|
||||
children: (
|
||||
<div style={tableWrapper}>
|
||||
<div style={{ padding: 16, paddingBottom: 0 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setInvModal(true)}>生成邀请码</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={invColumns}
|
||||
dataSource={invitations}
|
||||
rowKey="id"
|
||||
loading={invLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 900 }}
|
||||
locale={{ emptyText: <Empty description="暂无邀请码" /> }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requests',
|
||||
label: <Space><HistoryOutlined />加入申请{requests.length > 0 && reqStatusFilter === 'pending' && <Tag color="red">{requests.length}</Tag>}</Space>,
|
||||
children: (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center' }}>
|
||||
<Segmented
|
||||
value={reqStatusFilter}
|
||||
onChange={(v) => {
|
||||
setReqStatusFilter(v as string);
|
||||
loadRequests(v as string);
|
||||
}}
|
||||
options={[
|
||||
{ label: '待处理', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '全部', value: '' },
|
||||
]}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e2e8f0',
|
||||
padding: 3,
|
||||
background: '#f8fafc',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
'--ant-segmented-item-selected-bg': '#6366f1',
|
||||
'--ant-segmented-item-selected-color': '#ffffff',
|
||||
} as React.CSSProperties}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
<div style={tableWrapper}>
|
||||
<Table
|
||||
columns={reqColumns}
|
||||
dataSource={requests}
|
||||
rowKey="id"
|
||||
loading={reqLoading}
|
||||
pagination={false}
|
||||
bordered={false}
|
||||
scroll={{ x: 1000 }}
|
||||
locale={{ emptyText: <Empty description={reqStatusFilter === 'pending' ? '暂无待审批申请' : '暂无记录'} /> }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
const flowColumns = [
|
||||
{ title: '成员', dataIndex: 'username', width: 120 }, { title: '类型', dataIndex: 'recordTypeLabel', width: 110, render: (v: string) => <Tag>{v || '其他'}</Tag> },
|
||||
{ title: '团队积分变动', dataIndex: 'teamAmount', width: 130, align: 'right' as const, render: (v: number) => <Typography.Text strong style={{ color: Number(v) < 0 ? '#ef4444' : '#10b981' }}>{Number(v) > 0 ? '+' : ''}{Number(v || 0)}</Typography.Text> },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true }, { title: '订阅实例', dataIndex: 'subscriptionNo', width: 190, render: (v: string) => <Typography.Text copyable={v && v !== '历史订阅' ? { text: v } : false}>{v || '历史订阅'}</Typography.Text> },
|
||||
{ title: '周期ID', dataIndex: 'subscriptionPeriodId', width: 180, ellipsis: true }, { title: '席位ID', dataIndex: 'seatId', width: 180, ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 180, render: (v: string) => fmt(v) },
|
||||
];
|
||||
|
||||
/* ── 渲染 ────────────────────────────────────────────── */
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 顶部团队信息 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{teamLoading ? (
|
||||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||
) : team ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title>
|
||||
<Typography.Text type="secondary">代码: {team.code || '-'} 成员: {team.memberCount} 人</Typography.Text>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadTeam(); loadMembers(); loadInvitations(); loadRequests(reqStatusFilter); loadCreditRecords(); }}>刷新</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无法获取团队信息</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
const currentOnlyTabs = currentManager ? [
|
||||
{ key: 'subscriptions', label: '订阅与席位', children: subscriptionView },
|
||||
{ key: 'members', label: '团队成员', children: <><Table rowKey="id" columns={memberColumns} dataSource={members} pagination={false} scroll={{ x: 850 }} /><Pagination style={{ marginTop: 12, textAlign: 'right' }} current={memberPage} pageSize={20} total={memberTotal} onChange={setMemberPage} /></> },
|
||||
{ key: 'usage', label: '成员月消耗', children: usage.length ? <Table
|
||||
rowKey={(r) => `${r.userId}-${r.subscriptionPeriodId}`}
|
||||
pagination={false}
|
||||
dataSource={usage}
|
||||
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: TeamMemberUsage) => <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: TeamMemberUsage) => v || (r.periodSequence > 0 ? `第${r.periodSequence}个月` : '历史周期') },
|
||||
{
|
||||
title: '周期时间',
|
||||
width: 320,
|
||||
render: (_: any, r: TeamMemberUsage) => `${fmt(r.periodStartAt)} ~ ${fmt(r.periodExpiresAt)}`,
|
||||
},
|
||||
{ title: '本期净消耗', dataIndex: 'consumedCredits', width: 130, align: 'right' as const, render: (v: number) => Number(v || 0).toLocaleString() },
|
||||
]}
|
||||
/> : <Empty description="暂无团队积分消耗" /> },
|
||||
{ key: 'invite', label: '邀请与审批', children: <Space direction="vertical" style={{ width: '100%' }} size={18}><Button type="primary" icon={<PlusOutlined />} disabled={readOnly} onClick={() => setInviteModal(true)}>创建邀请码</Button><Table rowKey="id" size="small" pagination={false} columns={invitationColumns} dataSource={invitations} /><Typography.Title level={5}>加入申请</Typography.Title><Table rowKey="id" size="small" pagination={false} columns={requestColumns} dataSource={requests} /></Space> },
|
||||
{ key: 'history', label: '队长任期', children: history.length ? <Table rowKey="id" pagination={false} dataSource={history} columns={[{ title: '队长', dataIndex: 'managerName' }, { title: '任期开始', dataIndex: 'startedAt', render: (v) => fmt(v) }, { title: '任期结束', dataIndex: 'endedAt', render: (v) => v ? fmt(v) : <Tag color="green">当前任期</Tag> }]} /> : <Empty description="暂无任期记录" /> },
|
||||
] : [];
|
||||
|
||||
{/* 标签页 */}
|
||||
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
|
||||
const flowTab = { key: 'flow', label: '团队积分流水', children: <>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select style={{ width: 220 }} value={selectedFlowTeamId} onChange={(v) => { setSelectedFlowTeamId(v); setFlowPage(1); }} options={accessTeams.map((t) => ({ value: t.id, label: `${t.name}${t.isCurrentManager ? '(当前队长)' : '(历史任期)'}` }))} />
|
||||
<Input allowClear placeholder="成员手机号" style={{ width: 150 }} value={flowPhone} onChange={(e) => setFlowPhone(e.target.value)} />
|
||||
<Select allowClear placeholder="流水类型" style={{ width: 130 }} value={flowType} onChange={(v) => { setFlowType(v); setFlowPage(1); }} options={[{ value: 'recharge', label: '发放/充值' }, { value: 'consume', label: '消费' }, { value: 'refund', label: '业务退款' }, { value: 'expire', label: '积分过期' }, { value: 'revoke', label: '积分撤销' }]} />
|
||||
{currentManager && <Select allowClear placeholder="团队订阅" style={{ width: 220 }} value={flowSubscriptionId} onChange={(v) => { setFlowSubscriptionId(v); setFlowPage(1); }} options={subscriptions.map((s) => ({ value: s.subscription.id, label: `${s.subscription.subscriptionNo || '未编号'} · ${s.subscription.productNameSnapshot || '团队订阅套餐'}` }))} />}
|
||||
<RangePicker value={flowDates} onChange={(v) => { setFlowDates(v || [null, null]); setFlowPage(1); }} />
|
||||
<Button icon={<ReloadOutlined />} onClick={loadFlow}>刷新</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={() => { const url = getTeamCreditExportUrl({ teamId: selectedFlowTeamId, phone: flowPhone || undefined, subscriptionId: flowSubscriptionId, recordType: flowType, startDate: flowDates[0]?.format?.('YYYY-MM-DD'), endDate: flowDates[1]?.format?.('YYYY-MM-DD') }); window.open(url, '_blank'); }}>导出</Button>
|
||||
</Space>
|
||||
<Table rowKey="id" columns={flowColumns} dataSource={flow.items || []} pagination={false} scroll={{ x: 1200 }} />
|
||||
<Pagination style={{ marginTop: 12, textAlign: 'right' }} current={flowPage} pageSize={20} total={Number(flow.total || 0)} onChange={setFlowPage} />
|
||||
</> };
|
||||
|
||||
if (!team && accessTeams.length === 0 && !loading) return <Empty description="您当前不是团队队长,也没有可查看的历史队长任期" />;
|
||||
|
||||
{/* 生成邀请码弹窗 */}
|
||||
<Modal
|
||||
title={<Space><CopyOutlined />生成邀请码</Space>}
|
||||
open={invModal}
|
||||
confirmLoading={invSaving}
|
||||
onOk={handleCreateInvitation}
|
||||
onCancel={() => { setInvModal(false); invForm.resetFields(); }}
|
||||
okText="生成"
|
||||
width={480}
|
||||
>
|
||||
<Form form={invForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="maxUses" label="最大使用次数">
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="留空表示不限" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: '#eef2ff',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#4f46e5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<ClockCircleOutlined />
|
||||
<span>邀请码自生成起 <strong>24 小时</strong> 内有效,过期自动失效。</span>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
return <div>
|
||||
{team && <Card loading={loading} style={{ marginBottom: 16 }}><Row align="middle" justify="space-between"><Col><Space><TeamOutlined /><Typography.Title level={4} style={{ margin: 0 }}>{team.name}</Typography.Title><Tag color={team.status === 'active' ? 'green' : 'red'}>{team.statusLabel || (team.status === 'active' ? '启用' : '禁用')}</Tag></Space><div style={{ color: '#64748b', marginTop: 8 }}>成员 {team.memberCount} 人 · 当前队长 {team.managerName || '-'}</div></Col><Col>{readOnly && <Tag icon={<LockOutlined />} color="red">团队已禁用,仅允许只读查看</Tag>}</Col></Row></Card>}
|
||||
{readOnly && <Alert type="warning" showIcon message="团队业务已冻结" description="团队订阅仍会正常发放积分、自然滚期和过期;当前团队积分显示为冻结且不会参与结算。团队重新启用后,仅恢复届时仍有效的团队积分。" style={{ marginBottom: 16 }} />}
|
||||
{!currentManager && <Alert type="info" showIcon message="历史队长只读权限" description="您可以查看自己担任队长任期内的整个团队积分流水,但没有当前团队管理权限。" style={{ marginBottom: 16 }} />}
|
||||
<Tabs items={[...currentOnlyTabs, flowTab]} />
|
||||
|
||||
<Modal open={seatModal.open} title={seatModal.seat ? '修改席位月额度' : '分配团队订阅席位'} onCancel={() => setSeatModal({ open: false })} onOk={saveSeat} okText="确认保存" cancelText="取消">
|
||||
<Form form={seatForm} layout="vertical">
|
||||
{!seatModal.seat && <Form.Item name="userId" label="团队成员" rules={[{ required: true, message: '请选择团队成员' }]}><Select showSearch optionFilterProp="label" options={members.map((m) => ({ value: m.id, label: `${m.username}${m.id === team?.managerId ? '(队长)' : ''}` }))} /></Form.Item>}
|
||||
<Form.Item name="monthlyAllocatedCredits" label="每周期可消费团队积分" rules={[{ required: true, message: '请输入席位额度' }]} extra="额度必须大于0;修改低于已用额度时,本期剩余额度按0处理,不追回已完成消费。"><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal open={inviteModal} title="创建团队邀请码" onCancel={() => setInviteModal(false)} onOk={async () => { try { const v = await inviteForm.validateFields(); const inv = await createTeamInvitation(v.maxUses, v.expiresAt?.toISOString()); setInviteModal(false); inviteForm.resetFields(); await loadBase(); Modal.success({ title: '邀请码已创建', content: <Typography.Text copyable>{inv.code}</Typography.Text> }); } catch (e: any) { if (!e?.errorFields) message.error(e?.message || '创建邀请码失败'); } }} okText="创建" cancelText="取消">
|
||||
<Form form={inviteForm} layout="vertical"><Form.Item name="maxUses" label="最大使用次数"><InputNumber min={1} precision={0} style={{ width: '100%' }} placeholder="留空表示不限制" /></Form.Item><Form.Item name="expiresAt" label="过期时间"><DatePicker showTime style={{ width: '100%' }} disabledDate={(d) => d && d.isBefore(dayjs().startOf('day'))} /></Form.Item></Form>
|
||||
</Modal>
|
||||
</div>;
|
||||
};
|
||||
|
||||
export default TeamManagementPage;
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface TeamMember {
|
||||
username: string;
|
||||
phone?: string | null;
|
||||
credits: number;
|
||||
personalCredits?: number;
|
||||
teamAvailableCredits?: number;
|
||||
teamFrozenCredits?: number;
|
||||
isActive: boolean;
|
||||
joinedAt: string;
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export interface TeamInvitation {
|
||||
maxUses?: number | null;
|
||||
useCount: number;
|
||||
expiresAt?: string | null;
|
||||
inviteLink: string;
|
||||
inviteLink?: string;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
@@ -67,15 +70,27 @@ export interface ManagedTeam {
|
||||
name: string;
|
||||
code?: string | null;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
status: 'active' | 'disabled' | string;
|
||||
statusLabel?: string;
|
||||
isReadOnly?: boolean;
|
||||
teamCreditFrozen?: boolean;
|
||||
memberCount: number;
|
||||
managerId?: string | null;
|
||||
managerName?: string | null;
|
||||
firstSubscriptionPaidAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ManagerAccessTeam {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
isCurrentManager: boolean;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface JoinTeamInfo {
|
||||
teamName: string;
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
valid: boolean;
|
||||
alreadyInTeam: boolean;
|
||||
hasPendingRequest: boolean;
|
||||
@@ -84,12 +99,16 @@ export interface JoinTeamInfo {
|
||||
export interface CreditRecord {
|
||||
id: string;
|
||||
type: 'consume' | 'recharge' | 'refund' | 'expire' | 'revoke' | 'team_internal';
|
||||
typeLabel?: string;
|
||||
amount: number;
|
||||
personalAmount?: number;
|
||||
teamAmount?: number;
|
||||
balanceDelta?: number;
|
||||
expiredAmount?: number;
|
||||
balanceAfter?: number;
|
||||
description: string;
|
||||
billingScene?: string | null;
|
||||
billingSceneLabel?: string | null;
|
||||
sceneNameSnapshot?: string | null;
|
||||
inputTokens?: number | null;
|
||||
outputTokens?: number | null;
|
||||
@@ -527,22 +546,28 @@ export interface UploadResourceHistoryDayItems {
|
||||
// ── Dynamic credits and products ──────────────────────────
|
||||
export interface CreditBalanceSummary {
|
||||
credits: number;
|
||||
available_credits: number;
|
||||
next_expiring_credits: number;
|
||||
next_expires_at?: string | null;
|
||||
next_last_usable_at?: string | null;
|
||||
availableCredits: number;
|
||||
personalCredits: number;
|
||||
teamAvailableCredits: number;
|
||||
teamFrozenCredits: number;
|
||||
nextExpiringCredits: number;
|
||||
nextExpiresAt?: string | null;
|
||||
nextLastUsableAt?: string | null;
|
||||
}
|
||||
|
||||
export interface CreditProduct {
|
||||
id: string;
|
||||
productCode: string;
|
||||
productType: 'subscription' | 'credit_addon';
|
||||
productType: 'subscription' | 'team_subscription' | 'credit_addon';
|
||||
productTypeLabel?: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
features: string[];
|
||||
tierCode?: string | null;
|
||||
tierLabel?: string | null;
|
||||
tierRank?: number | null;
|
||||
billingCycle?: 'monthly' | 'quarterly' | 'yearly' | null;
|
||||
billingCycleLabel?: string | null;
|
||||
monthlyGrantCredits: number;
|
||||
grantCount: number;
|
||||
firstPurchasePrice: number;
|
||||
@@ -555,37 +580,105 @@ export interface CreditProduct {
|
||||
validityMonths?: number | null;
|
||||
price: number;
|
||||
currentPrice: number;
|
||||
targetPrice?: number | null;
|
||||
deductionAmount?: number;
|
||||
priceType?: string | null;
|
||||
priceTypeLabel?: string | null;
|
||||
creditLevel: string;
|
||||
creditLevelLabel?: string;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
isDeleted?: boolean;
|
||||
deletedAt?: string | null;
|
||||
statusLabel?: string;
|
||||
sortOrder: number;
|
||||
canPurchase?: boolean | null;
|
||||
canUpgrade: boolean;
|
||||
unavailableReason?: string | null;
|
||||
}
|
||||
|
||||
export interface CurrentCreditSubscription {
|
||||
export interface ActiveCreditSubscription {
|
||||
id: string;
|
||||
productId: string;
|
||||
subscriptionNo: string;
|
||||
productId?: string | null;
|
||||
productName: string;
|
||||
productType: 'subscription' | 'team_subscription';
|
||||
status: string;
|
||||
purchaseScene: string;
|
||||
statusLabel?: string;
|
||||
billingCycle: 'monthly' | 'quarterly' | 'yearly';
|
||||
billingCycleLabel?: string;
|
||||
tierCode: string;
|
||||
tierRank: number;
|
||||
billingCycle: 'monthly' | 'quarterly' | 'yearly';
|
||||
anchorAt: string;
|
||||
startAt: string;
|
||||
expiresAt: string;
|
||||
monthlyGrantCredits: number;
|
||||
grantCount: number;
|
||||
grantedCount: number;
|
||||
paidAmount: number;
|
||||
}
|
||||
|
||||
export interface CreditProductCatalog {
|
||||
subscriptionProducts: CreditProduct[];
|
||||
teamSubscriptionProducts: CreditProduct[];
|
||||
creditAddons: CreditProduct[];
|
||||
firstPurchaseAvailable: boolean;
|
||||
currentSubscription?: CurrentCreditSubscription | null;
|
||||
personalFirstPurchaseAvailable: boolean;
|
||||
teamFirstPurchaseAvailable: boolean;
|
||||
activePersonalSubscriptions: ActiveCreditSubscription[];
|
||||
personalEntitlement?: any | null;
|
||||
teamEntitlement?: any | null;
|
||||
teamPurchaseAvailable: boolean;
|
||||
teamPurchaseUnavailableReason?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamSeat {
|
||||
id: string;
|
||||
teamId: string;
|
||||
subscriptionId: string;
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
monthlyAllocatedCredits: number;
|
||||
currentPeriodId?: string | null;
|
||||
currentPeriodUsedCredits: number;
|
||||
currentPeriodRemainingCredits: number;
|
||||
status: string;
|
||||
statusLabel: string;
|
||||
createdAt: string;
|
||||
cancelledAt?: string | null;
|
||||
}
|
||||
|
||||
export interface TeamSubscriptionManage {
|
||||
subscription: any;
|
||||
currentPeriodId?: string | null;
|
||||
currentPeriodStartAt?: string | null;
|
||||
currentPeriodExpiresAt?: string | null;
|
||||
periodTotalCredits: number;
|
||||
periodUnspentCredits: number;
|
||||
periodUnallocatedCredits: number;
|
||||
seatLimit: number;
|
||||
activeSeatCount: number;
|
||||
seats: TeamSeat[];
|
||||
}
|
||||
|
||||
export interface TeamMemberUsage {
|
||||
userId: string;
|
||||
username?: string | null;
|
||||
// ID 仅用于内部关联/rowKey,不直接展示给用户。
|
||||
subscriptionId: string;
|
||||
subscriptionNo: string;
|
||||
subscriptionPeriodId: string;
|
||||
subscriptionName: string;
|
||||
tierCode: string;
|
||||
tierLabel: string;
|
||||
tierRank: number;
|
||||
billingCycle: string;
|
||||
billingCycleLabel: string;
|
||||
periodSequence: number;
|
||||
periodLabel: string;
|
||||
periodStartAt?: string | null;
|
||||
periodExpiresAt?: string | null;
|
||||
consumedCredits: number;
|
||||
}
|
||||
|
||||
export interface TeamManagerHistory {
|
||||
id: string;
|
||||
teamId: string;
|
||||
managerUserId: string;
|
||||
managerName?: string | null;
|
||||
startedAt: string;
|
||||
endedAt?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user