团队积分V1

This commit is contained in:
2026-08-14 15:23:46 +08:00
parent 516106c045
commit fac54b5667
70 changed files with 6670 additions and 4649 deletions
+42 -89
View File
@@ -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 ───────────────────────────────
+89 -264
View File
@@ -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>
+33 -169
View File
@@ -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;
+56 -153
View File
@@ -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>
);
};
+31 -197
View File
@@ -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;
+197 -634
View File
@@ -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;
+113 -20
View File
@@ -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;
}