merge main
This commit is contained in:
@@ -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=
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 272 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
-613
File diff suppressed because one or more lines are too long
+613
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
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 459 KiB |
Vendored
+36
-36
@@ -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>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"src/pages/CreativePlazaPage.tsx": {
|
||||
"description": "创意广场"
|
||||
},
|
||||
"src/pages/InvoicePage.tsx": {
|
||||
"description": "申请开票"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}>
|
||||
支持的文件类型:MP4、MOV | 视频最大时长:15 秒 | 最大大小:50M
|
||||
支持的文件类型:MP4、MOV | 视频最大时长:{currentMaxDuration} 秒 | 最大大小:50M
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1003,7 +1065,7 @@ const GenerateConver: React.FC = () => {
|
||||
点击或拖拽上传视频
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: 11, color: '#94a3b8', marginTop: 4 }}>
|
||||
支持的文件类型:MP4、MOV | 视频最大时长:15 秒 | 最大大小:50M
|
||||
支持的文件类型:MP4、MOV | 视频最大时长:{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
@@ -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}>
|
||||
|
||||
@@ -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%',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user