merge main

This commit is contained in:
2026-08-11 10:16:38 +08:00
156 changed files with 22362 additions and 1211 deletions
+1
View File
@@ -2,6 +2,7 @@
# VITE_API_BASE=http://192.168.120.17:8000
#VITE_API_BASE=https://apiforeign.minzhongzc.com
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
#VITE_API_BASE=http://localhost:8000
VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 459 KiB

+36 -36
View File
@@ -1,36 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
<script>
// 立即从 localStorage 设置 favicon,避免闪烁
(function() {
try {
var cached = localStorage.getItem('siteInfo');
if (cached) {
var info = JSON.parse(cached);
if (info.siteLogo) {
var link = document.getElementById('favicon');
link.href = info.siteLogo;
link.type = 'image/png';
}
if (info.siteName) {
document.title = info.siteName;
}
}
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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-BioNCQJL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IrbjYl3V.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" id="favicon" />
<script>
// 立即从 localStorage 设置 favicon,避免闪烁
(function() {
try {
var cached = localStorage.getItem('siteInfo');
if (cached) {
var info = JSON.parse(cached);
if (info.siteLogo) {
var link = document.getElementById('favicon');
link.href = info.siteLogo;
link.type = 'image/png';
}
if (info.siteName) {
document.title = info.siteName;
}
}
} catch (e) {}
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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>
<link rel="stylesheet" crossorigin href="/assets/index-DtYH0-uL.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+3
View File
@@ -1,5 +1,8 @@
{
"src/pages/CreativePlazaPage.tsx": {
"description": "创意广场"
},
"src/pages/InvoicePage.tsx": {
"description": "申请开票"
}
}
+14 -2
View File
@@ -27,7 +27,6 @@ import MaterialListPage from './pages/MaterialListPage';
import RemoveInfo from './pages/RemoveInfo';
import RemoveRw from './pages/RemoveRw';
import HomePage from './pages/HomePage';
// import RemoveFenbu from './pages/RemoveFenbu';
import ConsumePage from './pages/ConsumePage';
import AuthorizationWaitingPage from './pages/AuthorizationWaitingPage';
import PopularPage from './pages/PopularPage';
@@ -35,6 +34,9 @@ import CreativePlazaPage from './pages/CreativePlazaPage';
import TeamManagementPage from './pages/TeamManagementPage';
import JoinTeamPage from './pages/JoinTeamPage';
import PrivatePortraitAuthorizeResult from './pages/PrivatePortraitAuthorizeResult';
import InvoicePage from './pages/InvoicePage';
import VideovEditing from './pages/VideovEditing';
import VideoProjectList from './pages/VideoProjectList';
import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading, checkAuth } = useAuthStore();
@@ -119,7 +121,6 @@ const App = () => {
<Route path="removelens" element={<RemoveLens />} />
<Route path="removelens/:creatID/removeinfo" element={<RemoveInfo />} />
<Route path="removelens/:creatID/removefenbu" element={<RemoveRw />} />
{/* <Route path="removelens/:creatID/removefenbu" element={<RemoveFenbu />} /> */}
<Route path="generated" element={<GeneratedRecord />} />
<Route path="pretest" element={<PreTest />} />
<Route path="authorization" element={<AuthorizationPage />} />
@@ -131,7 +132,18 @@ const App = () => {
<Route path="authacc" element={<AuthAccountPage />} />
<Route path="creativeplaza" element={<CreativePlazaPage />} />
<Route path="team-management" element={<TeamManagementPage />} />
<Route path="invoice" element={<InvoicePage />} />
<Route path="videoprojects" element={<VideoProjectList />} />
</Route>
{/* 视频剪辑页面:独立全屏路由,不使用 AppLayout */}
<Route
path="/videoediting"
element={
<ProtectedRoute>
<VideovEditing />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
</BrowserRouter>
+143 -6
View File
@@ -320,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 }> {
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string; optimizeHoldCredits?: number; siteBanner?: string; siteBannerVersion?: number }> {
if (USE_MOCK) return { siteName: '智创', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2026 智创 版权所有', operationManual: '', loginBgVideo: '' };
return api.get('/auth/site-info', false);
}
@@ -370,10 +370,42 @@ export async function getAdminStats(): Promise<AdminStats> {
if (USE_MOCK) return mock.mockGetAdminStats();
return api.get('/admin/stats');
}
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
if (USE_MOCK) return mock.mockGetAdminUsers(search);
const q = search ? `?search=${encodeURIComponent(search)}` : '';
return api.get(`/admin/users${q}`);
export async function getAdminUsers(page = 1, pageSize = 1000, search?: string): Promise<{ items: AdminUser[]; total: number }> {
if (USE_MOCK) {
const items = await mock.mockGetAdminUsers(search);
return { items, total: items.length };
}
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
if (search) params.set('search', search);
return api.get(`/admin/users?${params.toString()}`);
}
export async function getAdminNotifications(page = 1, pageSize = 20): Promise<{ items: any[]; total: number }> {
if (USE_MOCK) {
const items = await mock.mockGetAdminNotifications();
return { items, total: items.length };
}
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
return api.get(`/admin/notifications?${params.toString()}`);
}
export async function createAdminNotification(params: { title: string; content: string; type?: string; target_user_id?: string }): Promise<void> {
if (USE_MOCK) return;
await api.post('/admin/notifications', params);
}
export async function deleteAdminNotification(id: string): Promise<void> {
if (USE_MOCK) return;
await api.delete(`/admin/notifications/${id}`);
}
export async function getNotificationReadUsers(notificationId: string): Promise<{ items: any[] }> {
if (USE_MOCK) return { items: [] };
return api.get(`/admin/notifications/${notificationId}/read-users`);
}
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
if (USE_MOCK) return mock.mockAdjustCredits(userId, amount, description);
@@ -432,10 +464,18 @@ export async function createRechargeOrder(planId: string, method: string = 'wech
return api.post('/payments/recharge', { plan: planId, method });
}
export async function getPaymentOrders(page = 1, pageSize = 20): Promise<{ items: any[]; total: number }> {
export async function getPaymentOrders(
page = 1,
pageSize = 20,
options?: { statusFilter?: string; startDate?: string; endDate?: string; invoiceMode?: boolean }
): Promise<{ items: any[]; total: number }> {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', String(pageSize));
if (options?.statusFilter) params.set('status_filter', options.statusFilter);
if (options?.startDate) params.set('start_date', options.startDate);
if (options?.endDate) params.set('end_date', options.endDate);
if (options?.invoiceMode) params.set('invoice_mode', 'true');
return api.get(`/payments/orders?${params.toString()}`);
}
@@ -447,6 +487,103 @@ export async function cancelPaymentOrder(orderNo: string): Promise<void> {
return api.post(`/payments/orders/${orderNo}/cancel`);
}
// ── Invoices ──────────────────────────────────────────────
export async function createInvoice(data: {
headerType: 'personal' | 'company';
headerName: string;
headerTaxNo?: string;
headerRegisterAddress?: string;
headerRegisterPhone?: string;
headerBankName?: string;
headerBankAccount?: string;
email: string;
orderIds: string[];
}): Promise<any> {
return api.post('/invoices', {
header_type: data.headerType,
header_name: data.headerName,
header_tax_no: data.headerTaxNo,
header_register_address: data.headerRegisterAddress,
header_register_phone: data.headerRegisterPhone,
header_bank_name: data.headerBankName,
header_bank_account: data.headerBankAccount,
email: data.email,
order_ids: data.orderIds,
});
}
export async function getInvoices(params?: { page?: number; pageSize?: number }): Promise<{ items: any[]; total: number }> {
const qs = new URLSearchParams();
if (params?.page) qs.set('page', String(params.page));
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
return api.get(`/invoices?${qs.toString()}`);
}
export async function getInvoiceDetail(id: string): Promise<any> {
return api.get(`/invoices/${id}`);
}
// ── Invoice Headers ──────────────────────────────────────
export async function getInvoiceHeaders(): Promise<{ items: any[] }> {
return api.get('/invoice-headers');
}
export async function createInvoiceHeader(data: {
type: 'personal' | 'company';
name: string;
taxNo?: string;
registerAddress?: string;
registerPhone?: string;
bankName?: string;
bankAccount?: string;
email?: string;
isDefault?: boolean;
}): Promise<any> {
return api.post('/invoice-headers', {
type: data.type,
name: data.name,
tax_no: data.taxNo,
register_address: data.registerAddress,
register_phone: data.registerPhone,
bank_name: data.bankName,
bank_account: data.bankAccount,
email: data.email,
is_default: data.isDefault ?? false,
});
}
export async function updateInvoiceHeader(id: string, data: {
name?: string;
taxNo?: string;
registerAddress?: string;
registerPhone?: string;
bankName?: string;
bankAccount?: string;
email?: string;
isDefault?: boolean;
}): Promise<any> {
return api.put(`/invoice-headers/${id}`, {
name: data.name,
tax_no: data.taxNo,
register_address: data.registerAddress,
register_phone: data.registerPhone,
bank_name: data.bankName,
bank_account: data.bankAccount,
email: data.email,
is_default: data.isDefault,
});
}
export async function deleteInvoiceHeader(id: string): Promise<void> {
await api.delete(`/invoice-headers/${id}`);
}
export async function setDefaultInvoiceHeader(id: string): Promise<any> {
return api.put(`/invoice-headers/${id}/set-default`);
}
export async function getCreditRatios(): Promise<any[]> {
return api.get('/credits/ratios');
}
@@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import { CloseOutlined, NotificationOutlined } from '@ant-design/icons';
import { getSiteInfo } from '../../api';
const STORAGE_KEY = 'dismissed_activity_banner_version';
interface ActivityBannerProps {
onVisibilityChange?: (visible: boolean) => void;
}
const ActivityBanner: React.FC<ActivityBannerProps> = ({ onVisibilityChange }) => {
const [bannerContent, setBannerContent] = useState('');
const [bannerVersion, setBannerVersion] = useState(0);
const [dismissed, setDismissed] = useState(true);
useEffect(() => {
getSiteInfo().then(info => {
const content = info.siteBanner || '';
const version = info.siteBannerVersion || 0;
setBannerVersion(version);
if (content) {
// 检查用户关闭的版本号是否与当前一致
const dismissedVersion = Number(localStorage.getItem(STORAGE_KEY) || 0);
const shouldShow = dismissedVersion < version;
setBannerContent(content);
setDismissed(!shouldShow);
onVisibilityChange?.(shouldShow);
} else {
setDismissed(true);
onVisibilityChange?.(false);
}
}).catch(() => {
setDismissed(true);
onVisibilityChange?.(false);
});
}, []);
const handleClose = () => {
localStorage.setItem(STORAGE_KEY, String(bannerVersion));
setDismissed(true);
onVisibilityChange?.(false);
};
if (dismissed || !bannerContent) return null;
return (
<div
style={{
width: '100%',
position: 'relative',
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
borderBottom: '1px solid rgba(139, 92, 246, 0.15)',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 40px 8px 16px',
maxWidth: 1400,
margin: '0 auto',
width: '100%',
boxSizing: 'border-box',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 28,
height: 28,
borderRadius: 8,
background: 'rgba(139, 92, 246, 0.12)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
</div>
<div
style={{
color: '#5b21b6',
fontSize: 14,
fontWeight: 500,
lineHeight: 1.5,
textAlign: 'center',
}}
dangerouslySetInnerHTML={{ __html: bannerContent }}
/>
</div>
<button
onClick={handleClose}
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
width: 26,
height: 26,
borderRadius: '50%',
background: 'rgba(139, 92, 246, 0.1)',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
outline: 'none',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.2)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.1)';
}}
>
<CloseOutlined style={{ color: '#7c3aed', fontSize: 11 }} />
</button>
</div>
</div>
);
};
export default ActivityBanner;
@@ -62,12 +62,14 @@ import {
MenuOutlined,
ArrowLeftOutlined,
RobotOutlined,
ProfileOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
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 './AppLayout.css';
import bg1 from '../../assets/bg1.png';
@@ -392,6 +394,7 @@ const AppLayout: React.FC = () => {
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const currentOrderNoRef = useRef<string | null>(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
const [bannerVisible, setBannerVisible] = useState(false);
// 资源存储容量(从 getUser().resource_capacity 获取)
const [resourceCapacity, setResourceCapacity] = useState<{
@@ -639,6 +642,7 @@ const AppLayout: React.FC = () => {
...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []),
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'invoice', icon: <ProfileOutlined />, label: '申请开票' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
...(operationManualUrl ? [{ key: 'manual' as const, icon: <FileTextOutlined />, label: '操作手册' }] : []),
{ type: 'divider' as const },
@@ -655,6 +659,7 @@ const AppLayout: React.FC = () => {
else if (key === 'teamManagement') { navigate('/team-management'); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
else if (key === 'invoice') { navigate('/invoice'); }
else if (key === 'manual') { window.open(operationManualUrl, '_blank'); }
};
@@ -862,16 +867,21 @@ const AppLayout: React.FC = () => {
return (
<Layout style={{
minHeight: '100vh',
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<ActivityBanner onVisibilityChange={(v) => setBannerVisible(v)} />
<div style={{ display: 'flex', position: 'relative', padding: 4, gap: 12, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div className="desktop-sidebar" style={{
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
width: sidebarW, position: 'sticky', top: 4, alignSelf: 'flex-start', zIndex: 100,
height: '100%',
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease, left 0.25s ease',
transition: 'width 0.25s ease',
overflow: 'hidden',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
@@ -1085,28 +1095,17 @@ const AppLayout: React.FC = () => {
</div>
<div className="desktop-content" style={{
marginLeft: sidebarW + 28,
marginRight: 12,
marginTop: 16,
marginBottom: 16,
flex: 1,
minHeight: 'calc(100vh - 32px)',
background: 'transparent',
padding: 0,
transition: 'margin-left 0.25s ease',
background: '#f1f2f3',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
padding: '24px 32px 32px',
border: '1px solid rgba(0, 0, 0, 0.06)',
overflow: 'auto',
minHeight: 0,
}}>
<div style={{
boxSizing: 'border-box',
height: '100%',
background: '#f1f2f3',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
minHeight: '100%',
padding: '24px 32px 32px',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
{!isMobile && <Outlet />}
</div>
{!isMobile && <Outlet />}
</div>
</div>
<div className="mobile-header">
+1 -1
View File
@@ -87,7 +87,7 @@ body {
.content_box {
margin: -24px -32px -32px;
border-radius: 0 0 16px 16px;
border-radius: 16px;
height: calc(100vh - 34px);
overflow: auto;
scrollbar-width: none;
+1 -1
View File
@@ -1957,7 +1957,7 @@ const GeneratePage: React.FC = () => {
style={{
margin: '-24px -32px -32px',
borderRadius: 20,
height: 'calc(100vh - 34px)',
height: 'calc(100vh - 8px)',
padding:'24px',
background: '#fff',
overflowY: 'auto'
+103 -33
View File
@@ -120,15 +120,64 @@ const GenerateConver: React.FC = () => {
setVideoEngines(engines);
if (engines.length > 0) {
const first = engines[0];
const firstMaxDur = (() => {
const raw = first.maxDuration ?? first.max_duration;
if (raw != null) return Number(raw);
const durs = first.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = first.supportedDurations;
const generatedDurs = Array.from({ length: firstMaxDur - 4 }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineId(first.id);
setVideoAspectRatio(first.supportedRatios?.includes('9:16') ? '9:16' : first.supportedRatios?.[0] || '9:16');
setVideoResolution(first.supportedResolutions?.[0] || '480p');
setVideoDuration(first.supportedDurations?.[0] || 5);
const defaultDur = (rawDurs || []).find((d: number) => d <= firstMaxDur) ?? generatedDurs[0] ?? 5;
setVideoDuration(defaultDur);
setEngineOptions({
ratios: first.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: first.supportedResolutions || ['480p', '720p', '1080p'],
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
});
}
}).catch(() => message.error('视频引擎加载失败'));
calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([]));
}, []);
// 当引擎变化时,同步 engineOptions 的 durations 以匹配 maxDuration
useEffect(() => {
if (!selectedEngine) return;
const engineMaxDur = (() => {
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = selectedEngine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions(prev => {
const prevDurs = prev.durations;
const maxDur = prevDurs.length > 0 ? Math.max(...prevDurs) : 0;
if (maxDur === engineMaxDur) return prev;
return {
ratios: selectedEngine.supportedRatios || prev.ratios,
resolutions: selectedEngine.supportedResolutions || prev.resolutions,
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
};
});
setVideoDuration(prev => {
if (prev <= engineMaxDur) return prev;
return (rawDurs || []).find((d: number) => d <= engineMaxDur) ?? generatedDurs[0] ?? 5;
});
}, [engineId]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
@@ -142,6 +191,14 @@ const GenerateConver: React.FC = () => {
}, []);
const selectedEngine = videoEngines.find((item: any) => item.id === engineId);
const currentMaxDuration = (() => {
if (!selectedEngine) return 15;
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const selectedEngineSupportsImage = !imageUrl || supportsReferenceImage(selectedEngine);
const estimatedCredits = (() => {
const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution);
@@ -301,8 +358,8 @@ const GenerateConver: React.FC = () => {
resolve(false);
return;
}
if (video.duration >= 16) {
message.error('视频时长不能超过15秒');
if (video.duration > currentMaxDuration) {
message.error(`视频时长不能超过${currentMaxDuration}`);
URL.revokeObjectURL(video.src);
resolve(false);
return;
@@ -888,6 +945,7 @@ const GenerateConver: React.FC = () => {
</div>
<div className="replication-form" style={{
overflow: 'auto', width: '30%',
background: 'transparent',
@@ -903,11 +961,15 @@ const GenerateConver: React.FC = () => {
background: 'rgba(258, 250, 252, 0.2)',
padding: 24, overflowY: 'auto'
}}>
{/* 上传视频 */}
<div style={{ marginBottom: 16 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b', marginBottom: 10 }}>
<span style={{ color: '#f94444' }}></span>
<p style={{ fontSize: 12, color: '#64748b', fontWeight: 400, }}>
{currentMaxDuration}
</p>
</p>
{videoUrl ? (
<div style={{ position: 'relative' }}>
@@ -982,7 +1044,7 @@ const GenerateConver: React.FC = () => {
...
</p>
<p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
MP4MOV | 15 | 50M
MP4MOV | {currentMaxDuration} | 50M
</p>
</div>
) : (
@@ -1003,7 +1065,7 @@ const GenerateConver: React.FC = () => {
</p>
<p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
MP4MOV | 15 | 50M
MP4MOV | {currentMaxDuration} | 50M
</p>
</>
)}
@@ -1048,7 +1110,7 @@ const GenerateConver: React.FC = () => {
>
×
</button>
</div>
) : (
<UploadSelector
@@ -1149,8 +1211,8 @@ const GenerateConver: React.FC = () => {
border: '1px solid rgba(99, 102, 241, 0.15)',
background: 'rgba(255,255,255,0.8)',
}}
maxLength={10}
suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{originalProductName.length}/10</span>}
maxLength={50}
suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{originalProductName.length}/50</span>}
/>
</div>
@@ -1171,8 +1233,8 @@ const GenerateConver: React.FC = () => {
border: '1px solid rgba(99, 102, 241, 0.15)',
background: 'rgba(255,255,255,0.8)',
}}
maxLength={10}
suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{ownProductName.length}/10</span>}
maxLength={50}
suffix={<span style={{ color: '#94a3b8', fontSize: 12 }}>{ownProductName.length}/50</span>}
/>
</div>
@@ -1196,16 +1258,16 @@ const GenerateConver: React.FC = () => {
background: 'rgba(255,255,255,0.8)',
}}
rows={3}
maxLength={30}
maxLength={200}
/>
<span style={{ position: 'absolute', right: 10, bottom: 8, color: '#94a3b8', fontSize: 12 }}>
{productSellingPoints.length}/30
{productSellingPoints.length}/200
</span>
</div>
</div>
<div style={{ marginBottom: 12, display: 'grid', gap: 10 }}>
<p style={{ margin: 0, fontSize: 13, fontWeight: 600, color: '#475569' }}>
<div style={{ marginBottom: 12, display: 'grid', gap: 10 }}>
<p style={{ margin: 0, fontSize: 14, fontWeight: 600, color: '#1e293b' }}>
<span style={{ color: '#f94444' }}></span>
</p>
<div style={{ width: '100%', display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
@@ -1246,7 +1308,7 @@ const GenerateConver: React.FC = () => {
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: -10,
left: -19,
width: 300,
backgroundColor: '#fff',
borderRadius: 16,
@@ -1278,10 +1340,20 @@ const GenerateConver: React.FC = () => {
type="button"
onClick={() => {
setEngineId(engine.id);
const engineMaxDur = (() => {
const raw = engine.maxDuration ?? engine.max_duration;
if (raw != null) return Number(raw);
const durs = engine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = engine.supportedDurations;
const generatedDurs = Array.from({ length: engineMaxDur - 4 }, (_, i) => i + 5);
const newDurations = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
durations: newDurations,
});
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
@@ -1289,8 +1361,9 @@ const GenerateConver: React.FC = () => {
if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p');
}
if (!engine.supportedDurations?.includes(videoDuration)) {
setVideoDuration(engine.supportedDurations?.[0] || 5);
if (!newDurations.includes(videoDuration) || videoDuration > engineMaxDur) {
const validDur = newDurations.find(d => d <= engineMaxDur) ?? newDurations[0] ?? 5;
setVideoDuration(validDur);
}
setShowEngineModal(false);
}}
@@ -1322,11 +1395,6 @@ const GenerateConver: React.FC = () => {
}}>
{engine.name}
</span>
<span style={{
fontSize: 11,
color: '#9ca3af',
}}>
</span>
</button>
))}
</div>
@@ -1436,13 +1504,13 @@ const GenerateConver: React.FC = () => {
<span style={{
fontSize: 9,
color: videoAspectRatio === ratio
? '#6366f1'
: '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
? '#6366f1'
: '#6b7280',
fontWeight: videoAspectRatio === ratio ? 600 : 400,
}}>
{ratio}
</span>
</button>
);
})}
</div>
@@ -1477,15 +1545,15 @@ const GenerateConver: React.FC = () => {
height: 6,
borderRadius: 3,
background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`,
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (currentMaxDuration - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)',
}} />
<input
type="range"
min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)}
max={currentMaxDuration}
value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))}
onChange={(e) => setVideoDuration(Math.min(Number(e.target.value), currentMaxDuration))}
style={{
position: 'relative',
width: '100%',
@@ -1567,6 +1635,8 @@ const GenerateConver: React.FC = () => {
</div>
{/* 立即生成按钮 */}
<Tooltip
title={((user?.credits || 0) < estimatedCredits) ? '媒体生成积分不足,请更换参数/充值积分' : 'LLM固定预扣由服务端按场景校验'}
File diff suppressed because it is too large Load Diff
+48 -10
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Typography, Pagination } from 'antd';
import React, { useCallback, useEffect, useState } from 'react';
import { Table, Tag, Empty, Spin, Typography, Pagination, Select, DatePicker, Space } from 'antd';
import { FileTextOutlined, AlipayCircleOutlined, WechatOutlined } from '@ant-design/icons';
import { getPaymentOrders } from '../api';
@@ -9,27 +9,39 @@ const OrderRecordsPage: React.FC = () => {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [statusFilter, setStatusFilter] = useState<string>('');
const [dateRange, setDateRange] = useState<any>([null, null]);
useEffect(() => {
loadData();
}, [page]);
const loadData = async () => {
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await getPaymentOrders(page, pageSize);
const data = await getPaymentOrders(page, pageSize, {
statusFilter: statusFilter || undefined,
startDate: dateRange[0]?.format?.('YYYY-MM-DD'),
endDate: dateRange[1]?.format?.('YYYY-MM-DD'),
});
setOrders(data.items || []);
setTotal(data.total || 0);
} catch {
setOrders([]);
}
setLoading(false);
};
}, [page, statusFilter, dateRange]);
useEffect(() => {
loadData();
}, [loadData]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handleReset = () => {
setStatusFilter('');
setDateRange([null, null]);
setPage(1);
};
const columns = [
{
title: '订单号',
@@ -139,7 +151,33 @@ const OrderRecordsPage: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<FileTextOutlined style={{ color: '#0ea5e9', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<span style={{ color: '#94a3b8', fontSize: 13 }}> {orders.length} </span>
<span style={{ color: '#94a3b8', fontSize: 13 }}> {total} </span>
</div>
{/* 搜索栏 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
<Select
placeholder="支付状态"
allowClear
style={{ width: 130 }}
value={statusFilter || undefined}
onChange={(v) => { setStatusFilter(v || ''); setPage(1); }}
options={[
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'refunded', label: '已退款' },
{ value: 'failed', label: '支付失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { setDateRange(dates); setPage(1); }}
allowClear
placeholder={['开始日期', '结束日期']}
/>
{(statusFilter || dateRange[0] || dateRange[1]) && (
<Typography.Link onClick={handleReset}></Typography.Link>
)}
</div>
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
<Spin spinning={loading}>
+174 -91
View File
@@ -115,18 +115,76 @@ function RemoveInfo() {
setVideoEngines(engines);
if (engines.length > 0) {
const first = engines[0];
const firstMaxDur = (() => {
const raw = first.maxDuration ?? first.max_duration;
if (raw != null) return Number(raw);
const durs = first.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = first.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(firstMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineId(first.id);
setVideoAspectRatio(first.supportedRatios?.includes('9:16') ? '9:16' : first.supportedRatios?.[0] || '9:16');
setVideoResolution(first.supportedResolutions?.[0] || '480p');
setVideoDuration(first.supportedDurations?.[0] || 5);
const defaultDur = (rawDurs || []).find((d: number) => d <= firstMaxDur) ?? generatedDurs[0] ?? 5;
setVideoDuration(defaultDur);
setEngineOptions({
ratios: first.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: first.supportedResolutions || ['480p', '720p', '1080p'],
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
});
}
}).catch(() => message.error('视频引擎加载失败'));
calculateCredits().then((rules: any) => setCreditRules(Array.isArray(rules) ? rules : [])).catch(() => setCreditRules([]));
}, []);
// 当引擎变化时,同步 engineOptions 的 durations 以匹配 maxDuration
useEffect(() => {
if (!selectedEngine) return;
const engineMaxDur = (() => {
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = selectedEngine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions(prev => {
const prevDurs = prev.durations;
const maxDur = prevDurs.length > 0 ? Math.max(...prevDurs) : 0;
if (maxDur === engineMaxDur) return prev;
return {
ratios: selectedEngine.supportedRatios || prev.ratios,
resolutions: selectedEngine.supportedResolutions || prev.resolutions,
durations: mergedDurs.length > 0 ? mergedDurs : generatedDurs,
};
});
setVideoDuration(prev => {
if (prev <= engineMaxDur) return prev;
return (rawDurs || []).find((d: number) => d <= engineMaxDur) ?? generatedDurs[0] ?? 5;
});
}, [engineId]);
const createIdempotencyRef = useRef<{ segmentId: string; key: string } | null>(null);
const selectedEngine = videoEngines.find((item: any) => item.id === engineId);
// 当前选中引擎支持的最大时长
const currentMaxDuration = (() => {
if (!selectedEngine) return 15;
const raw = selectedEngine.maxDuration ?? selectedEngine.max_duration;
if (raw != null) return Number(raw);
const durs = selectedEngine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const selectedEngineSupportsImage = !productImage || supportsReferenceImage(selectedEngine);
const estimatedCredits = useMemo(() => {
const rule = creditRules.find((item: any) => item.modelConfigId === engineId && item.genType === 'video' && item.resolution === videoResolution);
@@ -145,7 +203,17 @@ function RemoveInfo() {
if (!engine) return;
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
setVideoResolution(engine.supportedResolutions?.[0] || '480p');
setVideoDuration(engine.supportedDurations?.[0] || 5);
const engineMaxDur = (() => {
const raw = engine.maxDuration ?? engine.max_duration;
if (raw != null) return Number(raw);
const durs = engine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = engine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const mergedDurs = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setVideoDuration(mergedDurs.find((d: number) => d <= engineMaxDur) ?? mergedDurs[0] ?? 5);
};
const autoSplitButtonText = useMemo(() => {
@@ -744,6 +812,90 @@ function RemoveInfo() {
},
], []);
// 将表格部分用 useMemo 缓存,避免输入字段变化时触发 VideoCell 重新挂载
const tableSection = useMemo(() => (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
background: '#fff',
borderRadius: 16,
overflow: 'hidden',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
}}
>
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, transparent 100%)' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: '#1e293b', margin: 0 }}></h3>
<span style={{ fontSize: 12, color: '#64748b', marginLeft: 12 }}> {tableData.length} </span>
</div>
<Table
columns={columns}
dataSource={tableData}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
bordered={false}
rowKey="id"
scroll={{ y: 300 }}
style={{ flex: 1 }}
components={{
body: {
row: ({ className, style, ...rest }) => (
<tr
{...rest}
className={className}
style={{
...style,
transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
height: 80,
overflow: 'auto',
}}
/>
),
cell: ({ className, style, ...rest }) => (
<td
{...rest}
className={className}
style={{
...style,
padding: '16px 24px',
}}
/>
),
},
header: {
cell: ({ className, style, ...rest }) => (
<th
{...rest}
className={className}
style={{
...style,
background: 'rgba(255, 255, 255, 1)',
color: '#64748b',
fontWeight: 500,
fontSize: 13,
padding: '16px 24px',
borderBottom: 'none',
}}
/>
),
},
}}
/>
</div>
), [tableData, columns, currentPage, pageSize, total]);
return (
<>
<div
@@ -1028,87 +1180,7 @@ function RemoveInfo() {
</Popconfirm>
</div>
<div
style={{
flex: 2,
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.7) 100%)',
backdropFilter: 'blur(20px)',
borderRadius: 20,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.08)',
}}
>
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(99, 102, 241, 0.1)', background: 'linear-gradient(135deg, rgba(99,102,241,0.02) 0%, transparent 100%)' }}>
<h3 style={{ fontSize: 16, fontWeight: 600, color: '#1e293b', margin: 0 }}></h3>
<span style={{ fontSize: 12, color: '#64748b', marginLeft: 12 }}> {tableData.length} </span>
</div>
<Table
columns={columns}
dataSource={tableData}
pagination={{
current: currentPage,
pageSize: pageSize,
total: total,
onChange: (page, size) => {
setCurrentPage(page);
setPageSize(size);
},
showSizeChanger: true,
showTotal: (total) => `${total}`,
}}
bordered={false}
rowKey="id"
scroll={{ y: 300 }}
style={{ flex: 1 }}
components={{
body: {
row: ({ className, style, ...rest }) => (
<tr
{...rest}
className={className}
style={{
...style,
transition: 'all 0.2s ease',
borderBottom: '1px solid rgba(99, 102, 241, 0.05)',
height: 80,
overflow: 'auto',
}}
/>
),
cell: ({ className, style, ...rest }) => (
<td
{...rest}
className={className}
style={{
...style,
padding: '16px 24px',
}}
/>
),
},
header: {
cell: ({ className, style, ...rest }) => (
<th
{...rest}
className={className}
style={{
...style,
background: 'rgba(255, 255, 255, 1)',
color: '#64748b',
fontWeight: 500,
fontSize: 13,
padding: '16px 24px',
borderBottom: 'none',
}}
/>
),
},
}}
/>
</div>
{tableSection}
</div>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
@@ -1211,7 +1283,7 @@ function RemoveInfo() {
onChange={(e) => setProductName(e.target.value)}
placeholder="请输入项目名称"
style={{ height: 44, borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }}
maxLength={10}
maxLength={50}
showCount
/>
</div>
@@ -1225,7 +1297,7 @@ function RemoveInfo() {
onChange={(e) => setProductSellingPoint(e.target.value)}
placeholder="请输入项目描述"
style={{ borderRadius: 8, border: '1px solid rgba(99, 102, 241, 0.2)' }}
maxLength={100}
maxLength={200}
showCount
rows={3}
/>
@@ -1305,10 +1377,20 @@ function RemoveInfo() {
type="button"
onClick={() => {
setEngineId(engine.id);
const engineMaxDur = (() => {
const raw = engine.maxDuration ?? engine.max_duration;
if (raw != null) return Number(raw);
const durs = engine.supportedDurations;
if (Array.isArray(durs) && durs.length > 0) return Math.max(...durs);
return 15;
})();
const rawDurs = engine.supportedDurations;
const generatedDurs = Array.from({ length: Math.max(engineMaxDur - 4, 1) }, (_, i) => i + 5);
const newDurations = Array.from(new Set([...(rawDurs || []), ...generatedDurs].sort((a, b) => a - b)));
setEngineOptions({
ratios: engine.supportedRatios || ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
resolutions: engine.supportedResolutions || ['480p', '720p', '1080p'],
durations: engine.supportedDurations || [5, 8, 10, 12, 15],
durations: newDurations,
});
if (!engine.supportedRatios?.includes(videoAspectRatio)) {
setVideoAspectRatio(engine.supportedRatios?.[0] || '16:9');
@@ -1316,8 +1398,9 @@ function RemoveInfo() {
if (!engine.supportedResolutions?.includes(videoResolution)) {
setVideoResolution(engine.supportedResolutions?.[0] || '720p');
}
if (!engine.supportedDurations?.includes(videoDuration)) {
setVideoDuration(engine.supportedDurations?.[0] || 5);
if (!newDurations.includes(videoDuration) || videoDuration > engineMaxDur) {
const validDur = newDurations.find(d => d <= engineMaxDur) ?? newDurations[0] ?? 5;
setVideoDuration(validDur);
}
setShowEngineModal(false);
}}
@@ -1504,15 +1587,15 @@ function RemoveInfo() {
height: 6,
borderRadius: 3,
background: '#6366f1',
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (Math.max(...engineOptions.durations) - Math.min(...engineOptions.durations))) * 100}%`,
width: `${((videoDuration - Math.min(...engineOptions.durations)) / (currentMaxDuration - Math.min(...engineOptions.durations))) * 100}%`,
transform: 'translateY(-50%)',
}} />
<input
type="range"
min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)}
max={currentMaxDuration}
value={videoDuration}
onChange={(e) => setVideoDuration(Number(e.target.value))}
onChange={(e) => setVideoDuration(Math.min(Number(e.target.value), currentMaxDuration))}
style={{
position: 'relative',
width: '100%',
+27 -3
View File
@@ -499,9 +499,33 @@ const TeamManagementPage: React.FC = () => {
<Button type="primary" icon={<DownloadOutlined />} onClick={handleExportCredits}> Excel</Button>
</div>
{/* 汇总统计 */}
<div style={{ marginBottom: 12, padding: '8px 16px', background: '#f8f9fc', borderRadius: 8, display: 'flex', gap: 24, flexWrap: 'wrap', fontSize: 13 }}>
<span><strong style={{ color: '#ef4444', fontSize: 15 }}>{creditSummary?.totalConsume ?? 0}</strong></span>
{/* 汇总统计:净消耗 = 消费 - 退款,同时展示消费 / 退款 / 充值辅助详情 */}
<div style={{ marginBottom: 12, padding: '12px 18px', background: '#f8f9fc', borderRadius: 10, display: 'flex', gap: 28, flexWrap: 'wrap', fontSize: 13, alignItems: 'center' }}>
<span>
<strong style={{ color: '#ef4444', fontSize: 16, marginLeft: 4 }}>
{creditSummary?.netConsume ?? 0}
</strong>
</span>
<span style={{ color: '#94a3b8' }}>|</span>
<span>
<strong style={{ color: '#f97316', marginLeft: 4 }}>
{creditSummary?.totalConsume ?? 0}
</strong>
</span>
<span>
退
<strong style={{ color: '#10b981', marginLeft: 4 }}>
{creditSummary?.totalRefund ?? 0}
</strong>
</span>
<span>
<strong style={{ color: '#6366f1', marginLeft: 4 }}>
+ {creditSummary?.totalRecharge ?? 0}
</strong>
</span>
</div>
<div style={tableWrapper}>
@@ -0,0 +1,333 @@
import React, { useState } from 'react';
import { Button, Card, Empty, Modal, Input, message, Dropdown } from 'antd';
import {
PlusOutlined,
VideoCameraOutlined,
EllipsisOutlined,
DeleteOutlined,
EditOutlined,
FolderOpenOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
const { TextArea } = Input;
interface VideoProject {
id: string;
name: string;
description: string;
createdAt: string;
updatedAt: string;
}
const VideoProjectList: React.FC = () => {
const navigate = useNavigate();
const [projects, setProjects] = useState<VideoProject[]>([
{
id: 'demo-1',
name: '示例项目',
description: '这是一个视频剪辑示例项目',
createdAt: dayjs().subtract(2, 'day').format('YYYY-MM-DD HH:mm'),
updatedAt: dayjs().subtract(1, 'day').format('YYYY-MM-DD HH:mm'),
},
]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<VideoProject | null>(null);
const [formValues, setFormValues] = useState({ name: '', description: '' });
// 创建项目
const handleCreate = () => {
if (!formValues.name.trim()) {
message.warning('请输入项目名称');
return;
}
const newProject: VideoProject = {
id: `proj-${Date.now()}`,
name: formValues.name.trim(),
description: formValues.description.trim(),
createdAt: dayjs().format('YYYY-MM-DD HH:mm'),
updatedAt: dayjs().format('YYYY-MM-DD HH:mm'),
};
setProjects(prev => [newProject, ...prev]);
setCreateModalOpen(false);
setFormValues({ name: '', description: '' });
message.success('项目创建成功');
// 跳转到剪辑页面
navigate(`/videoediting?id=${newProject.id}`);
};
// 打开重命名弹窗
const handleRenameClick = (project: VideoProject) => {
setEditingProject(project);
setFormValues({ name: project.name, description: project.description });
setRenameModalOpen(true);
};
// 确认重命名
const handleRename = () => {
if (!editingProject) return;
if (!formValues.name.trim()) {
message.warning('请输入项目名称');
return;
}
setProjects(prev => prev.map(p =>
p.id === editingProject.id
? { ...p, name: formValues.name.trim(), description: formValues.description.trim(), updatedAt: dayjs().format('YYYY-MM-DD HH:mm') }
: p
));
setRenameModalOpen(false);
setEditingProject(null);
setFormValues({ name: '', description: '' });
message.success('项目已更新');
};
// 删除项目
const handleDelete = (id: string) => {
setProjects(prev => prev.filter(p => p.id !== id));
message.success('项目已删除');
};
// 打开项目
const handleOpenProject = (project: VideoProject) => {
navigate(`/videoediting?id=${project.id}`);
};
// 右上角操作菜单
const getDropdownItems = (project: VideoProject) => ({
items: [
{
key: 'rename',
icon: <EditOutlined />,
label: '重命名',
onClick: () => handleRenameClick(project),
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: '删除',
danger: true,
onClick: () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除项目「${project.name}」吗?`,
okText: '删除',
cancelText: '取消',
okButtonProps: { danger: true },
onOk: () => handleDelete(project.id),
});
},
},
],
});
return (
<div className="content_box" style={{ background: '#fff',}}>
{/* 顶部标题栏 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div>
<h2 style={{ margin: 0, fontSize: 22, fontWeight: 600, color: '#1e293b' }}>
</h2>
<p style={{ margin: '4px 0 0', fontSize: 13, color: '#64748b' }}>
</p>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
size="large"
onClick={() => {
setFormValues({ name: '', description: '' });
setCreateModalOpen(true);
}}
>
</Button>
</div>
{/* 项目列表 */}
{projects.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<span style={{ color: '#64748b' }}>
</span>
}
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setFormValues({ name: '', description: '' });
setCreateModalOpen(true);
}}
>
</Button>
</Empty>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: 16,
}}>
{projects.map(project => (
<Card
key={project.id}
hoverable
style={{
borderRadius: 12,
overflow: 'hidden',
transition: 'all 0.2s ease',
border: '1px solid #e8ecf2',
}}
bodyStyle={{ padding: 0 }}
>
{/* 缩略图区域 */}
<div
onClick={() => handleOpenProject(project)}
style={{
height: 160,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
position: 'relative',
}}
>
<VideoCameraOutlined style={{ fontSize: 48, color: 'rgba(255,255,255,0.8)' }} />
<div style={{
position: 'absolute',
top: 8,
right: 8,
}}>
<Dropdown
menu={getDropdownItems(project)}
trigger={['click']}
placement="bottomRight"
>
<Button
type="text"
size="small"
icon={<EllipsisOutlined style={{ color: '#fff', fontSize: 18 }} />}
style={{ background: 'rgba(0,0,0,0.3)', borderRadius: 6 }}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
</div>
</div>
{/* 信息区域 */}
<div
onClick={() => handleOpenProject(project)}
style={{ padding: '14px 16px', cursor: 'pointer' }}
>
<div style={{
fontSize: 15,
fontWeight: 600,
color: '#1e293b',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 4,
}}>
{project.name}
</div>
<div style={{
fontSize: 12,
color: '#94a3b8',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: 8,
}}>
{project.description || '暂无描述'}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: 12, color: '#94a3b8' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ClockCircleOutlined />
{project.updatedAt}
</span>
</div>
</div>
</Card>
))}
</div>
)}
{/* 创建项目弹窗 */}
<Modal
title="创建视频剪辑项目"
open={createModalOpen}
onOk={handleCreate}
onCancel={() => setCreateModalOpen(false)}
okText="创建并开始剪辑"
cancelText="取消"
width={480}
>
<div style={{ paddingTop: 8 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<Input
placeholder="请输入项目名称"
value={formValues.name}
onChange={e => setFormValues(prev => ({ ...prev, name: e.target.value }))}
maxLength={30}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<TextArea
placeholder="请输入项目描述"
value={formValues.description}
onChange={e => setFormValues(prev => ({ ...prev, description: e.target.value }))}
rows={3}
maxLength={100}
/>
</div>
</div>
</Modal>
{/* 重命名弹窗 */}
<Modal
title="编辑项目"
open={renameModalOpen}
onOk={handleRename}
onCancel={() => { setRenameModalOpen(false); setEditingProject(null); }}
okText="保存"
cancelText="取消"
width={480}
>
<div style={{ paddingTop: 8 }}>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<Input
placeholder="请输入项目名称"
value={formValues.name}
onChange={e => setFormValues(prev => ({ ...prev, name: e.target.value }))}
maxLength={30}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 13, fontWeight: 500, color: '#475569' }}></label>
<TextArea
placeholder="请输入项目描述"
value={formValues.description}
onChange={e => setFormValues(prev => ({ ...prev, description: e.target.value }))}
rows={3}
maxLength={100}
/>
</div>
</div>
</Modal>
</div>
);
};
export default VideoProjectList;
File diff suppressed because it is too large Load Diff