This commit is contained in:
2026-07-15 13:03:23 +08:00
27 changed files with 591 additions and 271 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BoVQkhEX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-JhRVnnL-.css">
<script type="module" crossorigin src="/assets/index-nrhXZrQV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css">
</head>
<body>
<div id="root"></div>
+5
View File
@@ -46,6 +46,11 @@ export async function changePassword(oldPwd: string, newPwd: string): Promise<vo
if (USE_MOCK) return;
await api.post('/auth/change-password', { old_password: oldPwd, new_password: newPwd });
}
export async function changeUsername(newUsername: string): Promise<{ message: string; user?: User }> {
return api.post<{ message: string; user?: User }>('/auth/change-username', { new_username: newUsername });
}
// ── Projects ──────────────────────────────────────────────
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects();
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer } from 'antd';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer, Tabs } from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import {
PlayCircleOutlined,
@@ -62,7 +62,7 @@ import {
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword } from '../../api';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
import NotificationPopup from '../NotificationPopup';
import './AppLayout.css';
@@ -309,11 +309,12 @@ const GRADIENTS = [
const AppLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { user, logout, refreshUser } = useAuthStore();
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const [pwdForm] = Form.useForm();
const [usernameForm] = Form.useForm();
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
@@ -603,7 +604,7 @@ const AppLayout: React.FC = () => {
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
...(operationManualUrl ? [{ key: 'manual' as const, icon: <FileTextOutlined />, label: '操作手册' }] : []),
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ key: 'changePwd', icon: <LockOutlined />, label: '个人信息' },
{ type: 'divider' as const },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
];
@@ -635,6 +636,40 @@ const AppLayout: React.FC = () => {
}
};
const handleChangeUsername = async () => {
try {
const values = await usernameForm.validateFields();
const result = await changeUsername(values.username);
message.success(result.message || '操作成功');
await refreshUser();
getUser().then((res: any) => {
const rc = res?.resourceCapacity;
if (rc) {
setResourceCapacity({
enabled: !!rc.enabled,
usedBytes: Number(rc.usedBytes) || 0,
totalBytes: Number(rc.totalBytes) || 0,
availableBytes: Number(rc.availableBytes) || 0,
usagePercent: Number(rc.usagePercent) || 0,
exceeded: !!rc.exceeded,
limitValue: rc.limitValue ?? '',
limitUnit: rc.limitUnit || 'GB',
});
}
}).catch(() => { });
setPwdModalOpen(false);
usernameForm.resetFields();
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message);
} else if (error?.response?.data?.detail) {
message.error(error.response.data.detail);
} else if (error?.message) {
message.error(error.message);
}
}
};
const handleLogout = () => {
logout();
navigate('/login');
@@ -1138,7 +1173,7 @@ const AppLayout: React.FC = () => {
<span className="mobile-menu-icon" style={{ color: '#0ea5e9' }}>
<LockOutlined />
</span>
<span className="mobile-menu-label" style={{ color: '#475569' }}></span>
<span className="mobile-menu-label" style={{ color: '#475569' }}></span>
</div>
<div style={{ height: 8 }} />
@@ -1171,28 +1206,46 @@ const AppLayout: React.FC = () => {
</div>
</Drawer>
<Modal title={<Space><LockOutlined /></Space>} open={pwdModalOpen}
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
okText="确认修改" cancelText="取消" width={440}>
<Form form={pwdForm} layout="vertical" style={{ marginTop: 20 }}>
<Form.Item name="oldPwd" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="newPwd" label="新密码" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码(至少6位)" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="confirmPwd" label="确认新密码" rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }: any) => ({
validator(_: any, value: string) {
if (!value || getFieldValue('newPwd') === value) return Promise.resolve();
return Promise.reject(new Error('两次密码不一致'));
},
}),
]}>
<Input.Password placeholder="请再次输入新密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
</Form>
<Modal title={<Space><SettingOutlined /></Space>} open={pwdModalOpen}
onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); usernameForm.resetFields(); }}
width={440}
footer={null}>
<Tabs defaultActiveKey="profile">
<Tabs.TabPane tab="个人信息" key="profile">
<Form form={usernameForm} layout="vertical" style={{ marginTop: 20 }} onValuesChange={() => {}}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }, { min: 3, message: '用户名至少3位' }]}>
<Input placeholder="请输入用户名" size="large" prefix={<UserOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item>
<Button type="primary" size="large" onClick={handleChangeUsername} style={{ width: '100%' }}></Button>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane tab="修改密码" key="password">
<Form form={pwdForm} layout="vertical" style={{ marginTop: 20 }} onValuesChange={() => {}}>
<Form.Item name="oldPwd" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
<Input.Password placeholder="请输入原密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="newPwd" label="新密码" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password placeholder="请输入新密码(至少6位)" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="confirmPwd" label="确认新密码" rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }: any) => ({
validator(_: any, value: string) {
if (!value || getFieldValue('newPwd') === value) return Promise.resolve();
return Promise.reject(new Error('两次密码不一致'));
},
}),
]}>
<Input.Password placeholder="请再次输入新密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item>
<Button type="primary" size="large" onClick={handleChangePwd} style={{ width: '100%' }}></Button>
</Form.Item>
</Form>
</Tabs.TabPane>
</Tabs>
</Modal>
<Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen}
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Tag, Typography, Button } from 'antd';
import { Tag, Button } from 'antd';
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
import { getNotifications, markNotificationRead } from '../api';
import { useAuthStore } from '../store/useAuthStore';
@@ -13,10 +13,25 @@ interface Notification {
createdAt: string;
}
const typeConfig: Record<string, { gradient: string; icon: React.ReactNode; label: string }> = {
system: { gradient: 'linear-gradient(135deg, #6366f1, #818cf8)', icon: <ThunderboltOutlined />, label: '系统通知' },
credit: { gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)', icon: <StarOutlined />, label: '积分通知' },
promo: { gradient: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', icon: <GiftOutlined />, label: '活动通知' },
const typeConfig: Record<string, { gradient: string; icon: React.ReactNode; label: string; glowColor: string }> = {
system: {
gradient: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a855f7 100%)',
icon: <ThunderboltOutlined />,
label: '系统通知',
glowColor: 'rgba(99, 102, 241, 0.4)',
},
credit: {
gradient: 'linear-gradient(135deg, #f59e0b 0%, #f97316 50%, #ea580c 100%)',
icon: <StarOutlined />,
label: '积分通知',
glowColor: 'rgba(245, 158, 11, 0.4)',
},
promo: {
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #ec4899 50%, #f43f5e 100%)',
icon: <GiftOutlined />,
label: '活动通知',
glowColor: 'rgba(139, 92, 246, 0.4)',
},
};
const NotificationPopup: React.FC = () => {
@@ -29,10 +44,8 @@ const NotificationPopup: React.FC = () => {
const fetchNotifications = useCallback(async () => {
try {
const result = await getNotifications(1, 20, false);
// 接口返回结构:{ credits: { balance: 4317.23 }, items: [...], total: N }
const data = result.items || [];
// 同步积分:result.credits.balance 与当前 user.credits 不一致时,直接替换
const newBalance = result?.credits?.balance;
if (typeof newBalance === 'number') {
const currentUser = useAuthStore.getState().user;
@@ -66,7 +79,6 @@ const NotificationPopup: React.FC = () => {
setVisible(false);
setNotifications([]);
setCurrentIndex(0);
// 刷新用户信息(包括积分)
try { await refreshUser(); } catch { /* ignore */ }
}
};
@@ -90,97 +102,193 @@ const NotificationPopup: React.FC = () => {
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(15,15,35,0.6)', backdropFilter: 'blur(8px)',
background: 'rgba(0,0,0,0.45)', backdropFilter: 'blur(6px)',
}} onClick={handleClose}>
<div onClick={e => e.stopPropagation()} style={{
width: 420, borderRadius: 20, overflow: 'hidden',
boxShadow: '0 32px 80px rgba(0,0,0,0.35)',
animation: 'slideUp 0.35s cubic-bezier(0.16,1,0.3,1)',
width: 420,
borderRadius: 24,
overflow: 'hidden',
boxShadow: '0 25px 80px rgba(0,0,0,0.15)',
animation: 'notificationPop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)',
background: '#ffffff',
}}>
{/* Header */}
<div style={{
background: tc.gradient,
padding: '28px 28px 24px',
position: 'relative', overflow: 'hidden',
padding: '24px 28px 20px',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
<div style={{ position: 'absolute', right: 50, bottom: -30, width: 80, height: 80, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
<div style={{
position: 'absolute',
right: -50,
top: -50,
width: 200,
height: 200,
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(255,255,255,0.15) 0%, transparent 60%)',
filter: 'blur(30px)',
}} />
<div style={{
position: 'absolute',
right: 30,
top: 10,
width: 80,
height: 80,
borderRadius: '50%',
background: 'rgba(255,255,255,0.06)',
filter: 'blur(15px)',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 48, height: 48, borderRadius: 14,
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(4px)', fontSize: 22, color: '#fff',
width: 44,
height: 44,
borderRadius: 14,
background: 'rgba(255,255,255,0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
color: '#fff',
}}>{tc.icon}</div>
<div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginBottom: 2 }}></div>
<div style={{ color: '#fff', fontSize: 17, fontWeight: 700 }}>{current.title}</div>
<div style={{
color: 'rgba(255,255,255,0.7)',
fontSize: 12,
marginBottom: 4,
fontWeight: 500,
}}></div>
<div style={{
color: '#fff',
fontSize: 28,
fontWeight: 700,
letterSpacing: '-1px',
}}>{current.title}</div>
</div>
</div>
<div onClick={handleClose} style={{
width: 28, height: 28, borderRadius: 8,
background: 'rgba(255,255,255,0.15)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.2s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
<button
onClick={handleClose}
style={{
width: 30,
height: 30,
borderRadius: '50%',
background: 'rgba(255,255,255,0.1)',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
outline: 'none',
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.2)';
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'rgba(255,255,255,0.1)';
}}
>
<CloseOutlined style={{ color: '#fff', fontSize: 12 }} />
</div>
<CloseOutlined style={{ color: '#fff', fontSize: 14 }} />
</button>
</div>
</div>
{/* Body */}
<div style={{ background: '#fff', padding: '24px 28px 28px' }}>
<div style={{
background: '#ffffff',
padding: '24px 28px 28px',
}}>
<div style={{
padding: '18px 20px', background: '#f8fafc', borderRadius: 14,
marginBottom: 20, border: '1px solid #f0f0f5',
padding: '18px 20px',
background: '#f8fafc',
borderRadius: 16,
marginBottom: 20,
border: '1px solid #f1f5f9',
}}>
<div style={{ fontSize: 14, color: '#334155', lineHeight: 1.8 }}>
<div style={{
fontSize: 14,
color: '#475569',
lineHeight: 1.7,
}}>
{current.content}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Tag color={tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6'}
style={{ borderRadius: 6, border: 'none', padding: '2px 10px', fontSize: 12 }}>
<Tag
color={tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6'}
style={{
borderRadius: 6,
border: 'none',
padding: '3px 10px',
fontSize: 11,
fontWeight: 500,
background: '#f1f5f9',
color: '#64748b',
}}
>
{tc.label}
</Tag>
{notifications.length > 1 && (
<span style={{ fontSize: 12, color: '#94a3b8' }}>{currentIndex + 1} / {notifications.length}</span>
)}
</div>
<Button type="primary" onClick={handleAcknowledge} style={{
borderRadius: 10, fontWeight: 600, height: 38,
background: tc.gradient, border: 'none',
paddingLeft: 28, paddingRight: 28,
boxShadow: `0 6px 16px ${tc.gradient.includes('#6366f1') ? 'rgba(99,102,241,0.3)' : tc.gradient.includes('#f59e0b') ? 'rgba(245,158,11,0.3)' : 'rgba(139,92,246,0.3)'}`,
}}>
<Button
type="primary"
onClick={handleAcknowledge}
style={{
borderRadius: 12,
fontWeight: 600,
height: 40,
background: tc.gradient,
border: 'none',
paddingLeft: 28,
paddingRight: 28,
boxShadow: `0 6px 16px ${tc.glowColor}`,
transition: 'all 0.2s ease',
fontSize: 14,
}}
onMouseEnter={e => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = `0 8px 20px ${tc.glowColor}`;
}}
onMouseLeave={e => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = `0 6px 16px ${tc.glowColor}`;
}}
>
</Button>
</div>
{/* Dots */}
{notifications.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 18 }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 20 }}>
{notifications.map((_, i) => (
<div key={i} style={{
width: i === currentIndex ? 22 : 6, height: 6, borderRadius: 3,
background: i === currentIndex ? (tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6') : '#e2e8f0',
transition: 'all 0.3s ease',
}} />
<button
key={i}
onClick={() => setCurrentIndex(i)}
style={{
width: i === currentIndex ? 20 : 5,
height: 5,
borderRadius: 3,
background: i === currentIndex
? (tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6')
: '#e2e8f0',
border: 'none',
cursor: 'pointer',
transition: 'all 0.3s ease',
outline: 'none',
}}
/>
))}
</div>
)}
</div>
</div>
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
@keyframes notificationPop {
0% { opacity: 0; transform: translateY(20px) scale(0.96); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
@@ -139,6 +139,8 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
>
<div style={{ maxHeight: 600, overflow: 'auto' ,marginTop:30}}>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Upload
accept="image/*,video/*"
@@ -237,7 +239,7 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
</div>
)}
{importState === 'imported' && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{importState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
@@ -290,9 +292,10 @@ const PrivatePortraitAssetUpload: React.FC<Props> = ({ projectId, open, onClose,
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} Active AI
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} AI
</div>
</Space>
</div>
</Modal>
);
};
@@ -428,7 +428,7 @@ const VirtualMaterialPanel: React.FC = () => {
{projects.length === 0 ? (
<Empty description="暂无虚拟人像项目组" />
) : (
<Space direction="vertical" style={{ width: '100%' }} size={10}>
<Space orientation="vertical" style={{ width: '100%' }} size={10}>
{projects.map((project) => {
const active = selectedProjectId === project.id;
return (
@@ -582,7 +582,7 @@ const VirtualMaterialPanel: React.FC = () => {
</Modal>
<Modal
title="上传虚拟人像素材"
title="上传虚拟人像素材"
open={uploadOpen}
onCancel={() => setUploadOpen(false)}
onOk={handleUpload}
@@ -593,11 +593,12 @@ const VirtualMaterialPanel: React.FC = () => {
disabled: fileList.length === 0 || fileList.some((f) => uploadStatus[f.uid] !== 'uploaded'),
}}
>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Upload
accept="image/*,video/*"
fileList={fileList}
multiple
<div style={{ maxHeight: 600, overflow: 'auto' ,marginTop:30}}>
<Space direction="vertical" style={{ width: '100%' }} size={14}>
<Upload
accept="image/*,video/*"
fileList={fileList}
multiple
beforeUpload={async (file) => {
const uid = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const isImage = file.type.startsWith('image/');
@@ -691,7 +692,7 @@ const VirtualMaterialPanel: React.FC = () => {
</div>
)}
{importState === 'imported' && (
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
<span style={{ fontSize: 12, color: '#10b981' }}> </span>
)}
{importState === 'error' && (
<span style={{ fontSize: 12, color: '#ef4444' }}> </span>
@@ -744,9 +745,10 @@ const VirtualMaterialPanel: React.FC = () => {
<Button icon={<UploadOutlined />}></Button>
</Upload>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 12, color: '#64748b', fontSize: 13 }}>
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset Active AI
{MIN_PRIVATE_VIDEO_DURATION}~{MAX_PRIVATE_VIDEO_DURATION} CreateAsset AI
</div>
</Space>
</Space>
</div>
</Modal>
<Modal title="素材预览" open={previewOpen} onCancel={() => setPreviewOpen(false)} footer={null} width={760} destroyOnHidden>
+2 -2
View File
@@ -2083,8 +2083,8 @@ const AIChatPage: React.FC = () => {
{/* 头部 - 显示对话标题和模型信息 */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 24px', borderRadius: 22,
background: 'rgb(255, 255, 255)',
padding: '16px 24px',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
backdropFilter: 'blur(22px)',
border: '1px solid rgba(231, 234, 240, 0.82)',
// boxShadow: '0 16px 44px rgba(31, 41, 55, 0.06)',
+4 -12
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from "react";
import { copyToClipboard } from "../utils/clipboard";
import { createPortal } from "react-dom";
import {
Button,
@@ -2741,10 +2742,7 @@ const GeneratePage: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(editedPrompt);
message.success("已复制");
}}
onClick={async () => { const ok = await copyToClipboard(editedPrompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: "#94a3b8" }}
/>
</Tooltip>
@@ -2886,10 +2884,7 @@ const GeneratePage: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(editedPrompt);
message.success("已复制");
}}
onClick={async () => { const ok = await copyToClipboard(editedPrompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: "#94a3b8" }}
/>
</Tooltip>
@@ -3683,10 +3678,7 @@ const GeneratePage: React.FC = () => {
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(prompt);
message.success("已复制");
}}
onClick={async () => { const ok = await copyToClipboard(prompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: "#94a3b8" }}
/>
</Tooltip>
@@ -465,9 +465,9 @@ const GenerateConver: React.FC = () => {
{/* 顶部标题栏 */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 24px', borderRadius: 16,
padding: '16px 24px',
paddingBottom: 0,
background: 'rgba(255,255,255,0.6)',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(99, 102, 241, 0.08)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
+13
View File
@@ -400,3 +400,16 @@
font-size: 15px !important;
}
}
@keyframes sliderShake {
0%, 100% { transform: translateX(0); }
10% { transform: translateX(-8px); }
20% { transform: translateX(8px); }
30% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
50% { transform: translateX(-4px); }
60% { transform: translateX(4px); }
70% { transform: translateX(-2px); }
80% { transform: translateX(2px); }
90% { transform: translateX(-1px); }
}
+16 -3
View File
@@ -27,6 +27,7 @@ const LoginPage: React.FC = () => {
const [sliderVerified, setSliderVerified] = useState(false);
const [loginSliderVerified, setLoginSliderVerified] = useState(false);
const [sliderKey, setSliderKey] = useState(0);
const [sliderShake, setSliderShake] = useState(false);
const [showResend, setShowResend] = useState(false);
const [loginShowResend, setLoginShowResend] = useState(false);
@@ -394,6 +395,9 @@ const LoginPage: React.FC = () => {
setLoginSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else if (showSliderVerify && !loginSliderVerified) {
setSliderShake(true);
setTimeout(() => setSliderShake(false), 500);
} else {
handleSendCode(phoneForm.getFieldValue('phone'));
}
@@ -408,6 +412,7 @@ const LoginPage: React.FC = () => {
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={loginSliderVerified}
shake={sliderShake}
/>
</Form.Item>
)}
@@ -437,6 +442,9 @@ const LoginPage: React.FC = () => {
setSliderVerified(false);
setShowSliderVerify(true);
setSliderKey(prev => prev + 1);
} else if (showSliderVerify && !sliderVerified) {
setSliderShake(true);
setTimeout(() => setSliderShake(false), 500);
} else {
handleSendCode(regForm.getFieldValue('phone'), true);
}
@@ -451,6 +459,7 @@ const LoginPage: React.FC = () => {
<SliderVerify
onSuccess={handleSliderSuccess}
isVerified={sliderVerified}
shake={sliderShake}
/>
</Form.Item>
)}
@@ -526,7 +535,8 @@ const LoginPage: React.FC = () => {
const SliderVerify: React.FC<{
onSuccess: () => void;
isVerified: boolean;
}> = ({ onSuccess, isVerified }) => {
shake?: boolean;
}> = ({ onSuccess, isVerified, shake }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const sliderRef = React.useRef<HTMLDivElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
@@ -673,11 +683,14 @@ const SliderVerify: React.FC<{
borderRadius: 25,
position: 'relative',
overflow: 'hidden',
border: '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: 'inset 0 2px 6px rgba(148, 163, 184, 0.15), 0 4px 12px rgba(0, 0, 0, 0.04)',
border: shake ? '2px solid #f59e0b' : '1px solid rgba(99, 102, 241, 0.1)',
boxShadow: shake
? '0 0 15px rgba(245, 158, 11, 0.4), inset 0 2px 6px rgba(148, 163, 184, 0.15)'
: 'inset 0 2px 6px rgba(148, 163, 184, 0.15), 0 4px 12px rgba(0, 0, 0, 0.04)',
cursor: isVerified ? 'default' : 'pointer',
userSelect: 'none',
touchAction: 'none',
animation: shake ? 'sliderShake 0.5s ease-in-out' : 'none',
}}
>
<div
+1 -1
View File
@@ -110,7 +110,7 @@ const ProjectsPage: React.FC = () => {
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 24, padding: '16px 24px', borderRadius: 16,
background: 'rgba(255,255,255,0.6)',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(99, 102, 241, 0.08)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
+2 -1
View File
@@ -33,6 +33,7 @@ import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import type { GenerationStatus, AspectRatio, Resolution } from '../types';
import { formatDate } from '../utils/formatDate';
import { copyToClipboard } from '../utils/clipboard';
const statusConfig: Record<GenerationStatus, { color: string; text: string; icon: React.ReactNode }> = {
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
@@ -300,7 +301,7 @@ const RecordsPage: React.FC = () => {
<Space size={4}>
<Tooltip title="复制">
<Button type="text" size="small" icon={<CopyOutlined />}
onClick={() => { navigator.clipboard.writeText(prompt); message.success('已复制'); }}
onClick={async () => { const ok = await copyToClipboard(prompt); message.success(ok ? '已复制' : '复制失败'); }}
style={{ color: '#94a3b8' }} />
</Tooltip>
{record.status === 'prompt_optimized' && (
+1 -1
View File
@@ -194,7 +194,7 @@ export default function VideoFrameExtractor() {
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 24, padding: '16px 24px', borderRadius: 16,
background: 'rgba(255,255,255,0.6)',
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(99, 102, 241, 0.08)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
+23
View File
@@ -0,0 +1,23 @@
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return true;
}
// 降级方案:使用 textarea + execCommand
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const succeeded = document.execCommand('copy');
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
+29 -9
View File
@@ -1,12 +1,32 @@
const CST_OFFSET = 8 * 60; // CST = UTC+8, in minutes
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '-';
let s = iso.trim();
// Normalize: space → T
if (!s.includes('T')) s = s.replace(' ', 'T');
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
const dotIdx = s.indexOf('.');
if (dotIdx > 0) s = s.slice(0, dotIdx);
// Remove any trailing timezone info (backend now sends naive datetimes)
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, '');
return s.replace('T', ' ').slice(0, 16);
const s = iso.trim();
if (!s) return '-';
// Parse the ISO string, handling timezone offset
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
if (!m) return s.slice(0, 16).replace('T', ' ');
const [, year, month, day, hour, min, sec, tz] = m;
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
if (tz && tz !== 'Z') {
const sign = tz[0] === '+' ? 1 : -1;
const [oh, om] = tz.slice(1).split(':');
const offsetMin = sign * (+oh * 60 + +om);
const localMs = utcMs - offsetMin * 60000 + CST_OFFSET * 60000;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
const isUTC = tz === 'Z';
const localMs = isUTC ? utcMs + CST_OFFSET * 60000 : utcMs;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}