会员积分改版V1
This commit is contained in:
@@ -280,10 +280,29 @@ 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; records: CreditRecord[]; total: number }> {
|
||||
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 }> {
|
||||
if (USE_MOCK) {
|
||||
const data = await mock.mockGetCredits();
|
||||
return data;
|
||||
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);
|
||||
return {
|
||||
credits: data.credits,
|
||||
availableCredits: data.credits,
|
||||
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));
|
||||
@@ -301,7 +320,7 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
|
||||
return res.token;
|
||||
}
|
||||
// ── Site Info ─────────────────────────────────────────────
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string; optimizeHoldCredits?: number }> {
|
||||
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string }> {
|
||||
if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' };
|
||||
return api.get('/auth/site-info', false);
|
||||
}
|
||||
@@ -542,7 +561,7 @@ export async function getone(projectId: string, stepId: string): Promise<any> {
|
||||
export async function updateImagePrompt(projectId: string, stepId: string, params: any): Promise<any> {
|
||||
return api.put(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/image-prompt`, params);
|
||||
}
|
||||
|
||||
|
||||
// 第二步,生成图片
|
||||
export async function gettwo(projectId: string, stepId: string ,params: any): Promise<any> {
|
||||
return api.post(`/hot-opening-replications/tasks/${projectId}/steps/${stepId}/generate-image`, params);
|
||||
@@ -915,7 +934,7 @@ export async function deleteOAuthAccount(params: DeleteOAuthAccountParams): Prom
|
||||
export async function getAllOAuthAccountList(): Promise<any> {
|
||||
return api.post(`/upload-material/oauth_account_list`);
|
||||
}
|
||||
// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id
|
||||
// 获取用户全部授权账户列表 resources_material_ids pre_test_template_id
|
||||
export async function submitPreTest(params: { resources_material_ids: any[]; pre_test_template_id: string }): Promise<any> {
|
||||
return api.post(`/resources-material/pre-commit`, params);
|
||||
}
|
||||
@@ -1263,3 +1282,14 @@ export function getTeamCreditExportUrl(params: {
|
||||
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 ───────────────────────────────
|
||||
export async function getCreditProductCatalog(): Promise<import('../types').CreditProductCatalog> {
|
||||
return api.get('/credit-products/catalog');
|
||||
}
|
||||
|
||||
export async function getCreditBalances(page = 1, pageSize = 20, status?: string): Promise<any[]> {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (status) params.set('status', status);
|
||||
return api.get(`/credits/balances?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -207,10 +207,14 @@ export async function mockOptimizePrompt(
|
||||
await delay(1500);
|
||||
|
||||
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
|
||||
const cost = Math.round(80 + params.prompt.length * 0.5 + (params.duration || 0) * 2);
|
||||
// Mock 与正式接口保持一致:提示词优化按场景固定预扣,积分不足直接拦截。
|
||||
const cost = 5;
|
||||
|
||||
if (currentUser) {
|
||||
currentUser.credits -= cost;
|
||||
if (currentUser.credits < cost) {
|
||||
throw new Error('积分不足');
|
||||
}
|
||||
currentUser.credits = Math.round((currentUser.credits - cost) * 100) / 100;
|
||||
}
|
||||
|
||||
const optimizedPromptMap: Record<string, string> = {
|
||||
@@ -356,7 +360,11 @@ export async function mockGetAdminUsers(search?: string): Promise<AdminUser[]> {
|
||||
export async function mockAdjustCredits(userId: string, amount: number, _description: string): Promise<void> {
|
||||
await delay(500);
|
||||
const user = MOCK_ADMIN_USERS.find(u => u.id === userId);
|
||||
if (user) user.credits += amount;
|
||||
if (user) {
|
||||
const nextCredits = Math.round((user.credits + amount) * 100) / 100;
|
||||
if (nextCredits < 0) throw new Error('有效积分不足');
|
||||
user.credits = nextCredits;
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockToggleUserStatus(userId: string, isActive: boolean): Promise<void> {
|
||||
|
||||
@@ -65,7 +65,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
|
||||
import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
|
||||
import type { CreditProduct, CreditProductCatalog } from '../../types';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
import './AppLayout.css';
|
||||
import bg1 from '../../assets/bg1.png';
|
||||
@@ -319,79 +320,33 @@ const MEMBERSHIP_GRADIENTS = [
|
||||
{ gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', icon: <BankFilled /> },
|
||||
];
|
||||
|
||||
const MEMBERSHIP_FEATURES = [
|
||||
'每日赠送积分 当日清零',
|
||||
'可充值更多积分',
|
||||
'基础加速通道',
|
||||
'作品去除水印',
|
||||
'可使用网页版和APP上全部AI功能',
|
||||
'高分辨率',
|
||||
'批量创作 更多同时生成任务',
|
||||
];
|
||||
|
||||
const MEMBERSHIP_PLANS = [
|
||||
{
|
||||
tier: '基础会员',
|
||||
desc: '开启创作之旅',
|
||||
monthlyPrice: 79,
|
||||
monthlyCredits: 1000,
|
||||
onlyMonthly: true,
|
||||
features: MEMBERSHIP_FEATURES.map((f) => f.replace('基础加速通道', '基础加速通道')),
|
||||
speedLabel: '基础加速通道',
|
||||
},
|
||||
{
|
||||
tier: '标准会员',
|
||||
desc: '畅享基础创作权益',
|
||||
monthlyPrice: 154,
|
||||
monthlyCredits: 2000,
|
||||
quarterlyPrice: 1029,
|
||||
quarterlyCredits: 5000,
|
||||
yearlyPrice: 7308,
|
||||
yearlyCredits: 10000,
|
||||
features: MEMBERSHIP_FEATURES,
|
||||
speedLabel: '标准加速通道',
|
||||
},
|
||||
{
|
||||
tier: '高级会员',
|
||||
desc: '解锁高级创作权益',
|
||||
monthlyPrice: 221,
|
||||
monthlyCredits: 3000,
|
||||
quarterlyPrice: 1197,
|
||||
quarterlyCredits: 6000,
|
||||
yearlyPrice: 10710,
|
||||
yearlyCredits: 15000,
|
||||
features: MEMBERSHIP_FEATURES,
|
||||
speedLabel: '高级加速通道',
|
||||
},
|
||||
{
|
||||
tier: '超级会员',
|
||||
desc: '释放无限创作生产力',
|
||||
monthlyPrice: 280,
|
||||
monthlyCredits: 4000,
|
||||
quarterlyPrice: 1367,
|
||||
quarterlyCredits: 7000,
|
||||
yearlyPrice: 13440,
|
||||
yearlyCredits: 20000,
|
||||
features: MEMBERSHIP_FEATURES,
|
||||
speedLabel: '高级加速通道',
|
||||
hot: true,
|
||||
},
|
||||
];
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout, refreshUser, setOptimizeHoldCredits } = useAuthStore();
|
||||
const { user, logout, refreshUser } = useAuthStore();
|
||||
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
||||
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
const [usernameForm] = Form.useForm();
|
||||
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
|
||||
const [subscriptionProducts, setSubscriptionProducts] = useState<CreditProduct[]>([]);
|
||||
const [currentSubscription, setCurrentSubscription] = useState<CreditProductCatalog['currentSubscription']>(null);
|
||||
const loadCreditCatalog = useCallback(async () => {
|
||||
try {
|
||||
const data = await getCreditProductCatalog();
|
||||
setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive));
|
||||
setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
|
||||
setCurrentSubscription(data.currentSubscription || null);
|
||||
} catch {
|
||||
setSubscriptionProducts([]);
|
||||
setRechargeOptions([]);
|
||||
setCurrentSubscription(null);
|
||||
}
|
||||
}, []);
|
||||
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
|
||||
const [selectedTierIndex, setSelectedTierIndex] = useState<number | null>(null);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
@@ -483,10 +438,6 @@ const AppLayout: React.FC = () => {
|
||||
setOperationManualUrl(info.operationManual);
|
||||
}
|
||||
|
||||
if (info.optimizeHoldCredits !== undefined) {
|
||||
setOptimizeHoldCredits(info.optimizeHoldCredits);
|
||||
} else {
|
||||
}
|
||||
|
||||
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
|
||||
}).catch((err) => {
|
||||
@@ -625,15 +576,13 @@ const AppLayout: React.FC = () => {
|
||||
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||||
setMenuItems(items);
|
||||
}).catch(() => { });
|
||||
getRechargePackages().then(data => {
|
||||
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
|
||||
}).catch(() => { });
|
||||
loadCreditCatalog();
|
||||
getPaymentMethods().then(data => {
|
||||
setEnabledMethods(data);
|
||||
if (data.alipay) setPaymentMethod('alipay');
|
||||
else if (data.wechat) setPaymentMethod('wechat');
|
||||
}).catch(() => { });
|
||||
}, []);
|
||||
}, [loadCreditCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -817,6 +766,7 @@ const AppLayout: React.FC = () => {
|
||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||
message.success('支付成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
loadCreditCatalog();
|
||||
setQrCodeModalOpen(false);
|
||||
setCurrentPaymentInfo(null);
|
||||
setSelectedPlan(null);
|
||||
@@ -849,7 +799,7 @@ const AppLayout: React.FC = () => {
|
||||
});
|
||||
}, 1000);
|
||||
countdownTimerRef.current = countdownTimer;
|
||||
}, [stopPolling]);
|
||||
}, [loadCreditCatalog, stopPolling]);
|
||||
|
||||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||||
const isActive = item.path === selectedKey;
|
||||
@@ -1077,22 +1027,22 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
<style>{`
|
||||
@keyframes arrowFlash {
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.6;
|
||||
0%, 100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 0.6;
|
||||
filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.4));
|
||||
}
|
||||
50% {
|
||||
transform: translateY(4px) scale(1.1);
|
||||
opacity: 1;
|
||||
50% {
|
||||
transform: translateY(4px) scale(1.1);
|
||||
opacity: 1;
|
||||
filter: drop-shadow(0 0 12px rgba(99, 102, 241, 0.7));
|
||||
}
|
||||
}
|
||||
@keyframes creditGlow {
|
||||
0%, 100% {
|
||||
0%, 100% {
|
||||
text-shadow: 0 0 0 transparent;
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
text-shadow: 0 0 15px rgba(99, 102, 241, 0.6), 0 0 30px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
}
|
||||
@@ -1101,21 +1051,21 @@ const AppLayout: React.FC = () => {
|
||||
100% { background-position: -200% center; }
|
||||
}
|
||||
@keyframes cardBreath {
|
||||
0%, 100% {
|
||||
0%, 100% {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
border-color: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.1), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
border-color: rgba(99, 102, 241, 0.25);
|
||||
}
|
||||
}
|
||||
@keyframes avatarPulse {
|
||||
0%, 100% {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
@@ -1432,328 +1382,84 @@ const AppLayout: React.FC = () => {
|
||||
title={null}
|
||||
placement="bottom"
|
||||
open={rechargeModalOpen}
|
||||
onClose={() => { setRechargeModalOpen(false); setSelectedPlan(null); setSelectedTierIndex(null); }}
|
||||
height="95vh"
|
||||
onClose={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
height="92vh"
|
||||
className="recharge-drawer"
|
||||
styles={{
|
||||
header: { display: 'none' },
|
||||
body: { padding: '20px 24px 0', overflowY: 'auto', position: 'relative' },
|
||||
}}
|
||||
styles={{ header: { display: 'none' }, body: { padding: '24px', overflowY: 'auto' } }}
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 0', borderTop: '1px solid #f0f0f0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space><WalletOutlined style={{ color: '#6366f1' }} /><Typography.Text>当前有效积分</Typography.Text><Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text></Space>
|
||||
<Space>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b' }}>当前积分余额</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); setSelectedTierIndex(null); }} style={{ borderRadius: 10 }}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled={!selectedPlan && selectedTierIndex === null}
|
||||
loading={paying}
|
||||
<Button onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}>取消</Button>
|
||||
<Button type="primary" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
||||
onClick={async () => {
|
||||
if (selectedPlan) {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
};
|
||||
setCurrentPaymentInfo(paymentInfo);
|
||||
setRechargeModalOpen(false);
|
||||
setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
createdAt: order.createdAt || new Date().toISOString(),
|
||||
timeoutSeconds: 180,
|
||||
}));
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
setRechargeModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '创建订单失败,请重试');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
const product = subscriptionProducts.find((item) => item.id === selectedPlan);
|
||||
if (!product) return;
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(product.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
const paymentInfo = { price: Number(order.amount ?? product.currentPrice ?? product.price ?? 0), credits: Number(product.monthlyGrantCredits || 0), qrCode, method: order.paymentMethod };
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
setCurrentPaymentInfo(paymentInfo); setRechargeModalOpen(false); setQrCodeModalOpen(true);
|
||||
currentOrderNoRef.current = order.orderNo;
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({ orderNo: order.orderNo, ...paymentInfo, createdAt: order.createdAt || new Date().toISOString(), timeoutSeconds: 180 }));
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('订阅购买成功,首期积分已到账');
|
||||
await useAuthStore.getState().refreshUser();
|
||||
await loadCreditCatalog();
|
||||
setRechargeModalOpen(false); setSelectedPlan(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600,
|
||||
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
||||
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
|
||||
}}>
|
||||
确认充值
|
||||
</Button>
|
||||
</div>
|
||||
} catch (err: any) { message.error(err?.message || '创建订阅订单失败'); }
|
||||
finally { setPaying(false); }
|
||||
}}>确认购买</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div style={{ position: 'absolute', top: 8, right: 16, zIndex: 10 }}>
|
||||
<CloseOutlined
|
||||
style={{ fontSize: 18, color: '#94a3b8', cursor: 'pointer', padding: 8, borderRadius: '50%', transition: 'color 0.2s' }}
|
||||
onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); setSelectedTierIndex(null); }}
|
||||
onMouseEnter={(e) => { (e.target as HTMLElement).style.color = '#6366f1'; }}
|
||||
onMouseLeave={(e) => { (e.target as HTMLElement).style.color = '#94a3b8'; }}
|
||||
/>
|
||||
}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<CloseOutlined style={{ position: 'absolute', right: 0, top: 0, cursor: 'pointer', color: '#94a3b8' }} onClick={() => setRechargeModalOpen(false)} />
|
||||
<div style={{ textAlign: 'center', marginBottom: 20 }}>
|
||||
<Typography.Title level={3} style={{ marginBottom: 8 }}>订阅套餐</Typography.Title>
|
||||
<Typography.Text type="secondary">订阅积分按自然月逐月发放;有效订阅只能升级同周期更高等级套餐,不能提前续费。</Typography.Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center', marginBottom: 20, marginTop: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0, fontSize: 20, fontWeight: 700, color: '#1a1a2e' }}>
|
||||
选择合适的计划,助力业务提升
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 12 }}>
|
||||
<div style={{
|
||||
display: 'inline-flex', background: '#f5f5fa', borderRadius: 10, padding: 4,
|
||||
gap: 4,
|
||||
}}>
|
||||
{[
|
||||
{ key: 'yearly', label: '连续包年 8折' },
|
||||
{ key: 'quarterly', label: '连续包季 8折' },
|
||||
{ key: 'monthly', label: '连续包月' },
|
||||
].map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
onClick={() => {
|
||||
const period = p.key as 'monthly' | 'quarterly' | 'yearly';
|
||||
setSelectedPeriod(period);
|
||||
if (period !== 'monthly' && selectedTierIndex !== null && MEMBERSHIP_PLANS[selectedTierIndex]?.onlyMonthly) {
|
||||
setSelectedTierIndex(null);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 16px', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500,
|
||||
background: selectedPeriod === p.key ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : 'transparent',
|
||||
color: selectedPeriod === p.key ? '#fff' : '#64748b',
|
||||
transition: 'all 0.2s', whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* <Tag
|
||||
style={{
|
||||
borderRadius: 8, fontSize: 12, padding: '4px 12px', cursor: 'pointer',
|
||||
background: '#f0f0ff', color: '#6366f1', border: '1px solid #e0e0ff',
|
||||
}}
|
||||
>
|
||||
API服务
|
||||
</Tag> */}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', top: 12, right: 48, zIndex: 10 }}>
|
||||
<div
|
||||
onClick={() => setCreditsModalOpen(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer',
|
||||
padding: '6px 14px', borderRadius: 20,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.08))',
|
||||
border: '1px solid rgba(99,102,241,0.2)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))';
|
||||
e.currentTarget.style.borderColor = 'rgba(99,102,241,0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.08), rgba(139,92,246,0.08))';
|
||||
e.currentTarget.style.borderColor = 'rgba(99,102,241,0.2)';
|
||||
}}
|
||||
>
|
||||
<WalletOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: '#6366f1' }}>积分充值</span>
|
||||
{/* <Tag color="purple" style={{ marginLeft: 2, borderRadius: 6, fontSize: 10, margin: 0, padding: '0 6px', lineHeight: '18px' }}>8折</Tag> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
||||
{MEMBERSHIP_PLANS
|
||||
.map((plan, idx) => ({ plan, idx }))
|
||||
.filter(({ plan }) => selectedPeriod === 'monthly' || !plan.onlyMonthly)
|
||||
.map(({ plan, idx }) => {
|
||||
const g = MEMBERSHIP_GRADIENTS[idx % MEMBERSHIP_GRADIENTS.length];
|
||||
const price = selectedPeriod === 'monthly' ? plan.monthlyPrice
|
||||
: selectedPeriod === 'quarterly' ? plan.quarterlyPrice : plan.yearlyPrice;
|
||||
const credits = selectedPeriod === 'monthly' ? plan.monthlyCredits
|
||||
: selectedPeriod === 'quarterly' ? plan.quarterlyCredits : plan.yearlyCredits;
|
||||
const creditsPer10 = Math.round((credits / price) * 10);
|
||||
const isSelected = selectedTierIndex === idx;
|
||||
const isHot = plan.hot;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setSelectedTierIndex(idx)}
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
padding: '20px 16px 16px',
|
||||
background: isHot
|
||||
? 'linear-gradient(180deg, #f0edff 0%, #fafbff 30%)'
|
||||
: '#fafbff',
|
||||
border: isSelected
|
||||
? '2px solid #6366f1'
|
||||
: isHot
|
||||
? '2px solid #8b5cf6'
|
||||
: '1px solid #f0f0f5',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'stretch',
|
||||
}}
|
||||
>
|
||||
{isHot && (
|
||||
<div style={{
|
||||
position: 'absolute', top: -10, right: 12,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', color: '#fff',
|
||||
fontSize: 11, padding: '3px 10px', borderRadius: 8, fontWeight: 600,
|
||||
}}>最热销</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15, color: '#1a1a2e' }}>{plan.tier}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{plan.desc}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, marginBottom: 4 }}>
|
||||
<span style={{
|
||||
fontSize: 28, fontWeight: 800,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{price}</span>
|
||||
<span style={{ fontSize: 12, color: '#94a3b8', marginLeft: 4 }}>/月</span>
|
||||
</div>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', marginBottom: 8 }}>
|
||||
自动续订,可随时取消。
|
||||
</Typography.Text>
|
||||
<div style={{
|
||||
display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11, color: '#6366f1', background: 'rgba(99,102,241,0.08)',
|
||||
padding: '3px 8px', borderRadius: 6, fontWeight: 600,
|
||||
}}>
|
||||
每月 {credits.toLocaleString()} 积分
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11, color: '#94a3b8', background: '#f5f5fa',
|
||||
padding: '3px 8px', borderRadius: 6,
|
||||
}}>
|
||||
¥10 = {creditsPer10} 积分
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
onClick={(e) => { e.stopPropagation(); setSelectedTierIndex(idx); }}
|
||||
style={{
|
||||
borderRadius: 10, marginBottom: 12, fontWeight: 600, height: 36,
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: isHot
|
||||
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
|
||||
: '#1a1a2e',
|
||||
border: 'none', color: '#fff', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
订阅月卡{plan.tier}
|
||||
</Button>
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f5', paddingTop: 10,
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
{plan.features.map((feature: string, fi: number) => (
|
||||
<div key={fi} style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<CheckCircleFilled style={{ fontSize: 12, color: '#6366f1', flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b', lineHeight: 1.5 }}>{feature}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6 }}>
|
||||
<CheckCircleFilled style={{ fontSize: 12, color: '#6366f1', flexShrink: 0, marginTop: 2 }} />
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b', lineHeight: 1.5 }}>
|
||||
解锁抢先购
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||
<div style={{ marginBottom: 16, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
||||
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
style={{ display: 'flex', gap: 12 }}>
|
||||
{enabledMethods.alipay && (
|
||||
<Radio.Button value="alipay" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
||||
}}>
|
||||
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
支付宝
|
||||
</Radio.Button>
|
||||
)}
|
||||
{enabledMethods.wechat && (
|
||||
<Radio.Button value="wechat" style={{
|
||||
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
||||
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
||||
}}>
|
||||
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
||||
微信支付
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
{currentSubscription && (
|
||||
<div style={{ marginBottom: 20, padding: 16, borderRadius: 12, background: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Typography.Text strong>当前订阅:{currentSubscription.tierCode || '订阅套餐'}({currentSubscription.billingCycle === 'monthly' ? '月' : currentSubscription.billingCycle === 'quarterly' ? '季' : '年'})</Typography.Text>
|
||||
<Typography.Text type="secondary">已发放 {currentSubscription.grantedCount}/{currentSubscription.grantCount} 期,每月 {Number(currentSubscription.monthlyGrantCredits || 0).toLocaleString()} 积分</Typography.Text>
|
||||
<Typography.Text type="secondary">订阅到期:{new Date(currentSubscription.expiresAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(99, 102, 241, 0.06)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}>
|
||||
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
|
||||
当前平台仅支持支付宝/微信扫码充值,如需转账支付请
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: '#ff0000ff',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
|
||||
>联系我们</Typography.Text>
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 20 }}>
|
||||
<Radio.Group value={selectedPeriod} onChange={(e) => { setSelectedPeriod(e.target.value); setSelectedPlan(null); }} buttonStyle="solid">
|
||||
<Radio.Button value="monthly">月套餐</Radio.Button><Radio.Button value="quarterly">季套餐</Radio.Button><Radio.Button value="yearly">年套餐</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).length === 0 ? (
|
||||
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>暂无可订阅套餐,请联系管理员配置并上架套餐。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 16 }}>
|
||||
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).map((product, index) => {
|
||||
const selected = selectedPlan === product.id;
|
||||
const gradient = MEMBERSHIP_GRADIENTS[index % MEMBERSHIP_GRADIENTS.length];
|
||||
return <div key={product.id} onClick={() => product.canPurchase !== false && setSelectedPlan(product.id)} style={{ border: selected ? '2px solid #6366f1' : '1px solid #e5e7eb', borderRadius: 16, padding: 20, cursor: product.canPurchase === false ? 'not-allowed' : 'pointer', opacity: product.canPurchase === false ? .6 : 1, background: '#fff' }}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space><div style={{ width: 36, height: 36, borderRadius: 10, background: gradient.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff' }}>{gradient.icon}</div><div><Typography.Text strong style={{ fontSize: 16 }}>{product.name}</Typography.Text><div><Typography.Text type="secondary">{product.description || product.tierCode}</Typography.Text></div></div></Space>
|
||||
<div><span style={{ fontSize: 28, fontWeight: 800, color: '#6366f1' }}>¥{product.currentPrice}</span><Tag style={{ marginLeft: 8 }}>{product.priceType === 'first_purchase' ? '首充价' : product.priceType === 'activity' ? '活动价' : product.canUpgrade ? '升级价' : '原价'}</Tag></div>
|
||||
{product.canUpgrade && Number(product.deductionAmount || 0) > 0 && <Typography.Text type="secondary">目标套餐价 ¥{product.targetPrice},已抵扣未生效月份 ¥{product.deductionAmount}</Typography.Text>}
|
||||
<Tag color="purple">每月发放 {Number(product.monthlyGrantCredits || 0).toLocaleString()} 积分,共 {product.grantCount} 次</Tag>
|
||||
{(product.features || []).map((feature) => <div key={feature}><CheckCircleFilled style={{ color: '#6366f1', marginRight: 6 }} />{feature}</div>)}
|
||||
{product.unavailableReason && <Typography.Text type="danger">{product.unavailableReason}</Typography.Text>}
|
||||
</Space>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 24 }}><Button type="link" icon={<WalletOutlined />} onClick={() => { setSelectedPlan(null); setRechargeModalOpen(false); setCreditsModalOpen(true); }}>单独购买积分增值包</Button></div>
|
||||
{(!enabledMethods.alipay && !enabledMethods.wechat) && <div style={{ marginTop: 16, padding: 12, background: '#fef2f2', color: '#dc2626', borderRadius: 8 }}>暂无可用支付方式</div>}
|
||||
<div style={{ marginTop: 16 }}><Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}>{enabledMethods.alipay && <Radio.Button value="alipay"><AlipayCircleOutlined /> 支付宝</Radio.Button>}{enabledMethods.wechat && <Radio.Button value="wechat"><WechatOutlined /> 微信支付</Radio.Button>}</Radio.Group></div>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -1770,14 +1476,14 @@ const AppLayout: React.FC = () => {
|
||||
onClick={async () => {
|
||||
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
||||
if (!plan) return;
|
||||
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
||||
const totalCredits = Number(plan.grantCredits || 0);
|
||||
try {
|
||||
setPaying(true);
|
||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||
const paymentInfo = {
|
||||
price: plan.price,
|
||||
price: Number(order.amount ?? plan.currentPrice ?? plan.price ?? 0),
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
@@ -1789,7 +1495,7 @@ const AppLayout: React.FC = () => {
|
||||
|
||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||
orderNo: order.orderNo,
|
||||
price: plan.price,
|
||||
price: Number(order.amount ?? plan.currentPrice ?? plan.price ?? 0),
|
||||
credits: totalCredits,
|
||||
qrCode: qrCode,
|
||||
method: order.paymentMethod,
|
||||
@@ -1800,7 +1506,8 @@ const AppLayout: React.FC = () => {
|
||||
startPolling(order.orderNo);
|
||||
} else {
|
||||
message.success('充值成功!积分已到账');
|
||||
useAuthStore.getState().refreshUser();
|
||||
await useAuthStore.getState().refreshUser();
|
||||
await loadCreditCatalog();
|
||||
setCreditsModalOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}
|
||||
@@ -1863,9 +1570,10 @@ const AppLayout: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{rechargeOptions.length === 0 && <div style={{ width: '100%', padding: 32, textAlign: 'center', color: '#94a3b8' }}>暂无可购买的积分增值包</div>}
|
||||
{rechargeOptions.map((opt, idx) => {
|
||||
const g = GRADIENTS[idx % GRADIENTS.length];
|
||||
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
|
||||
const totalCredits = Number(opt.grantCredits || 0);
|
||||
return (
|
||||
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
|
||||
flex: '1 1 45%', minWidth: 180, borderRadius: 16, padding: '18px 14px',
|
||||
@@ -1890,7 +1598,7 @@ const AppLayout: React.FC = () => {
|
||||
<div style={{
|
||||
fontSize: 20, fontWeight: 800, marginTop: 2,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{opt.price}</div>
|
||||
}}>¥{opt.currentPrice || opt.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ const CreditRecordsPage: React.FC = () => {
|
||||
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 [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
|
||||
@@ -20,8 +22,10 @@ const CreditRecordsPage: React.FC = () => {
|
||||
try {
|
||||
const data = await getCredits(page, pageSize);
|
||||
setRecords(data.records || []);
|
||||
setCredits(data.credits || 0);
|
||||
setCredits(data.availableCredits ?? data.credits ?? 0);
|
||||
setTotal(data.total || 0);
|
||||
setTotalGranted(data.totalGranted ?? 0);
|
||||
setTotalConsumed(data.totalConsumed ?? 0);
|
||||
} catch {
|
||||
setRecords([]);
|
||||
}
|
||||
@@ -44,7 +48,9 @@ const CreditRecordsPage: React.FC = () => {
|
||||
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(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 (
|
||||
@@ -61,23 +67,29 @@ const CreditRecordsPage: React.FC = () => {
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '变动积分',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
width: 120,
|
||||
title: '有效积分变动',
|
||||
key: 'balanceDelta',
|
||||
width: 140,
|
||||
align: 'right' as const,
|
||||
render: (amount: number) => (
|
||||
<Typography.Text strong style={{ fontWeight: 700, color: amount > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{amount > 0 ? '+' : ''}{amount}
|
||||
</Typography.Text>
|
||||
),
|
||||
render: (_: unknown, record: any) => {
|
||||
const delta = record.balanceDelta ?? record.balance_delta ?? record.amount ?? 0;
|
||||
const expired = record.expiredAmount ?? record.expired_amount ?? 0;
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontWeight: 700, 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: '创建时间',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (_, record: any) => {
|
||||
const createdAt = record.created_at || record.createdAt || record.create_time || record.createTime;
|
||||
const createdAt = record.createdAt || record.created_at || record.createTime || record.create_time;
|
||||
return createdAt ? new Date(createdAt).toLocaleString('zh-CN') : '-';
|
||||
},
|
||||
},
|
||||
@@ -115,7 +127,7 @@ const CreditRecordsPage: React.FC = () => {
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计获得</span>
|
||||
</div>
|
||||
<div style={{ color: '#10b981', fontSize: 28, fontWeight: 800 }}>
|
||||
{records.filter((r: any) => r.amount > 0).reduce((sum: number, r: any) => sum + r.amount, 0).toLocaleString()}
|
||||
{totalGranted.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
@@ -130,7 +142,7 @@ const CreditRecordsPage: React.FC = () => {
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计消耗</span>
|
||||
</div>
|
||||
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}>
|
||||
{records.filter((r: any) => r.amount < 0).reduce((sum: number, r: any) => sum + Math.abs(r.amount), 0).toLocaleString()}
|
||||
{totalConsumed.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
@@ -144,7 +156,7 @@ const CreditRecordsPage: React.FC = () => {
|
||||
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
|
||||
<Spin spinning={loading}>
|
||||
{records.length === 0 ? (
|
||||
<Empty
|
||||
<Empty
|
||||
description={<span style={{ color: '#94a3b8' }}>暂无积分变动记录</span>}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { getCredits } from '../api';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { formatDate, formatDatePrecise } from '../utils/formatDate';
|
||||
import type { CreditRecord } from '../types';
|
||||
|
||||
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 1200 }) => {
|
||||
@@ -39,6 +39,10 @@ const CreditsPage: React.FC = () => {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const fetch = async () => {
|
||||
@@ -46,6 +50,10 @@ const CreditsPage: React.FC = () => {
|
||||
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);
|
||||
};
|
||||
fetch();
|
||||
@@ -56,23 +64,34 @@ const CreditsPage: React.FC = () => {
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
const totalRecharge = records.filter((r) => r.type === 'recharge').reduce((sum, r) => sum + r.amount, 0);
|
||||
const totalConsume = records.filter((r) => r.type === 'consume').reduce((sum, r) => sum + Math.abs(r.amount), 0);
|
||||
|
||||
const columns = [
|
||||
{ title: '类型', dataIndex: 'type', key: 'type', width: 100,
|
||||
render: (type: string) => (
|
||||
<Tag color={type === 'recharge' ? 'green' : 'orange'} icon={type === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>
|
||||
{type === 'recharge' ? '充值' : '消费'}
|
||||
</Tag>
|
||||
),
|
||||
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: '积分变动', dataIndex: 'amount', key: 'amount', width: 120,
|
||||
render: (amount: number) => (
|
||||
<Typography.Text strong style={{ color: amount > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{amount > 0 ? '+' : ''}{amount}
|
||||
</Typography.Text>
|
||||
),
|
||||
{ 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,
|
||||
@@ -97,7 +116,7 @@ const CreditsPage: React.FC = () => {
|
||||
<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={user?.credits ?? 0} />
|
||||
<AnimatedNumber value={availableCredits} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,9 +129,10 @@ const CreditsPage: React.FC = () => {
|
||||
<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>
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>最近即将过期</span>
|
||||
</Space>
|
||||
<div style={{ color: '#10b981', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalRecharge} /></div>
|
||||
<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}>
|
||||
@@ -125,7 +145,7 @@ const CreditsPage: React.FC = () => {
|
||||
</div>
|
||||
<span style={{ color: '#94a3b8', fontSize: 14 }}>累计消耗</span>
|
||||
</Space>
|
||||
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalConsume} /></div>
|
||||
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalConsumed} /></div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -511,7 +511,7 @@ const GeneratePage: React.FC = () => {
|
||||
retryGeneration,
|
||||
} = useAppStore();
|
||||
const recordItems = records.items;
|
||||
const { user, optimizeHoldCredits } = useAuthStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [optimizing, setOptimizing] = useState(false);
|
||||
const [showOptimized, setShowOptimized] = useState(false);
|
||||
@@ -3200,21 +3200,16 @@ const GeneratePage: React.FC = () => {
|
||||
</span>
|
||||
)}
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < optimizeHoldCredits) ? `积分不足${optimizeHoldCredits},请充值积分` : ''}
|
||||
title="固定预扣积分由服务端按当前功能场景校验"
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < optimizeHoldCredits) {
|
||||
message.warning(`积分不足${optimizeHoldCredits},请充值积分`);
|
||||
return;
|
||||
}
|
||||
handleOptimize();
|
||||
}}
|
||||
loading={optimizing}
|
||||
disabled={(user?.credits || 0) < optimizeHoldCredits}
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
fontWeight: 600,
|
||||
|
||||
@@ -27,7 +27,7 @@ function InitialInfo() {
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||
const { user, optimizeHoldCredits } = useAuthStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
|
||||
|
||||
@@ -1667,16 +1667,12 @@ function InitialInfo() {
|
||||
</Button>
|
||||
{isV2 && (
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < optimizeHoldCredits) ? `积分不足${optimizeHoldCredits},请充值积分` : ''}
|
||||
title="固定预扣积分由服务端按当前功能场景校验"
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < optimizeHoldCredits) {
|
||||
message.warning(`积分不足${optimizeHoldCredits},请充值积分`);
|
||||
return;
|
||||
}
|
||||
// 从接口返回的数据中读取视频配置作为默认值
|
||||
const videoConfig = steps[1]?.input?.payload?.videoConfig || steps[1]?.input?.payload?.video_config;
|
||||
if (videoConfig) {
|
||||
@@ -1698,7 +1694,7 @@ function InitialInfo() {
|
||||
setRetryPromptModalVisible(true);
|
||||
}}
|
||||
style={{ flex: '0 0 auto', minWidth: 160, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500 }}
|
||||
disabled={step.status === 'processing' || steps[2]?.status === 'processing' || ((user?.credits || 0) < optimizeHoldCredits)}
|
||||
disabled={step.status === 'processing' || steps[2]?.status === 'processing'}
|
||||
>
|
||||
重新生成视频提词
|
||||
</Button>
|
||||
|
||||
@@ -52,7 +52,7 @@ const buildAssetUrl = (url?: string): string => {
|
||||
|
||||
const GenerateConver: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, optimizeHoldCredits } = useAuthStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [tableData, setTableData] = useState<any[]>(
|
||||
[]
|
||||
@@ -1569,7 +1569,7 @@ const GenerateConver: React.FC = () => {
|
||||
|
||||
{/* 立即生成按钮 */}
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) ? '积分不足,请更换参数/充值积分' : ''}
|
||||
title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM固定预扣由服务端按场景校验'}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
@@ -1577,8 +1577,8 @@ const GenerateConver: React.FC = () => {
|
||||
block
|
||||
size="large"
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) {
|
||||
message.warning('积分不足,请更换参数/充值积分');
|
||||
if ((user?.credits || 0) < estimatedCredits) {
|
||||
message.warning('媒体生成积分不足,请更换参数/充值积分');
|
||||
return;
|
||||
}
|
||||
handleGenerate();
|
||||
@@ -1598,7 +1598,7 @@ const GenerateConver: React.FC = () => {
|
||||
立即生成
|
||||
|
||||
<span style={{ color: '#ffffffff', marginLeft: 8, fontSize: 13 }}>
|
||||
预估积分:{(estimatedCredits + optimizeHoldCredits)}
|
||||
预估媒体积分:{estimatedCredits}
|
||||
</span>
|
||||
|
||||
</Button>
|
||||
|
||||
@@ -66,7 +66,7 @@ const VideoCell = React.memo(({ record, onPreview }: VideoCellProps) => {
|
||||
function RemoveInfo() {
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, optimizeHoldCredits } = useAuthStore();
|
||||
const { user } = useAuthStore();
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [trimModalVisible, setTrimModalVisible] = useState(false);
|
||||
const [currentSegment, setCurrentSegment] = useState<string | null>(null);
|
||||
@@ -1600,21 +1600,21 @@ function RemoveInfo() {
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 8 }}>
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) ? '积分不足,请更换参数/充值积分' : ''}
|
||||
title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM固定预扣由服务端按场景校验'}
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) {
|
||||
message.warning('积分不足,请更换参数/充值积分');
|
||||
if ((user?.credits || 0) < estimatedCredits) {
|
||||
message.warning('媒体生成积分不足,请更换参数/充值积分');
|
||||
return;
|
||||
}
|
||||
handleManualGenerate();
|
||||
}}
|
||||
loading={loading}
|
||||
// disabled={loading || !engineId || !selectedEngineSupportsImage || ((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits))}
|
||||
// disabled={loading || !engineId || !selectedEngineSupportsImage || ((user?.credits || 0) < estimatedCredits)}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 44,
|
||||
@@ -1630,7 +1630,7 @@ function RemoveInfo() {
|
||||
{loading ? '生成中...' : '手动生成'}
|
||||
|
||||
<span style={{ color: '#ffffffff', marginLeft: 8, fontSize: 13 }}>
|
||||
预估积分:{(estimatedCredits + optimizeHoldCredits)}
|
||||
预估媒体积分:{estimatedCredits}
|
||||
</span>
|
||||
|
||||
</Button>
|
||||
|
||||
@@ -27,7 +27,7 @@ function InitialInfo() {
|
||||
const { creatID } = useParams<{ creatID: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||
const { user, optimizeHoldCredits } = useAuthStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [creditCalculationData, setCreditCalculationData] = useState<any[]>([]);
|
||||
@@ -1011,20 +1011,16 @@ function InitialInfo() {
|
||||
|
||||
{!isV2 && (
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < optimizeHoldCredits) ? `积分不足${optimizeHoldCredits},请充值积分` : ''}
|
||||
title="固定预扣积分由服务端按当前功能场景校验"
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < optimizeHoldCredits) {
|
||||
message.warning(`积分不足${optimizeHoldCredits},请充值积分`);
|
||||
return;
|
||||
}
|
||||
createone(step.id);
|
||||
}}
|
||||
type="primary"
|
||||
style={{ width: '100%', borderRadius: 10, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none', height: 36, fontWeight: 500, boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)' }}
|
||||
disabled={step.status !== 'completed' || ((user?.credits || 0) < optimizeHoldCredits)}
|
||||
disabled={step.status !== 'completed'}
|
||||
>
|
||||
下一步:生成图片提示词
|
||||
</Button>
|
||||
@@ -1574,16 +1570,12 @@ function InitialInfo() {
|
||||
</Button>
|
||||
{isV2 && (
|
||||
<Tooltip
|
||||
title={((user?.credits || 0) < optimizeHoldCredits) ? `积分不足${optimizeHoldCredits},请充值积分` : ''}
|
||||
title="固定预扣积分由服务端按当前功能场景校验"
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
if ((user?.credits || 0) < optimizeHoldCredits) {
|
||||
message.warning(`积分不足${optimizeHoldCredits},请充值积分`);
|
||||
return;
|
||||
}
|
||||
// 从接口返回的数据中读取视频配置作为默认值
|
||||
const videoConfig = steps[1]?.input?.payload?.videoConfig || steps[1]?.input?.payload?.video_config;
|
||||
if (videoConfig) {
|
||||
@@ -1605,7 +1597,7 @@ function InitialInfo() {
|
||||
setRetryPromptModalVisible(true);
|
||||
}}
|
||||
style={{ flex: '0 0 auto', minWidth: 160, borderRadius: 10, borderColor: 'rgba(99, 102, 241, 0.3)', color: '#6366f1', height: 36, fontWeight: 500 }}
|
||||
disabled={step.status === 'processing' || steps[2]?.status === 'processing' || ((user?.credits || 0) < optimizeHoldCredits)}
|
||||
disabled={step.status === 'processing' || steps[2]?.status === 'processing'}
|
||||
>
|
||||
重新生成视频提词
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
const { RangePicker } = DatePicker;
|
||||
import {
|
||||
createTeamInvitation, getJoinTeamInfo, getManagedTeam, getPendingJoinRequests,
|
||||
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest, transferCredits,
|
||||
getTeamCreditExportUrl, getTeamCreditRecords, getTeamInvitations, getTeamMembers, handleJoinRequest, revokeInvitation, submitJoinRequest,
|
||||
} from '../api';
|
||||
import type { ManagedTeam, TeamInvitation, TeamJoinRequest, TeamMember } from '../types';
|
||||
import { useAuthStore } from '../store/useAuthStore';
|
||||
@@ -74,41 +74,6 @@ const TeamManagementPage: React.FC = () => {
|
||||
|
||||
useEffect(() => { loadMembers(); }, [loadMembers]);
|
||||
|
||||
// ── 调整积分弹窗 ──
|
||||
const [creditModal, setCreditModal] = useState<{ open: boolean; member: TeamMember | null }>({ open: false, member: null });
|
||||
const [creditForm] = Form.useForm();
|
||||
const [creditSaving, setCreditSaving] = useState(false);
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!creditModal.member) return;
|
||||
try {
|
||||
const values = await creditForm.validateFields();
|
||||
// 二次校验:确保是正数
|
||||
const amount = Number(values.amount);
|
||||
if (!amount || amount <= 0 || amount > 9999999) {
|
||||
message.error('请输入有效的正数积分数量');
|
||||
return;
|
||||
}
|
||||
setCreditSaving(true);
|
||||
await transferCredits(
|
||||
creditModal.member.id,
|
||||
amount,
|
||||
values.direction || 'increase',
|
||||
values.description,
|
||||
);
|
||||
message.success(values.direction === 'decrease' ? '积分扣减成功' : '积分增加成功');
|
||||
setCreditModal({ open: false, member: null });
|
||||
creditForm.resetFields();
|
||||
loadMembers();
|
||||
loadCreditRecords();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setCreditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Tab 2: 邀请码 ──
|
||||
const [invitations, setInvitations] = useState<TeamInvitation[]>([]);
|
||||
const [invLoading, setInvLoading] = useState(false);
|
||||
@@ -340,17 +305,11 @@ const TeamManagementPage: React.FC = () => {
|
||||
{ title: '加入时间', dataIndex: 'joinedAt', width: 170, render: (v: string) => formatDateTime(v) },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100,
|
||||
render: (_: any, r: TeamMember) => {
|
||||
const currentUserId = useAuthStore.getState().user?.id;
|
||||
const isSelf = r.id === currentUserId;
|
||||
return isSelf ? (
|
||||
<Tooltip title="不能给自己调整积分">
|
||||
<Button size="small" type="link" style={{ padding: 0, color: '#999', cursor: 'not-allowed' }} disabled>调整积分</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size="small" type="link" style={{ padding: 0 }} onClick={() => { setCreditModal({ open: true, member: r }); creditForm.resetFields(); }}>调整积分</Button>
|
||||
);
|
||||
},
|
||||
render: () => (
|
||||
<Tooltip title="当前版本积分暂未开放团队转账功能">
|
||||
<Button size="small" type="link" style={{ padding: 0, color: '#999' }} disabled>积分转账未开放</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -656,79 +615,6 @@ const TeamManagementPage: React.FC = () => {
|
||||
{/* 标签页 */}
|
||||
<Tabs items={tabItems} activeKey={activeTab} onChange={setActiveTab} size="large" />
|
||||
|
||||
{/* 调整积分弹窗 */}
|
||||
<Modal
|
||||
title={<Space><UserOutlined />调整成员积分 - {creditModal.member?.username}</Space>}
|
||||
open={creditModal.open}
|
||||
confirmLoading={creditSaving}
|
||||
onOk={handleTransfer}
|
||||
onCancel={() => setCreditModal({ open: false, member: null })}
|
||||
okText="确认"
|
||||
width={480}
|
||||
>
|
||||
<Form form={creditForm} layout="vertical" style={{ marginTop: 16 }} initialValues={{ direction: 'increase' }}>
|
||||
{/* 显示管理人当前积分 */}
|
||||
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#f0f4ff', borderRadius: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text type="secondary">我的当前积分</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 20, color: '#6366f1' }}>
|
||||
{useAuthStore.getState().user?.credits?.toFixed(2) ?? '0.00'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Form.Item name="direction" label="操作类型" rules={[{ required: true, message: '请选择操作类型' }]}>
|
||||
<Radio.Group buttonStyle="solid" size="large" style={{ width: '100%' }}>
|
||||
<Radio.Button value="increase" style={{ width: '50%', textAlign: 'center' }}>增加成员积分</Radio.Button>
|
||||
<Radio.Button value="decrease" style={{ width: '50%', textAlign: 'center' }}>扣减成员积分</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="积分数量"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入积分数量' },
|
||||
{ type: 'number', min: 0.01, message: '必须大于 0' },
|
||||
{ type: 'number', max: 9999999, message: '单次不能超过 9999999' },
|
||||
]}
|
||||
validateTrigger={['onChange', 'onBlur']}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
step={1}
|
||||
min={0.01}
|
||||
max={9999999}
|
||||
precision={2}
|
||||
placeholder="请输入正数积分数量"
|
||||
size="large"
|
||||
formatter={(value) => {
|
||||
if (!value) return '';
|
||||
let str = `${value}`.replace(/[^0-9.]/g, '');
|
||||
str = str.replace(/^0+(?=\d)/, '');
|
||||
return str;
|
||||
}}
|
||||
parser={(str) => {
|
||||
if (!str || str === '.') return '' as any;
|
||||
let num = parseFloat(str);
|
||||
if (isNaN(num) || num <= 0) return '' as any;
|
||||
return Math.min(num, 9999999) as any;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// 禁止输入负号、e、E
|
||||
if (e.key === '-' || e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onChange={(val) => {
|
||||
if (val === null || val === undefined) {
|
||||
creditForm.validateFields(['amount']);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea rows={2} maxLength={256} placeholder="选填,例如:活动奖励" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 生成邀请码弹窗 */}
|
||||
<Modal
|
||||
|
||||
@@ -5,21 +5,17 @@ import * as api from '../api';
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
optimizeHoldCredits: number;
|
||||
login: (username: string, password: string, captchaToken?: string, rememberMe?: boolean) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
changePassword: (oldPwd: string, newPwd: string) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
setUserCredits: (credits: number) => void;
|
||||
setOptimizeHoldCredits: (credits: number) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: true,
|
||||
optimizeHoldCredits: 0,
|
||||
|
||||
login: async (username, password, captchaToken?, rememberMe?) => {
|
||||
const user = await api.login(username, password, captchaToken, rememberMe);
|
||||
set({ user });
|
||||
@@ -34,14 +30,8 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) { set({ user: null, loading: false }); return; }
|
||||
const [user, siteInfo] = await Promise.all([
|
||||
api.getUser(),
|
||||
api.getSiteInfo()
|
||||
]);
|
||||
const user = await api.getUser();
|
||||
set({ user, loading: false });
|
||||
if (siteInfo.optimizeHoldCredits !== undefined) {
|
||||
set({ optimizeHoldCredits: siteInfo.optimizeHoldCredits });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -69,7 +59,4 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
set((state) => (state.user ? { user: { ...state.user, credits } } : {}));
|
||||
},
|
||||
|
||||
setOptimizeHoldCredits: (credits: number) => {
|
||||
set({ optimizeHoldCredits: credits });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -83,9 +83,20 @@ export interface JoinTeamInfo {
|
||||
|
||||
export interface CreditRecord {
|
||||
id: string;
|
||||
type: 'consume' | 'recharge';
|
||||
type: 'consume' | 'recharge' | 'refund' | 'expire' | 'revoke' | 'team_internal';
|
||||
amount: number;
|
||||
balanceDelta?: number;
|
||||
expiredAmount?: number;
|
||||
balanceAfter?: number;
|
||||
description: string;
|
||||
billingScene?: string | null;
|
||||
sceneNameSnapshot?: string | null;
|
||||
inputTokens?: number | null;
|
||||
outputTokens?: number | null;
|
||||
totalTokens?: number | null;
|
||||
llmCallCount?: number | null;
|
||||
llmSuccessCallCount?: number | null;
|
||||
llmFailedCallCount?: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -512,3 +523,69 @@ export interface UploadResourceHistoryDayItems {
|
||||
pageSize: number;
|
||||
items: UploadResourceHistoryItem[];
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
export interface CreditProduct {
|
||||
id: string;
|
||||
productCode: string;
|
||||
productType: 'subscription' | 'credit_addon';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
features: string[];
|
||||
tierCode?: string | null;
|
||||
tierRank?: number | null;
|
||||
billingCycle?: 'monthly' | 'quarterly' | 'yearly' | null;
|
||||
monthlyGrantCredits: number;
|
||||
grantCount: number;
|
||||
firstPurchasePrice: number;
|
||||
regularPrice: number;
|
||||
activityPrice?: number | null;
|
||||
activityStartAt?: string | null;
|
||||
activityEndAt?: string | null;
|
||||
renewalEnabled: boolean;
|
||||
grantCredits: number;
|
||||
validityMonths?: number | null;
|
||||
price: number;
|
||||
currentPrice: number;
|
||||
targetPrice?: number | null;
|
||||
deductionAmount?: number;
|
||||
priceType?: string | null;
|
||||
creditLevel: string;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
canPurchase?: boolean | null;
|
||||
canUpgrade: boolean;
|
||||
unavailableReason?: string | null;
|
||||
}
|
||||
|
||||
export interface CurrentCreditSubscription {
|
||||
id: string;
|
||||
productId: string;
|
||||
status: string;
|
||||
purchaseScene: string;
|
||||
tierCode: string;
|
||||
tierRank: number;
|
||||
billingCycle: 'monthly' | 'quarterly' | 'yearly';
|
||||
anchorAt: string;
|
||||
startAt: string;
|
||||
expiresAt: string;
|
||||
monthlyGrantCredits: number;
|
||||
grantCount: number;
|
||||
grantedCount: number;
|
||||
}
|
||||
|
||||
export interface CreditProductCatalog {
|
||||
subscriptionProducts: CreditProduct[];
|
||||
creditAddons: CreditProduct[];
|
||||
firstPurchaseAvailable: boolean;
|
||||
currentSubscription?: CurrentCreditSubscription | null;
|
||||
}
|
||||
|
||||
@@ -30,3 +30,28 @@ export function formatDate(iso: string | null | undefined): string {
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
export function formatDatePrecise(iso: string | null | undefined): string {
|
||||
if (!iso) return '-';
|
||||
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
|
||||
const base = formatDateToSeconds(iso);
|
||||
return base === '-' ? base : `${base}.${fraction}`;
|
||||
}
|
||||
|
||||
function formatDateToSeconds(iso: string): string {
|
||||
const s = iso.trim();
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
|
||||
if (!m) return s.replace('T', ' ');
|
||||
const [, year, month, day, hour, min, sec, tz] = m;
|
||||
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
|
||||
let target = utcMs;
|
||||
if (tz === 'Z') target += CST_OFFSET * 60000;
|
||||
else if (tz) {
|
||||
const sign = tz[0] === '+' ? 1 : -1;
|
||||
const compact = tz.slice(1).replace(':', '');
|
||||
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
|
||||
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
|
||||
}
|
||||
const d = new Date(target);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user