会员积分改版V1

This commit is contained in:
2026-08-11 09:24:18 +08:00
parent fe24e51b97
commit b9fd07f293
111 changed files with 9355 additions and 3900 deletions
+35 -5
View File
@@ -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()}`);
}
+11 -3
View File
@@ -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> {
+111 -403
View File
@@ -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>
+27 -15
View File
@@ -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>}
/>
) : (
+39 -19
View File
@@ -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>
+2 -7
View File
@@ -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,
+3 -7
View File
@@ -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>
+6 -6
View File
@@ -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>
+5 -13
View File
@@ -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>
+6 -120
View File
@@ -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
+1 -14
View File
@@ -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 });
},
}));
+78 -1
View File
@@ -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;
}
+25
View File
@@ -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())}`;
}