This commit is contained in:
2026-08-13 09:38:36 +08:00
124 changed files with 11236 additions and 5549 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -27,7 +27,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
<script type="module" crossorigin src="/assets/index-C_heyhPf.js"></script>
<script type="module" crossorigin src="/assets/index-ruYs83m_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtYH0-uL.css">
</head>
<body>
+1 -3
View File
@@ -1,10 +1,8 @@
{
<<<<<<< HEAD
"src/pages/CreativePlazaPage.tsx": {
"description": "创意广场"
=======
},
"src/pages/InvoicePage.tsx": {
"description": "申请开票"
>>>>>>> sun
}
}
+34 -4
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));
@@ -679,7 +698,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);
@@ -1052,7 +1071,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);
}
@@ -1400,3 +1419,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> {
+114 -403
View File
@@ -66,7 +66,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 ActivityBanner from './ActivityBanner';
import KickedOutModal from '../KickedOutModal';
@@ -322,79 +323,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);
@@ -495,10 +450,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) => {
@@ -637,15 +588,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) {
@@ -831,6 +780,7 @@ const AppLayout: React.FC = () => {
localStorage.removeItem(PENDING_ORDER_KEY);
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
loadCreditCatalog();
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setSelectedPlan(null);
@@ -863,7 +813,7 @@ const AppLayout: React.FC = () => {
});
}, 1000);
countdownTimerRef.current = countdownTimer;
}, [stopPolling]);
}, [loadCreditCatalog, stopPolling]);
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
@@ -1097,22 +1047,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);
}
}
@@ -1121,21 +1071,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);
}
@@ -1441,328 +1391,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>
@@ -1779,14 +1485,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,
@@ -1798,7 +1504,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,
@@ -1809,7 +1515,8 @@ const AppLayout: React.FC = () => {
startPolling(order.orderNo);
} else {
message.success('充值成功!积分已到账');
useAuthStore.getState().refreshUser();
await useAuthStore.getState().refreshUser();
await loadCreditCatalog();
setCreditsModalOpen(false);
setSelectedPlan(null);
}
@@ -1872,9 +1579,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',
@@ -1899,7 +1607,10 @@ 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>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{Number(opt.validityMonths || 1)}
</Typography.Text>
</div>
</div>
</div>
+21 -12
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 (
@@ -64,20 +70,23 @@ const CreditRecordsPage: React.FC = () => {
title: '变动积分',
dataIndex: 'amount',
key: 'amount',
width: 120,
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: (value: number) => {
const delta = Number(value ?? 0);
return (
<Typography.Text strong style={{ fontWeight: 700, color: delta > 0 ? '#10b981' : delta < 0 ? '#ef4444' : '#64748b', fontSize: 15 }}>
{delta > 0 ? '+' : ''}{delta}
</Typography.Text>
);
},
},
{
title: '创建时间',
key: 'createdAt',
width: 180,
render: (_, record: any) => {
const createdAt = record.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 +124,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 +139,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 +153,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="LLM积分由服务端按当前功能场景直接消费"
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="LLM积分由服务端按当前功能场景直接消费"
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[]>(
[]
@@ -1639,7 +1639,7 @@ const GenerateConver: React.FC = () => {
{/* 立即生成按钮 */}
<Tooltip
title={((user?.credits || 0) < (estimatedCredits + optimizeHoldCredits)) ? '积分不足,请更换参数/充值积分' : ''}
title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM积分由服务端按场景配置直接消费'}
placement="top"
>
<Button
@@ -1647,8 +1647,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();
@@ -1668,7 +1668,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);
@@ -1683,21 +1683,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,
@@ -1713,7 +1713,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="LLM积分由服务端按当前功能场景直接消费"
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="LLM积分由服务端按当前功能场景直接消费"
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>
),
},
];
@@ -680,79 +639,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())}`;
}