merge main
This commit is contained in:
@@ -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