This commit is contained in:
2026-05-25 17:08:18 +08:00
parent df501f6151
commit f1259b71b5
9178 changed files with 1626125 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+87
View File
@@ -0,0 +1,87 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntApp, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import AppLayout from './components/Layout/AppLayout';
import LoginPage from './pages/LoginPage';
import ProjectsPage from './pages/ProjectsPage';
import GeneratePage from './pages/GeneratePage';
import RecordsPage from './pages/RecordsPage';
import CreditsPage from './pages/CreditsPage';
import { useAuthStore } from './store/useAuthStore';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const { user, loading } = useAuthStore();
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
const App = () => {
const { checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, []);
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#6366f1',
borderRadius: 8,
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
},
components: {
Button: {
controlHeight: 36,
controlHeightLG: 44,
},
Card: {
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
},
Table: {
headerBg: '#fafbfc',
},
},
}}
>
<AntApp>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/projects" replace />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="credits" element={<CreditsPage />} />
</Route>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
</BrowserRouter>
</AntApp>
</ConfigProvider>
);
};
export default App;
+126
View File
@@ -0,0 +1,126 @@
/**
* Real API client for connecting to the FastAPI backend.
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
*/
import { encrypt, decrypt } from './crypto';
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
interface RequestOptions {
method?: string;
body?: unknown;
auth?: boolean;
encryptBody?: boolean;
}
/** Convert snake_case string to camelCase */
function toCamel(s: string): string {
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
}
/** Recursively convert all snake_case keys in an object/array to camelCase */
function keysToCamel(obj: unknown): unknown {
if (Array.isArray(obj)) return obj.map(keysToCamel);
if (obj !== null && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
);
}
return obj;
}
function getToken(): string | null {
return localStorage.getItem('auth_token');
}
export function setToken(token: string): void {
localStorage.setItem('auth_token', token);
}
export function clearToken(): void {
localStorage.removeItem('auth_token');
}
/** Try to decrypt response data, return null if it fails */
async function tryDecrypt(data: string): Promise<string | null> {
try {
return await decrypt(data);
} catch {
return null;
}
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (auth) {
const token = getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
}
let bodyStr: string | undefined;
if (body !== undefined) {
const json = JSON.stringify(body);
if (encryptBody) {
headers['X-Encrypted'] = 'true';
bodyStr = JSON.stringify({ data: await encrypt(json) });
} else {
bodyStr = json;
}
}
const res = await fetch(`${BASE_URL}/api${path}`, {
method,
headers,
body: bodyStr,
});
if (res.status === 204) return undefined as T;
const text = await res.text();
if (!text) return undefined as T;
// Parse the response body
let parsed: any;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(`响应解析失败 (${res.status})`);
}
// If the response is encrypted, decrypt it
const hasEncryptedData = parsed && typeof parsed.data === 'string';
if (hasEncryptedData && encryptBody) {
const decrypted = await tryDecrypt(parsed.data);
if (decrypted !== null) {
parsed = JSON.parse(decrypted);
}
// If decryption fails, fall through — parsed still has {data: "..."}
// which means the response was NOT actually encrypted (just has a "data" field)
}
// Handle error responses
if (!res.ok) {
const msg = parsed?.detail || `请求失败 (${res.status})`;
if (res.status === 401) {
clearToken();
}
throw new Error(msg);
}
return keysToCamel(parsed) as T;
}
// Convenience methods
export const api = {
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }),
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
};
+47
View File
@@ -0,0 +1,47 @@
/**
* AES-256-GCM encryption/decryption for API request/response.
* Uses Web Crypto API with a shared symmetric key.
*/
const ALGO = 'AES-GCM';
const IV_LENGTH = 12;
const TAG_LENGTH = 128;
let cryptoKey: CryptoKey | null = null;
async function getCryptoKey(): Promise<CryptoKey> {
if (cryptoKey) return cryptoKey;
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
if (keyBytes.length !== 32) {
const padded = new Uint8Array(32);
padded.set(keyBytes.slice(0, 32));
keyBytes = padded;
}
cryptoKey = await crypto.subtle.importKey('raw', keyBytes, { name: ALGO }, false, ['encrypt', 'decrypt']);
return cryptoKey;
}
export async function encrypt(plaintext: string): Promise<string> {
const key = await getCryptoKey();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const encoded = new TextEncoder().encode(plaintext);
const cipherBuf = await crypto.subtle.encrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, encoded);
const cipherBytes = new Uint8Array(cipherBuf);
// Prepend IV to ciphertext
const combined = new Uint8Array(iv.length + cipherBytes.length);
combined.set(iv);
combined.set(cipherBytes, iv.length);
return btoa(String.fromCharCode(...combined));
}
export async function decrypt(cipherB64: string): Promise<string> {
const key = await getCryptoKey();
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
const iv = combined.slice(0, IV_LENGTH);
const cipherBytes = combined.slice(IV_LENGTH);
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
return new TextDecoder().decode(plainBuf);
}
+268
View File
@@ -0,0 +1,268 @@
/**
* API abstraction layer.
* Switches between mock data and real backend based on VITE_USE_MOCK env var.
*/
import { api, setToken, clearToken } from './client';
import * as mock from './mock';
import type {
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
} from '../types';
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
// ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
if (USE_MOCK) return mock.mockLogin({ username, password });
const res = await api.post<{ accessToken: string; user: User }>('/auth/login', { username, password, captcha_token: captchaToken, remember_me: rememberMe || false }, false);
setToken(res.accessToken);
return res.user;
}
export async function register(phone: string, code: string, password: string): Promise<User> {
const res = await api.post<{ accessToken: string; user: User }>('/auth/register', { phone, code, password }, false);
setToken(res.accessToken);
return res.user;
}
export async function logout(): Promise<void> {
if (USE_MOCK) return mock.mockLogout();
await api.post('/auth/logout');
clearToken();
}
export async function getUser(): Promise<User | null> {
if (USE_MOCK) return mock.mockGetUser();
try {
return await api.get<User>('/auth/me');
} catch {
return null;
}
}
export async function changePassword(oldPwd: string, newPwd: string): Promise<void> {
if (USE_MOCK) return;
await api.post('/auth/change-password', { old_password: oldPwd, new_password: newPwd });
}
// ── Projects ──────────────────────────────────────────────
export async function getProjects(): Promise<Project[]> {
if (USE_MOCK) return mock.mockGetProjects();
return api.get<Project[]>('/projects');
}
export async function createProject(name: string, industry: Industry): Promise<Project> {
if (USE_MOCK) return mock.mockCreateProject(name, industry);
return api.post<Project>('/projects', { name, industry });
}
export async function deleteProject(id: string): Promise<void> {
if (USE_MOCK) return mock.mockDeleteProject(id);
await api.delete(`/projects/${id}`);
}
// ── Generation ────────────────────────────────────────────
export async function getRecords(projectId?: string): Promise<GenerationRecord[]> {
if (USE_MOCK) return mock.mockGetGenerationRecords(projectId);
const q = projectId ? `?project_id=${projectId}` : '';
return api.get<GenerationRecord[]>(`/generation-records${q}`);
}
export async function optimizePrompt(
projectId: string, params: OptimizeParams
): Promise<OptimizeResult> {
if (USE_MOCK) return mock.mockOptimizePrompt(projectId, params as any);
return api.post('/generation-records/optimize', {
project_id: projectId,
prompt: params.prompt,
duration: params.duration,
references: params.references || null,
idempotency_key: params.idempotencyKey || null,
});
}
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-image`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('图片上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
}
export async function uploadVideo(file: File): Promise<{ url: string; filename: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/generation-records/upload-video`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) throw new Error('视频上传失败');
const data = await res.json();
return { url: data.url, filename: data.filename };
}
export async function deleteUpload(url: string): Promise<void> {
await api.post(`/generation-records/delete-file?url=${encodeURIComponent(url)}`);
}
export async function updateRecordPrompt(recordId: string, optimizedPrompt: string): Promise<void> {
await api.put(`/generation-records/${recordId}/prompt`, { optimized_prompt: optimizedPrompt });
}
export async function generateVideo(recordId: string, params: GenerateParams): Promise<GenerationRecord> {
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`, {
aspect_ratio: params.aspectRatio,
resolution: params.resolution,
});
}
// ── Credits ───────────────────────────────────────────────
export async function getCredits(): Promise<{ credits: number; records: CreditRecord[] }> {
if (USE_MOCK) return mock.mockGetCredits();
return api.get('/credits');
}
// ── Captcha ───────────────────────────────────────────────
export async function getSliderCaptcha(): Promise<{ captcha_id: string; bg_image: string; slider_image: string }> {
if (USE_MOCK) return { captcha_id: 'mock', bg_image: '', slider_image: '' };
return api.get('/captcha/slider', false);
}
export async function verifyCaptcha(captchaId: string, x: number): Promise<string> {
if (USE_MOCK) return 'mock-token';
const res = await api.post<{ token: string }>('/captcha/verify', { captcha_id: captchaId, x_offset: x }, false);
return res.token;
}
// ── Site Info ─────────────────────────────────────────────
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementUrl: string; privacyPolicyUrl: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementUrl: '', privacyPolicyUrl: '' };
return api.get('/auth/site-info', false);
}
// ── Video Engines ─────────────────────────────────────────
export async function getVideoEngines(): Promise<{ items: { id: string; name: string; provider: string; supportedRatios: string[]; supportedResolutions: string[]; supportedDurations: number[] }[] }> {
if (USE_MOCK) return { items: [{ id: 'mock', name: 'Seedance', provider: 'seedance', supportedRatios: ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'], supportedResolutions: ['480p', '720p', '1080p'], supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] }] };
return api.get('/video-engines', false);
}
// ── SMS ───────────────────────────────────────────────────
export async function sendSms(phone: string, captchaToken?: string): Promise<void> {
if (USE_MOCK) return;
await api.post('/sms/send', { phone, captcha_token: captchaToken }, false);
}
export async function verifySms(phone: string, code: string): Promise<{ token: string }> {
if (USE_MOCK) return { token: 'mock-sms-token' };
return api.post('/sms/verify', { phone, code }, false);
}
// ── Notifications ─────────────────────────────────────────
export async function getNotifications(): Promise<AdminNotification[]> {
if (USE_MOCK) return mock.mockGetAdminNotifications();
return api.get('/notifications');
}
export async function getUnreadCount(): Promise<number> {
if (USE_MOCK) return mock.mockGetAdminNotifications().then(n => n.filter(x => !x.isRead).length);
const res = await api.get<{ count: number }>('/notifications/unread-count');
return res.count;
}
export async function markNotificationRead(id: string): Promise<void> {
if (USE_MOCK) return;
await api.put(`/notifications/${id}/read`);
}
// ── Admin ─────────────────────────────────────────────────
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 adjustCredits(userId: string, amount: number, description: string): Promise<void> {
if (USE_MOCK) return mock.mockAdjustCredits(userId, amount, description);
await api.post(`/admin/users/${userId}/credits`, { amount, description });
}
export async function toggleUserStatus(userId: string, isActive: boolean): Promise<void> {
if (USE_MOCK) return mock.mockToggleUserStatus(userId, isActive);
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
}
export async function getModelConfigs(): Promise<ModelConfig[]> {
if (USE_MOCK) return mock.mockGetModelConfigs();
return api.get('/admin/model-configs');
}
export async function saveModelConfig(config: Partial<ModelConfig> & { id?: string }): Promise<ModelConfig> {
if (USE_MOCK) return mock.mockSaveModelConfig(config as any);
if (config.id) return api.put(`/admin/model-configs/${config.id}`, config);
return api.post('/admin/model-configs', config);
}
export async function deleteModelConfig(id: string): Promise<void> {
if (USE_MOCK) return mock.mockDeleteModelConfig(id);
await api.delete(`/admin/model-configs/${id}`);
}
export async function getSystemConfigs(): Promise<SystemConfig[]> {
if (USE_MOCK) return mock.mockGetSystemConfigs();
return api.get('/admin/system-configs');
}
export async function updateSystemConfig(id: string, value: string): Promise<void> {
if (USE_MOCK) return mock.mockUpdateSystemConfig(id, value);
await api.put(`/admin/system-configs/${id}`, { value });
}
// ── Industries ─────────────────────────────────────────────
export async function getIndustries(): Promise<IndustryConfig[]> {
const data = await api.get<any[]>('/industries');
return data.map((item: any) => {
const skills = Array.isArray(item.skills) ? item.skills : [];
const optionGroups = skills
.filter((s: any) => s && s.type === 'option_group' && s.name && Array.isArray(s.options))
.map((s: any) => ({ name: s.name, options: s.options }));
return { ...item, optionGroups };
});
}
// ── Menu Config ────────────────────────────────────────────
export async function getMenuConfigs(): Promise<any[]> {
return api.get('/menu-configs');
}
// ── Recharge Packages ──────────────────────────────────────
export async function getRechargePackages(): Promise<any[]> {
return api.get('/recharge-packages');
}
+344
View File
@@ -0,0 +1,344 @@
import type {
User,
CreditRecord,
Project,
GenerationRecord,
OptimizeParams,
OptimizeResult,
LoginParams,
Industry,
AdminUser,
AdminStats,
ModelConfig,
SystemConfig,
AdminNotification,
} from '../types';
// Simulated delay
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ── Mock Data ──────────────────────────────────────────────
let currentUser: User | null = null;
const MOCK_USER: User = {
id: 'u-001',
username: 'videomaker',
email: 'demo@videogen.ai',
credits: 2680,
};
const MOCK_CREDIT_RECORDS: CreditRecord[] = [
{ id: 'c-1', type: 'recharge', amount: 3000, description: '会员充值赠送', createdAt: '2026-04-28 10:00:00' },
{ id: 'c-2', type: 'consume', amount: -120, description: '提示词优化 - 电商广告视频', createdAt: '2026-04-29 14:22:00' },
{ id: 'c-3', type: 'consume', amount: -80, description: '提示词优化 - 教育课程视频', createdAt: '2026-04-30 09:15:00' },
{ id: 'c-4', type: 'consume', amount: -120, description: '提示词优化 - 产品宣传视频', createdAt: '2026-05-01 16:40:00' },
{ id: 'c-5', type: 'recharge', amount: 500, description: '活动赠送积分', createdAt: '2026-05-02 11:00:00' },
{ id: 'c-6', type: 'consume', amount: -100, description: '提示词优化 - 品牌故事视频', createdAt: '2026-05-03 08:30:00' },
];
let MOCK_PROJECTS: Project[] = [
{ id: 'p-1', name: '618电商大促宣传片', industry: 'ecommerce', createdAt: '2026-04-28', updatedAt: '2026-05-01' },
{ id: 'p-2', name: '在线课程推广视频', industry: 'education', createdAt: '2026-04-30', updatedAt: '2026-05-02' },
{ id: 'p-3', name: '新游戏预告片', industry: 'gaming', createdAt: '2026-05-01', updatedAt: '2026-05-03' },
];
let MOCK_RECORDS: GenerationRecord[] = [
{
id: 'r-1',
projectId: 'p-1',
projectName: '618电商大促宣传片',
originalPrompt: '一个年轻女性在时尚直播间推荐夏季新款连衣裙,背景明亮温馨',
optimizedPrompt: '镜头缓慢推近一位25岁左右的亚洲女性主播,她身穿亮色夏季连衣裙,站在精心布置的粉色系直播间内。身后是柔和的环形补光灯和商品展示架。她面带微笑,手势优雅地展示裙摆细节。暖色调LED背景墙上显示"618大促"字样。4K画质,浅景深,时尚美妆类短视频风格。',
duration: 15,
aspectRatio: '9:16',
resolution: '1080p',
status: 'completed',
videoUrl: 'https://example.com/video1.mp4',
textCreditsCost: 10,
textTokensUsed: 850,
creditsCost: 120,
videoTokensUsed: 0,
createdAt: '2026-04-29 14:22:00',
generatedAt: '2026-04-29 14:25:00',
},
{
id: 'r-2',
projectId: 'p-2',
projectName: '在线课程推广视频',
originalPrompt: '学生在明亮的教室里用平板电脑学习编程课程',
optimizedPrompt: '俯拍视角,一位大学生坐在现代化开放式学习空间的木质书桌前,手持iPad Pro,屏幕上显示Python代码编辑器界面。桌上摆放着咖啡杯、笔记本和绿色小盆栽。自然光从落地窗洒入,营造温暖的学习氛围。背景虚化处可见其他学生在安静学习。画面节奏舒缓,配以轻柔的钢琴背景音乐。',
duration: 20,
aspectRatio: '16:9',
resolution: '1080p',
status: 'completed',
videoUrl: 'https://example.com/video2.mp4',
textCreditsCost: 8,
textTokensUsed: 720,
creditsCost: 80,
videoTokensUsed: 0,
createdAt: '2026-04-30 09:15:00',
generatedAt: '2026-04-30 09:18:00',
},
{
id: 'r-3',
projectId: 'p-1',
projectName: '618电商大促宣传片',
originalPrompt: '多个快递包裹从仓库货架上飞出,物流车快速配送',
optimizedPrompt: '高速摄影风格,镜头从大型智能仓储中心内部开始,自动化机械臂精准抓取印有品牌Logo的快递包裹。包裹沿传送带高速移动,在分拣中心精准落入对应区域。画面切换至无人机和无人配送车在城市街道上进行最后一公里配送。最终画面定格在消费者微笑签收包裹的瞬间。整体采用蓝色科技感色调,快节奏剪辑。',
duration: 10,
aspectRatio: '16:9',
resolution: '4K',
status: 'prompt_optimized',
textCreditsCost: 12,
textTokensUsed: 960,
creditsCost: 120,
videoTokensUsed: 0,
createdAt: '2026-05-01 16:40:00',
},
{
id: 'r-4',
projectId: 'p-3',
projectName: '新游戏预告片',
originalPrompt: '一个奇幻世界里的魔法城堡,龙在天空飞过',
optimizedPrompt: '史诗级航拍镜头,一座哥特式魔法城堡矗立在云雾缭绕的山巅,城堡尖塔闪烁着神秘的紫色光芒。天空中一头银色巨龙展开双翼翱翔而过,鳞片在夕阳下折射出炫目光芒。镜头环绕城堡360度旋转,展示城堡周围悬浮的魔法水晶和瀑布。大气磅礴的管弦乐配乐,电影级CG画质,暗色调魔幻风格。',
duration: 30,
aspectRatio: '16:9',
resolution: '4K',
status: 'prompt_optimized',
textCreditsCost: 10,
textTokensUsed: 880,
creditsCost: 100,
videoTokensUsed: 0,
createdAt: '2026-05-03 08:30:00',
},
];
// ── Mock API Functions ─────────────────────────────────────
export async function mockLogin(params: LoginParams): Promise<User> {
await delay(800);
if (!params.username || !params.password) {
throw new Error('请输入用户名和密码');
}
currentUser = { ...MOCK_USER, username: params.username };
return currentUser;
}
export async function mockLogout(): Promise<void> {
await delay(300);
currentUser = null;
}
export async function mockGetUser(): Promise<User | null> {
await delay(200);
return currentUser;
}
export async function mockGetCredits(): Promise<{ credits: number; records: CreditRecord[] }> {
await delay(400);
return {
credits: currentUser?.credits ?? 0,
records: MOCK_CREDIT_RECORDS,
};
}
export async function mockGetProjects(): Promise<Project[]> {
await delay(300);
return [...MOCK_PROJECTS];
}
export async function mockCreateProject(name: string, industry: Industry): Promise<Project> {
await delay(500);
const project: Project = {
id: `p-${Date.now()}`,
name,
industry,
createdAt: new Date().toISOString().slice(0, 10),
updatedAt: new Date().toISOString().slice(0, 10),
};
MOCK_PROJECTS.unshift(project);
return project;
}
export async function mockDeleteProject(id: string): Promise<void> {
await delay(300);
MOCK_PROJECTS = MOCK_PROJECTS.filter((p) => p.id !== id);
}
export async function mockOptimizePrompt(
projectId: string,
params: OptimizeParams
): Promise<OptimizeResult> {
await delay(1500);
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
const textCredits = Math.max(1, Math.ceil(params.prompt.length * 0.3));
const textTokens = Math.round(params.prompt.length * 1.2);
if (currentUser) {
currentUser.credits -= textCredits;
}
const optimizedPromptMap: Record<string, string> = {
'直播': `镜头缓慢推近一位25岁左右的亚洲女性主播,她身穿亮色系服装,站在精心布置的现代直播间内。身后是柔和的环形补光灯和多层商品展示架。她面带自信微笑,手势优雅地展示产品细节。暖色调LED背景墙上动态显示品牌元素。4K画质,浅景深,时尚美妆类短视频风格。`,
'产品': `微距镜头缓缓推进,展示一件精致的科技产品放置在纯黑大理石台面上。柔和的三点布光突显产品的金属质感和流线型设计。镜头沿产品表面缓慢滑行,每一个细节——按钮、接口、材质纹理——都被清晰捕捉。背景为虚化的深蓝色渐变,偶尔有微弱的光斑闪烁。极简主义广告风格。`,
'课程': `俯拍视角,一位学生坐在现代化学习空间的木质书桌前,手持平板设备,屏幕上显示丰富的学习内容。桌上摆放着咖啡杯、笔记本和绿色小盆栽。自然光从落地窗洒入,营造温暖的学习氛围。画面节奏舒缓,知识改变命运的叙事风格。`,
'美食': `高速微距摄影,新鲜食材从空中落下慢动作特写:翠绿的蔬菜叶片、鲜红的番茄切片、金黄的芝士丝在暖色灯光下飞舞。镜头切换至厨师双手在光滑的不锈钢操作台上精心摆盘。最终成品——一道精致的创意料理在柔光中呈现,蒸汽袅袅升起。暖色调,美食纪录片风格。`,
'品牌': `电影级运镜,镜头从城市天际线的黎明金光中缓缓下降,穿过玻璃幕墙的反光,进入一间充满设计感的创意办公室。设计师团队围坐在大屏幕前热烈讨论。镜头继续穿越屏幕,进入品牌视觉世界——色彩、字体、图像在三维空间中流动重组,最终凝聚成一个令人印象深刻的标志。品牌叙事风格。`,
'游戏': `史诗级航拍镜头,一座宏伟的建筑矗立在云雾缭绕的山巅,闪烁着神秘的光芒。天空中壮观的景象展开,光芒在夕阳下折射出炫目光芒。镜头环绕建筑360度旋转,展示周围悬浮的元素。大气磅礴的配乐,电影级CG画质。`,
};
let optimizedPrompt = `针对"${params.prompt}"的专业视频描述:`;
const keys = Object.keys(optimizedPromptMap);
const matched = keys.find((k) => params.prompt.includes(k));
if (matched) {
optimizedPrompt = optimizedPromptMap[matched];
} else {
optimizedPrompt = `精心构图的画面中,${params.prompt}。采用电影级镜头语言,自然光线与人工光源完美结合,营造出沉浸式视觉体验。画面色彩饱满而真实,细节丰富。运镜流畅自然,节奏张弛有度,完美适配${params.duration}秒时长。`;
}
const record: GenerationRecord = {
id: `r-${Date.now()}`,
projectId,
projectName: project?.name ?? '未知项目',
originalPrompt: params.prompt,
optimizedPrompt,
duration: params.duration,
status: 'prompt_optimized',
textCreditsCost: textCredits,
textTokensUsed: textTokens,
creditsCost: 0,
videoTokensUsed: 0,
createdAt: new Date().toLocaleString('zh-CN'),
};
MOCK_RECORDS.unshift(record);
return { optimizedPrompt, textCreditsCost: textCredits, textTokensUsed: textTokens, record };
}
export async function mockGenerateVideo(recordId: string): Promise<GenerationRecord> {
await delay(2000);
const record = MOCK_RECORDS.find((r) => r.id === recordId);
if (!record) throw new Error('记录不存在');
record.status = 'completed';
record.videoUrl = `https://example.com/video-${recordId}.mp4`;
record.generatedAt = new Date().toLocaleString('zh-CN');
return record;
}
export async function mockGetGenerationRecords(projectId?: string): Promise<GenerationRecord[]> {
await delay(300);
if (projectId) {
return MOCK_RECORDS.filter((r) => r.projectId === projectId);
}
return [...MOCK_RECORDS];
}
// ── Admin Mock Data ───────────────────────────────────
const MOCK_ADMIN_USERS: AdminUser[] = [
{ id: 'u-001', username: 'videomaker', email: 'demo@videogen.ai', credits: 2680, isActive: true, isAdmin: false, userType: 'normal', createdAt: '2026-04-15', lastLoginAt: '2026-05-06 14:30' },
{ id: 'u-002', username: 'designer', email: 'designer@example.com', credits: 520, isActive: true, isAdmin: false, userType: 'normal', createdAt: '2026-04-20', lastLoginAt: '2026-05-05 09:12' },
{ id: 'u-003', username: 'marketer', email: 'mkt@company.com', phone: '13800138000', credits: 0, isActive: true, isAdmin: false, userType: 'normal', createdAt: '2026-04-25', lastLoginAt: '2026-05-04 16:45' },
{ id: 'u-004', username: 'editor', email: 'editor@studio.com', credits: 1500, isActive: false, isAdmin: false, userType: 'normal', createdAt: '2026-04-10', lastLoginAt: '2026-04-28 11:20' },
{ id: 'u-005', username: 'admin', email: 'admin@videogen.ai', credits: 10000, isActive: true, isAdmin: true, userType: 'admin', createdAt: '2026-04-01', lastLoginAt: '2026-05-07 08:00' },
];
let MOCK_MODEL_CONFIGS: ModelConfig[] = [
{ id: 'm-1', name: 'GPT-4o', provider: 'openai_compatible', apiBase: 'https://api.openai.com/v1', apiKey: 'sk-****', modelName: 'gpt-4o', weight: 3, maxTokens: 4096, temperature: 0.7, isActive: true, priority: 1 },
{ id: 'm-2', name: 'DeepSeek-V3', provider: 'openai_compatible', apiBase: 'https://api.deepseek.com/v1', apiKey: 'sk-****', modelName: 'deepseek-chat', weight: 2, maxTokens: 4096, temperature: 0.7, isActive: true, priority: 2 },
{ id: 'm-3', name: 'Mock模式', provider: 'mock', apiBase: '', apiKey: '', modelName: 'mock', weight: 1, maxTokens: 2048, temperature: 0.5, isActive: false, priority: 0 },
];
let MOCK_SYSTEM_CONFIGS: SystemConfig[] = [
{ id: 'sc-1', key: 'site_name', value: 'VideoGen.AI', description: '网站名称' },
{ id: 'sc-2', key: 'site_logo', value: '', description: '网站Logo URL' },
{ id: 'sc-3', key: 'seo_title', value: 'VideoGen.AI - AI视频生成平台', description: 'SEO标题' },
{ id: 'sc-4', key: 'seo_description', value: '专业的AI视频生成服务,一键将文字转化为精美视频', description: 'SEO描述' },
{ id: 'sc-5', key: 'seo_keywords', value: 'AI视频,视频生成,人工智能,AIGC,短视频', description: 'SEO关键词' },
];
const MOCK_ADMIN_NOTIFICATIONS: AdminNotification[] = [
{ id: 'n-1', title: '系统上线通知', content: 'VideoGen.AI 平台正式上线,欢迎体验AI视频生成功能!', type: 'system', isRead: false, createdAt: '2026-05-01 09:00:00' },
{ id: 'n-2', title: '积分充值优惠', content: '限时活动:充值进阶包额外赠送200积分,活动截止5月15日。', type: 'credit', isRead: false, createdAt: '2026-05-03 10:00:00' },
{ id: 'n-3', title: '功能更新', content: '新增9:16竖屏比例支持,适配抖音、快手等短视频平台。', type: 'system', isRead: true, createdAt: '2026-05-05 14:00:00' },
];
// ── Admin Mock API Functions ──────────────────────────
export async function mockGetAdminStats(): Promise<AdminStats> {
await delay(500);
return {
totalUsers: 5,
totalProjects: 12,
totalGenerations: 48,
totalRevenue: 8560,
creditsConsumedToday: 320,
};
}
export async function mockGetAdminUsers(search?: string): Promise<AdminUser[]> {
await delay(400);
if (search) {
return MOCK_ADMIN_USERS.filter(u =>
u.username.includes(search) || u.email.includes(search)
);
}
return [...MOCK_ADMIN_USERS];
}
export async function mockAdjustCredits(userId: string, amount: number, _description: string): Promise<void> {
await delay(500);
const user = MOCK_ADMIN_USERS.find(u => u.id === userId);
if (user) user.credits += amount;
}
export async function mockToggleUserStatus(userId: string, isActive: boolean): Promise<void> {
await delay(300);
const user = MOCK_ADMIN_USERS.find(u => u.id === userId);
if (user) user.isActive = isActive;
}
export async function mockGetModelConfigs(): Promise<ModelConfig[]> {
await delay(300);
return [...MOCK_MODEL_CONFIGS];
}
export async function mockSaveModelConfig(config: Omit<ModelConfig, 'id'> & { id?: string }): Promise<ModelConfig> {
await delay(500);
if (config.id) {
const idx = MOCK_MODEL_CONFIGS.findIndex(m => m.id === config.id);
if (idx >= 0) {
MOCK_MODEL_CONFIGS[idx] = { ...config, id: config.id } as ModelConfig;
return MOCK_MODEL_CONFIGS[idx];
}
}
const newConfig: ModelConfig = { ...config, id: `m-${Date.now()}` } as ModelConfig;
MOCK_MODEL_CONFIGS.push(newConfig);
return newConfig;
}
export async function mockDeleteModelConfig(id: string): Promise<void> {
await delay(300);
MOCK_MODEL_CONFIGS = MOCK_MODEL_CONFIGS.filter(m => m.id !== id);
}
export async function mockGetSystemConfigs(): Promise<SystemConfig[]> {
await delay(300);
return [...MOCK_SYSTEM_CONFIGS];
}
export async function mockUpdateSystemConfig(id: string, value: string): Promise<void> {
await delay(300);
const config = MOCK_SYSTEM_CONFIGS.find(c => c.id === id);
if (config) config.value = value;
}
export async function mockGetAdminNotifications(): Promise<AdminNotification[]> {
await delay(300);
return [...MOCK_ADMIN_NOTIFICATIONS];
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,631 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd';
import {
PlayCircleOutlined,
WalletOutlined,
LogoutOutlined,
UserOutlined,
ThunderboltOutlined,
HomeOutlined,
LockOutlined,
PlusCircleOutlined,
LeftOutlined,
RightOutlined,
PlusOutlined,
GiftOutlined,
BellOutlined,
StarFilled,
FireFilled,
CrownFilled,
BankFilled,
CloseOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
import NotificationPopup from '../NotificationPopup';
interface MenuConfig {
id: string;
key?: string;
label: string;
path: string;
icon: string;
sortOrder: number;
isActive: boolean;
parentId?: string | null;
parent_id?: string | null;
menuType?: string;
menu_type?: string;
}
const iconMap: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
WalletOutlined: <WalletOutlined />,
SettingOutlined: <LockOutlined />,
BellOutlined: <GiftOutlined />,
UserOutlined: <UserOutlined />,
AppstoreOutlined: <FireFilled />,
FileTextOutlined: <FireFilled />,
StarOutlined: <StarFilled />,
HeartOutlined: <FireFilled />,
CameraOutlined: <PlayCircleOutlined />,
};
const EXPANDED_W = 240;
const COLLAPSED_W = 68;
const GRADIENTS = [
{ gradient: 'linear-gradient(135deg, #f59e0b, #f97316)', shadow: 'rgba(245,158,11,0.3)', icon: <StarFilled /> },
{ gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)', shadow: 'rgba(99,102,241,0.3)', icon: <FireFilled /> },
{ gradient: 'linear-gradient(135deg, #06b6d4, #0ea5e9)', shadow: 'rgba(6,182,212,0.3)', icon: <CrownFilled /> },
{ gradient: 'linear-gradient(135deg, #10b981, #059669)', shadow: 'rgba(16,185,129,0.3)', icon: <BankFilled /> },
];
const AppLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
const [pwdForm] = Form.useForm();
const [collapsed, setCollapsed] = useState(false);
const [toggleHover, setToggleHover] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [msgModalOpen, setMsgModalOpen] = useState(false);
const [allNotifications, setAllNotifications] = useState<any[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [siteName, setSiteName] = useState('VideoGen.AI');
const [siteLogo, setSiteLogo] = useState('');
useEffect(() => {
getSiteInfo().then(info => {
setSiteName(info.siteName || 'VideoGen.AI');
setSiteLogo(info.siteLogo || '');
document.title = info.siteName || 'VideoGen.AI';
}).catch(() => {});
}, []);
const loadNotifications = () => {
getNotifications().then(data => {
setAllNotifications(data);
setUnreadCount(data.filter((n: any) => !(n.isRead ?? n.is_read)).length);
}).catch(() => {});
};
useEffect(() => {
getMenuConfigs().then(data => {
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
// Filter by user's allowed menus if set
if (user?.allowedMenus && user.allowedMenus.length > 0) {
const allowed = new Set(user.allowedMenus);
const groupIds = new Set<string>();
items.forEach((m: any) => {
const pid = m.parent_id ?? m.parentId;
if (pid && allowed.has(m.path)) groupIds.add(pid);
});
items = items.filter((m: any) => {
const mt = m.menu_type ?? m.menuType;
if (mt === 'group' && groupIds.has(m.id)) return true;
return allowed.has(m.path);
});
}
setMenuItems(items);
}).catch(() => {});
getRechargePackages().then(data => {
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
}).catch(() => {});
loadNotifications();
}, [user]);
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
const sidebarW = collapsed ? COLLAPSED_W : EXPANDED_W;
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined />, label: `积分余额: ${user?.credits ?? 0}`, disabled: true },
{ type: 'divider' as const },
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
{ type: 'divider' as const },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
];
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); navigate('/login'); }
else if (key === 'changePwd') { setPwdModalOpen(true); }
};
const handleChangePwd = async () => {
try {
await pwdForm.validateFields();
message.success('密码修改成功(演示)');
setPwdModalOpen(false); pwdForm.resetFields();
} catch { /* validation */ }
};
const handleLogout = () => {
logout();
navigate('/login');
};
const handleMobileRecharge = () => {
setRechargeModalOpen(true);
};
return (
<Layout style={{ minHeight: '100vh' }}>
{/* Desktop Sidebar */}
<div className="desktop-sidebar" style={{
width: sidebarW, position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
borderRight: '1px solid rgba(255,255,255,0.05)',
display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease',
overflow: 'hidden',
}}>
{/* Logo + Toggle */}
<div style={{
height: 72, display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
borderBottom: '1px solid rgba(255,255,255,0.06)',
padding: collapsed ? '0' : '0 16px 0 20px',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, overflow: 'hidden' }}>
<div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(99,102,241,0.3)', overflow: 'hidden',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 18, color: '#fff' }} />
)}
</div>
{!collapsed && (
<span style={{
color: '#f1f5f9', fontSize: 17, fontWeight: 800, letterSpacing: -0.5,
whiteSpace: 'nowrap', opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease',
}}>
{siteName}
</span>
)}
</div>
</div>
{/* Credits pill */}
<div onClick={() => navigate('/credits')} style={{
margin: collapsed ? '16px auto 4px' : '16px 14px 4px',
padding: collapsed ? '10px' : '12px 14px',
borderRadius: 12,
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.12))',
border: '1px solid rgba(99,102,241,0.15)', cursor: 'pointer', transition: 'all 0.25s',
textAlign: collapsed ? 'center' : 'left',
}}>
{collapsed ? (
<WalletOutlined style={{ color: '#818cf8', fontSize: 18 }} />
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<WalletOutlined style={{ color: '#818cf8', fontSize: 13 }} />
<span style={{ color: 'rgba(203,213,225,0.6)', fontSize: 12 }}></span>
</div>
<span style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>{user?.credits ?? 0}</span>
</>
)}
</div>
{/* Recharge button - opens modal */}
<div onClick={() => setRechargeModalOpen(true)} style={{
margin: collapsed ? '8px auto 4px' : '8px 14px 4px',
padding: collapsed ? '10px' : '10px 14px',
borderRadius: 12, cursor: 'pointer', textAlign: 'center',
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))',
border: '1px solid rgba(99,102,241,0.25)',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.3), rgba(139,92,246,0.3))'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))'; }}
>
<PlusOutlined style={{ color: '#818cf8', fontSize: 13 }} />
{!collapsed && <span style={{ color: '#818cf8', fontSize: 13, fontWeight: 600 }}></span>}
</div>
{/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'hidden' }}>
{!collapsed && (
<div style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
</div>
)}
{(() => {
const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group');
const pages = menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group');
const childMap: Record<string, MenuConfig[]> = {};
pages.filter(m => m.parent_id ?? m.parentId).forEach(m => {
const pid = (m.parent_id ?? m.parentId) as string;
if (!childMap[pid]) childMap[pid] = [];
childMap[pid].push(m);
});
const topLevel = pages.filter(m => !(m.parent_id ?? m.parentId));
const items: React.ReactNode[] = [];
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
const isActive = item.path === selectedKey;
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
const el = (
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: collapsed ? 0 : 10,
padding: collapsed ? '10px 0' : depth > 0 ? '8px 14px 8px 32px' : '10px 14px',
borderRadius: 10, margin: '2px 0', cursor: 'pointer',
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
color: isActive ? '#fff' : 'rgba(148,163,184,0.75)',
background: isActive ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))' : 'transparent',
border: isActive ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
transition: 'all 0.2s ease',
}}>
<span style={{ fontSize: depth > 0 ? 15 : 17, flexShrink: 0 }}>{menuIcon}</span>
{!collapsed && <span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>}
</div>
);
return collapsed ? <Tooltip key={item.id} title={item.label} placement="right">{el}</Tooltip> : el;
};
// Render groups with children
groups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(g => {
const children = (childMap[g.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
if (!collapsed) {
const isGroupOpen = !collapsedGroups[g.id];
items.push(
<div key={g.id}>
<div onClick={() => setCollapsedGroups(prev => ({ ...prev, [g.id]: !prev[g.id] }))} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 14px', margin: '8px 0 2px', cursor: 'pointer',
color: 'rgba(148,163,184,0.5)', fontSize: 11, fontWeight: 600,
letterSpacing: 0.5,
}}>
<span>{g.label}</span>
<span style={{ fontSize: 10, transform: isGroupOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}></span>
</div>
{isGroupOpen && children.map(c => renderMenuItem(c, 1))}
</div>
);
} else {
children.forEach(c => items.push(renderMenuItem(c)));
}
});
// Render top-level pages
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
items.push(renderMenuItem(m));
});
return items;
})()}
</div>
{/* Message button */}
<div onClick={() => { setMsgModalOpen(true); loadNotifications(); }} style={{
margin: collapsed ? '4px auto' : '4px 14px',
padding: collapsed ? '10px' : '10px 14px',
borderRadius: 12, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
gap: collapsed ? 0 : 10,
color: 'rgba(148,163,184,0.75)', fontSize: 14,
background: 'rgba(255,255,255,0.03)',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }}
>
<span style={{ position: 'relative' }}>
<BellOutlined style={{ fontSize: 16 }} />
{unreadCount > 0 && (
<span style={{
position: 'absolute', top: -6, right: -8,
background: '#ef4444', color: '#fff', fontSize: 10, fontWeight: 700,
borderRadius: 10, padding: '0 5px', lineHeight: '16px', minWidth: 16, textAlign: 'center',
}}>{unreadCount > 99 ? '99+' : unreadCount}</span>
)}
</span>
{!collapsed && <span></span>}
</div>
{/* User block at bottom-left */}
<div style={{ padding: collapsed ? '12px 8px' : '14px 14px', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: collapsed ? 0 : 10,
padding: collapsed ? '8px' : '8px 10px',
borderRadius: 12, cursor: 'pointer', transition: 'background 0.2s',
background: 'rgba(255,255,255,0.03)',
}}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.06)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.03)'}
>
<Avatar size={32} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', flexShrink: 0 }} />
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#e2e8f0', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.username}
</div>
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 11 }}></div>
</div>
)}
</div>
</Dropdown>
</div>
</div>
{/* Floating sidebar toggle hover zone */}
<div
onMouseEnter={() => setToggleHover(true)}
onMouseLeave={() => setToggleHover(false)}
style={{
position: 'fixed', left: sidebarW - 16, top: 0, bottom: 0,
zIndex: 110, width: 32, cursor: 'default',
}}
>
{toggleHover && (
<div onClick={() => setCollapsed(!collapsed)} style={{
position: 'absolute', left: '50%', top: '50%',
transform: 'translate(-50%, -50%)',
width: 24, height: 56, borderRadius: '0 8px 8px 0',
background: '#1a1a35', border: '1px solid rgba(255,255,255,0.1)', borderLeft: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
boxShadow: '2px 0 12px rgba(0,0,0,0.2)',
animation: 'fadeInRight 0.2s ease both',
}}>
{collapsed
? <RightOutlined style={{ color: '#818cf8', fontSize: 12 }} />
: <LeftOutlined style={{ color: '#818cf8', fontSize: 12 }} />}
</div>
)}
</div>
{/* Main Content */}
<div className="desktop-content" style={{
marginLeft: sidebarW, flex: 1, minHeight: '100vh', background: '#f5f6fa',
padding: '24px 32px 32px', transition: 'margin-left 0.25s ease',
}}>
<Outlet />
</div>
{/* Mobile Bottom Nav */}
<div className="mobile-bottom-nav">
{menuItems.map((item) => {
const isActive = item.path === selectedKey;
return (
<div key={item.id}
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => navigate(item.path)}>
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
<span>{item.label}</span>
</div>
);
})}
<div className="nav-item" onClick={handleMobileRecharge}>
<span className="nav-icon"><PlusCircleOutlined /></span>
<span></span>
</div>
<div className="nav-item" onClick={handleLogout}>
<span className="nav-icon"><LogoutOutlined /></span>
<span>退</span>
</div>
</div>
{/* Change Password Modal */}
<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>
{/* Recharge Modal */}
<Modal title={<Space><GiftOutlined /></Space>} open={rechargeModalOpen}
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
footer={null} width={680}>
<div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#64748b' }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text>
</Space>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{rechargeOptions.map((opt, idx) => {
const g = GRADIENTS[idx % GRADIENTS.length];
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
return (
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
}}>
{opt.description && (
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
}}>{g.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} </Typography.Text>
</div>
<div style={{
fontSize: 22, fontWeight: 800, marginTop: 4,
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
}}>¥{opt.price}</div>
</div>
</div>
</div>
);
})}
</div>
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end' }}>
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}></Button>
<Button type="primary" size="large" disabled={!selectedPlan}
onClick={() => { message.success('支付功能对接后端后开放'); setRechargeModalOpen(false); setSelectedPlan(null); }}
style={{
borderRadius: 10, fontWeight: 600,
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
}}>
</Button>
</div>
</div>
</Modal>
{/* Message Center Modal */}
<Modal
open={msgModalOpen}
onCancel={() => { setMsgModalOpen(false); }}
footer={null} width={520}
closable={false}
styles={{
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
}}
>
{/* Header */}
<div style={{
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
padding: '20px 24px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 10,
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
backdropFilter: 'blur(4px)',
}}>
<BellOutlined style={{ fontSize: 18, color: '#fff' }} />
</div>
<span style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}></span>
{unreadCount > 0 && (
<span style={{
background: 'rgba(255,255,255,0.25)', color: '#fff',
fontSize: 11, fontWeight: 600, borderRadius: 10,
padding: '2px 10px', backdropFilter: 'blur(4px)',
}}>{unreadCount} </span>
)}
</div>
<Button type="text" icon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />}
onClick={() => setMsgModalOpen(false)}
style={{ color: '#fff' }} />
</div>
{/* List */}
<div style={{ maxHeight: 460, overflow: 'auto', padding: '12px 16px 16px' }}>
{allNotifications.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<BellOutlined style={{ fontSize: 40, color: '#cbd5e1', marginBottom: 12 }} />
<div style={{ color: '#94a3b8', fontSize: 14 }}></div>
</div>
) : (
allNotifications.map((n: any) => {
const isRead = n.isRead ?? n.is_read;
const typeConfig: Record<string, { color: string; bg: string; label: string }> = {
system: { color: '#6366f1', bg: 'rgba(99,102,241,0.1)', label: '系统' },
credit: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: '积分' },
promo: { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)', label: '活动' },
};
const tc = typeConfig[n.type] || typeConfig.system;
return (
<div key={n.id} style={{
padding: '16px', borderRadius: 12, marginBottom: 8,
background: isRead ? '#fff' : 'linear-gradient(135deg, rgba(99,102,241,0.03), rgba(139,92,246,0.03))',
border: isRead ? '1px solid #f0f0f5' : '1px solid rgba(99,102,241,0.18)',
cursor: isRead ? 'default' : 'pointer',
transition: 'all 0.2s',
position: 'relative',
}} onClick={async () => {
if (!isRead) {
await markNotificationRead(n.id);
loadNotifications();
}
}}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.04)'; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}
>
<div style={{ display: 'flex', gap: 12 }}>
{/* Type icon */}
<div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
background: tc.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<BellOutlined style={{ color: tc.color, fontSize: 15 }} />
</div>
{/* Content */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{!isRead && <span style={{
width: 7, height: 7, borderRadius: '50%',
background: '#6366f1', flexShrink: 0,
boxShadow: '0 0 6px rgba(99,102,241,0.4)',
}} />}
<span style={{ fontWeight: 600, fontSize: 14, color: '#1e293b' }}>{n.title}</span>
</div>
<span style={{ fontSize: 11, color: '#94a3b8', flexShrink: 0 }}>
{n.createdAt ? n.createdAt.replace('T', ' ').slice(0, 16) : ''}
</span>
</div>
<div style={{
fontSize: 13, color: '#64748b', lineHeight: 1.7,
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
}}>{n.content}</div>
<Tag color={tc.color} style={{ marginTop: 8, borderRadius: 6, border: 'none', fontSize: 11, padding: '1px 8px' }}>
{tc.label}
</Tag>
</div>
</div>
</div>
);
})
)}
</div>
</Modal>
<NotificationPopup />
</Layout>
);
};
export default AppLayout;
@@ -0,0 +1,174 @@
import React, { useEffect, useState, useCallback } from 'react';
import { Tag, Typography, Button } from 'antd';
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
import { getNotifications, markNotificationRead } from '../api';
interface Notification {
id: string;
title: string;
content: string;
type: string;
isRead: boolean;
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 NotificationPopup: React.FC = () => {
const [visible, setVisible] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const fetchNotifications = useCallback(async () => {
try {
const data = await getNotifications();
const unread = data.filter((n: Notification) => !n.isRead);
setNotifications(unread);
if (unread.length > 0 && !visible) {
setVisible(true);
setCurrentIndex(0);
}
} catch { /* ignore */ }
}, [visible]);
useEffect(() => {
fetchNotifications();
const timer = setInterval(fetchNotifications, 30000);
return () => clearInterval(timer);
}, []);
const handleAcknowledge = async () => {
const current = notifications[currentIndex];
if (current) {
try { await markNotificationRead(current.id); } catch { /* ignore */ }
}
if (currentIndex < notifications.length - 1) {
setCurrentIndex(currentIndex + 1);
} else {
setVisible(false);
setNotifications([]);
setCurrentIndex(0);
}
};
const handleClose = async () => {
const current = notifications[currentIndex];
if (current) {
try { await markNotificationRead(current.id); } catch { /* ignore */ }
}
setVisible(false);
setNotifications([]);
setCurrentIndex(0);
};
if (!visible || notifications.length === 0) return null;
const current = notifications[currentIndex];
const tc = typeConfig[current.type] || typeConfig.system;
return (
<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)',
}} 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)',
}}>
{/* Header */}
<div style={{
background: tc.gradient,
padding: '28px 28px 24px',
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={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<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',
}}>{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>
</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)'; }}
>
<CloseOutlined style={{ color: '#fff', fontSize: 12 }} />
</div>
</div>
</div>
{/* Body */}
<div style={{ background: '#fff', padding: '24px 28px 28px' }}>
<div style={{
padding: '18px 20px', background: '#f8fafc', borderRadius: 14,
marginBottom: 20, border: '1px solid #f0f0f5',
}}>
<div style={{ fontSize: 14, color: '#334155', lineHeight: 1.8 }}>
{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 }}>
{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>
</div>
{/* Dots */}
{notifications.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 18 }}>
{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',
}} />
))}
</div>
)}
</div>
</div>
<style>{`
@keyframes slideUp {
from { opacity: 0; transform: translateY(24px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
`}</style>
</div>
);
};
export default NotificationPopup;
@@ -0,0 +1,116 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Typography } from 'antd';
interface SliderCaptchaProps {
onVerify: (x: number) => Promise<boolean>;
onSuccess: () => void;
width?: number;
}
const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onSuccess, width = 320 }) => {
const [dragging, setDragging] = useState(false);
const [x, setX] = useState(0);
const [verified, setVerified] = useState(false);
const [failed, setFailed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const sliderW = 44;
const maxDrag = width - sliderW;
const handleMove = useCallback((clientX: number) => {
if (!containerRef.current || !dragging) return;
const rect = containerRef.current.getBoundingClientRect();
const raw = clientX - rect.left - sliderW / 2;
setX(Math.max(0, Math.min(raw, maxDrag)));
}, [dragging, maxDrag]);
const handleEnd = useCallback(async () => {
if (!dragging) return;
setDragging(false);
const ratio = x / maxDrag;
const pixelX = Math.round(ratio * 260);
const ok = await onVerify(pixelX);
if (ok) {
setVerified(true);
onSuccess();
} else {
setFailed(true);
setX(0);
setTimeout(() => setFailed(false), 600);
}
}, [dragging, x, maxDrag, onVerify, onSuccess]);
useEffect(() => {
if (!dragging) return;
const onMouseMove = (e: MouseEvent) => handleMove(e.clientX);
const onTouchMove = (e: TouchEvent) => handleMove(e.touches[0].clientX);
const onUp = () => handleEnd();
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onUp);
window.addEventListener('touchmove', onTouchMove);
window.addEventListener('touchend', onUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onUp);
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', onUp);
};
}, [dragging, handleMove, handleEnd]);
return (
<div ref={containerRef} style={{
width, height: 44, borderRadius: 8, position: 'relative', userSelect: 'none',
background: verified ? '#ecfdf5' : failed ? '#fef2f2' : '#f1f5f9',
border: `1px solid ${verified ? '#10b981' : failed ? '#ef4444' : '#e2e8f0'}`,
transition: 'all 0.3s',
overflow: 'hidden',
}}>
{/* Track text */}
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: verified ? '#10b981' : failed ? '#ef4444' : '#94a3b8',
fontSize: 13, fontWeight: 500,
transition: 'color 0.3s',
}}>
{verified ? '验证通过' : failed ? '验证失败,请重试' : '请按住滑块,拖动到最右边'}
</div>
{/* Slider */}
<div
onMouseDown={() => !verified && setDragging(true)}
onTouchStart={() => !verified && setDragging(true)}
style={{
position: 'absolute', left: x, top: 0,
width: sliderW, height: '100%',
borderRadius: 8,
background: verified
? 'linear-gradient(135deg, #10b981, #059669)'
: failed
? 'linear-gradient(135deg, #ef4444, #dc2626)'
: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: verified ? 'default' : 'grab',
boxShadow: dragging ? '0 4px 12px rgba(99,102,241,0.3)' : 'none',
transition: dragging ? 'none' : 'left 0.3s ease, background 0.3s',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6 3l5 5-5 5" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{/* Progress fill */}
{!verified && (
<div style={{
position: 'absolute', left: 0, top: 0, bottom: 0,
width: x + sliderW / 2,
background: failed ? 'rgba(239,68,68,0.06)' : 'rgba(99,102,241,0.06)',
borderRadius: '8px 0 0 8px',
transition: dragging ? 'none' : 'width 0.3s',
}} />
)}
</div>
);
};
export default SliderCaptcha;
+220
View File
@@ -0,0 +1,220 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
*,
*::before,
*::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f5f6fa;
color: #1a1a2e;
}
#root { min-height: 100vh; }
/* ── Scrollbar ─────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #9ca3af; }
/* ── Keyframe Animations ───────────────── */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInLeft {
from { opacity: 0; transform: translateX(-20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes fadeInRight {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes float1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(20px, -30px) scale(1.05); }
}
@keyframes float2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-15px, 20px) scale(0.95); }
}
@keyframes float3 {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
50% { transform: translate(10px, -15px) rotate(3deg); }
}
@keyframes slideInCard {
from { opacity: 0; transform: translateY(14px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes spinSlow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-fadeInUp { animation: fadeInUp 0.45s ease-out both; }
.animate-fadeInLeft { animation: fadeInLeft 0.55s ease-out both; }
.animate-fadeInRight { animation: fadeInRight 0.55s ease-out both; }
.animate-slideInCard { animation: slideInCard 0.4s ease-out both; }
.animate-float1 { animation: float1 8s ease-in-out infinite; }
.animate-float2 { animation: float2 10s ease-in-out infinite; }
.animate-float3 { animation: float3 12s ease-in-out infinite; }
.ref-thumb:hover .ref-delete { opacity: 1 !important; }
.stagger-children > *:nth-child(1) { animation-delay: 0.04s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.10s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.16s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.22s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.28s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.34s; }
/* ── Ant Design Overrides ──────────────── */
.ant-input, .ant-input-affix-wrapper, .ant-select-selector, .ant-input-number {
border-radius: 10px !important;
transition: all 0.25s ease !important;
}
.ant-input:focus, .ant-input-affix-wrapper:focus, .ant-input-affix-wrapper-focused,
.ant-select-focused .ant-select-selector {
box-shadow: 0 0 0 3px rgba(99,102,241,0.12) !important;
border-color: #6366f1 !important;
}
.ant-btn {
border-radius: 10px !important;
transition: all 0.25s ease !important;
font-weight: 500 !important;
}
.ant-btn:hover { transform: none; }
.ant-btn:active { transform: translateY(0); }
.ant-card { border-radius: 16px !important; transition: all 0.3s ease !important; }
.ant-modal-content { border-radius: 20px !important; overflow: hidden; }
.ant-tag { font-size: 12px; border-radius: 8px !important; }
/* ── Recharge card hover ───────────────── */
.recharge-card {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important;
cursor: pointer;
}
.recharge-card:hover {
transform: translateY(-8px) scale(1.02) !important;
box-shadow: 0 20px 40px rgba(99,102,241,0.15) !important;
}
.recharge-card .recharge-price { transition: all 0.3s ease; }
.recharge-card:hover .recharge-price { transform: scale(1.08); }
/* ── Project card hover ────────────────── */
.project-card {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important;
position: relative;
overflow: visible;
}
.project-card:hover {
transform: translateY(-4px) !important;
box-shadow: 0 16px 40px rgba(99,102,241,0.12) !important;
border-color: #6366f1 !important;
}
/* ── Mobile Bottom Nav ─────────────────── */
.mobile-bottom-nav {
display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 200;
height: 64px;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(20px);
border-top: 1px solid rgba(0,0,0,0.06);
box-shadow: 0 -2px 12px rgba(0,0,0,0.06);
}
.mobile-bottom-nav .nav-item {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 3px;
cursor: pointer; transition: all 0.2s;
color: #94a3b8; font-size: 10px; font-weight: 500;
}
.mobile-bottom-nav .nav-item.active {
color: #6366f1;
}
.mobile-bottom-nav .nav-item .nav-icon {
font-size: 20px; transition: transform 0.2s;
}
.mobile-bottom-nav .nav-item.active .nav-icon {
transform: scale(1.1);
}
/* ── Responsive Breakpoints ────────────── */
@media (max-width: 768px) {
.desktop-sidebar { display: none !important; }
.desktop-content { margin-left: 0 !important; padding: 16px !important; padding-bottom: 80px !important; overflow-x: hidden !important; }
.mobile-bottom-nav { display: flex !important; }
/* Prevent horizontal overflow globally */
html, body { overflow-x: hidden !important; }
.mobile-hide { display: none !important; }
.mobile-full { width: 100% !important; max-width: 100% !important; }
.mobile-stack { flex-direction: column !important; }
.mobile-p-16 { padding: 16px !important; }
.mobile-gap-12 { gap: 12px !important; }
/* Login page */
.login-page { flex-direction: column !important; }
.login-left { display: none !important; }
.login-right { padding: 24px 16px !important; }
.login-card { width: 100% !important; }
/* Project list */
.project-item { flex-direction: column !important; align-items: flex-start !important; }
.project-item .project-actions { margin-left: 0 !important; margin-top: 12px; width: 100%; justify-content: flex-end; }
/* Generate page header */
.gen-header { flex-direction: column !important; gap: 12px !important; padding: 16px 18px !important; }
/* Operation flow bar */
.gen-flow-bar { flex-direction: column !important; gap: 14px !important; padding: 16px !important; }
.gen-flow-divider { display: none !important; }
/* Config form params row */
.gen-form-row { flex-direction: column !important; gap: 0 !important; }
/* Credits estimate bar */
.gen-credits-bar { flex-direction: column !important; gap: 8px !important; text-align: center; }
/* Prompt rows */
.gen-prompt-row { flex-direction: column !important; }
.gen-prompt-row > * { width: 100% !important; }
.gen-params { flex-wrap: wrap !important; gap: 8px !important; }
.gen-params > div { flex: 1 1 45% !important; min-width: 0; }
/* Expanded detail left+right stacks */
.gen-detail-row { flex-direction: column !important; }
.gen-detail-row > * { width: 100% !important; min-width: 0 !important; flex: 1 1 auto !important; }
/* Record filter bar stacks */
.record-filter-bar { flex-direction: column !important; }
.record-filter-bar .ant-space { flex-wrap: wrap !important; }
.record-filter-bar .ant-select { width: 100% !important; }
/* Record summary row stacks */
.record-item { flex-direction: column !important; align-items: flex-start !important; gap: 8px !important; overflow: hidden !important; }
.record-item .record-actions { width: 100% !important; justify-content: flex-end; }
.record-item > span[style*="textOverflow"] { white-space: normal !important; overflow: visible !important; text-overflow: unset !important; }
/* Hide less important meta on mobile */
.mobile-meta { display: none !important; }
/* Credits page */
.credits-balance { flex-direction: column !important; }
.recharge-grid { flex-direction: column !important; }
.recharge-grid > * { width: 100% !important; }
/* Video player fits mobile */
video { max-width: 100% !important; }
}
/* ── Date Display ──────────────────────── */
.date-display { font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; }
@media (max-width: 480px) {
.login-card { width: 100% !important; }
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(<App />)
+140
View File
@@ -0,0 +1,140 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Col, Row, Space, Table, Tag, Typography,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { getCredits } from '../api';
import { useAuthStore } from '../store/useAuthStore';
import { formatDate } from '../utils/formatDate';
import type { CreditRecord } from '../types';
const AnimatedNumber: React.FC<{ value: number; duration?: number }> = ({ value, duration = 1200 }) => {
const [display, setDisplay] = useState(0);
const ref = useRef<number>(0);
useEffect(() => {
const start = display;
const diff = value - start;
if (diff === 0) return;
const startTime = performance.now();
const animate = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setDisplay(Math.round(start + diff * eased));
if (progress < 1) ref.current = requestAnimationFrame(animate);
};
ref.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(ref.current);
}, [value]);
return <>{display.toLocaleString()}</>;
};
const CreditsPage: React.FC = () => {
const { user } = useAuthStore();
const [records, setRecords] = useState<CreditRecord[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetch = async () => {
setLoading(true);
const data = await getCredits();
setRecords(data.records);
setLoading(false);
};
fetch();
}, []);
const totalRecharge = records.filter((r) => r.type === 'recharge').reduce((sum, r) => sum + r.amount, 0);
const totalConsume = records.filter((r) => r.type === 'consume').reduce((sum, r) => sum + Math.abs(r.amount), 0);
const columns = [
{ title: '类型', dataIndex: 'type', key: 'type', width: 100,
render: (type: string) => (
<Tag color={type === 'recharge' ? 'green' : 'orange'} icon={type === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>
{type === 'recharge' ? '充值' : '消费'}
</Tag>
),
},
{ title: '积分变动', dataIndex: 'amount', key: 'amount', width: 120,
render: (amount: number) => (
<Typography.Text strong style={{ color: amount > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{amount > 0 ? '+' : ''}{amount}
</Typography.Text>
),
},
{ title: '说明', dataIndex: 'description', key: 'description' },
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
render: (d: string) => <span className="date-display" translate="no" style={{ color: '#94a3b8' }}>{formatDate(d)}</span>,
},
];
return (
<div>
{/* Balance cards */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<div className="animate-fadeInUp" style={{
borderRadius: 16, padding: 24,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
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: 'relative' }}>
<Space style={{ marginBottom: 10 }}>
<WalletOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />
<span style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}></span>
</Space>
<div style={{ color: '#fff', fontSize: 38, fontWeight: 800, lineHeight: 1 }}>
<AnimatedNumber value={user?.credits ?? 0} />
</div>
</div>
</div>
</Col>
<Col xs={12} sm={8}>
<div className="animate-fadeInUp" style={{
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5', animationDelay: '0.06s',
}}>
<Space style={{ marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(16,185,129,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowUpOutlined style={{ color: '#10b981', fontSize: 14 }} />
</div>
<span style={{ color: '#94a3b8', fontSize: 14 }}></span>
</Space>
<div style={{ color: '#10b981', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalRecharge} /></div>
</div>
</Col>
<Col xs={12} sm={8}>
<div className="animate-fadeInUp" style={{
borderRadius: 16, padding: 24, background: '#fff', border: '1px solid #f0f0f5', animationDelay: '0.12s',
}}>
<Space style={{ marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(239,68,68,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ArrowDownOutlined style={{ color: '#ef4444', fontSize: 14 }} />
</div>
<span style={{ color: '#94a3b8', fontSize: 14 }}></span>
</Space>
<div style={{ color: '#ef4444', fontSize: 28, fontWeight: 800 }}><AnimatedNumber value={totalConsume} /></div>
</div>
</Col>
</Row>
{/* Records */}
<div className="animate-fadeInUp" style={{ animationDelay: '0.15s' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<ThunderboltOutlined style={{ color: '#6366f1', fontSize: 16 }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</div>
<div style={{ borderRadius: 16, background: '#fff', border: '1px solid #f0f0f5', overflow: 'hidden' }}>
<Table columns={columns} dataSource={records} rowKey="id" loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
scroll={{ x: 500 }} />
</div>
</div>
</div>
);
};
export default CreditsPage;
File diff suppressed because it is too large Load Diff
+390
View File
@@ -0,0 +1,390 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
import {
LockOutlined, ThunderboltOutlined,
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
MobileOutlined, SafetyOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { sendSms, getSiteInfo, register as registerApi } from '../api';
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'password' | 'phone' | 'register'>('password');
const [tab, setTab] = useState<'password' | 'phone'>('password');
const [countdown, setCountdown] = useState(0);
const [regCountdown, setRegCountdown] = useState(0);
const [agreed, setAgreed] = useState(false);
const [siteName, setSiteName] = useState('VideoGen.AI');
const [siteLogo, setSiteLogo] = useState('');
const [agreementUrl, setAgreementUrl] = useState('');
const [policyUrl, setPolicyUrl] = useState('');
const navigate = useNavigate();
const { login } = useAuthStore();
const [pwdForm] = Form.useForm();
const [phoneForm] = Form.useForm();
const [regForm] = Form.useForm();
useEffect(() => {
getSiteInfo().then(info => {
setSiteName(info.siteName);
setSiteLogo(info.siteLogo);
setAgreementUrl(info.userAgreementUrl);
setPolicyUrl(info.privacyPolicyUrl);
}).catch(() => {});
}, []);
const checkAgreed = (): boolean => {
if (!agreed) {
message.warning('请先阅读并同意用户协议和隐私政策');
return false;
}
return true;
};
const handlePasswordLogin = async () => {
if (!checkAgreed()) return;
try {
const values = await pwdForm.validateFields();
setLoading(true);
await login(values.phone, values.password, undefined, values.rememberMe);
message.success('登录成功,欢迎回来');
navigate('/projects');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
const handlePhoneLogin = async () => {
if (!checkAgreed()) return;
try {
const values = phoneForm.getFieldsValue();
if (!values.phone || !values.code) {
message.error('请填写手机号和验证码');
return;
}
setLoading(true);
await login(values.phone, values.code);
message.success('登录成功,欢迎回来');
navigate('/projects');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
if (!checkAgreed()) return;
try {
const values = await regForm.validateFields();
setLoading(true);
await registerApi(values.phone, values.regCode, values.password);
message.success('注册成功');
navigate('/projects');
} catch {
message.error('注册失败');
} finally {
setLoading(false);
}
};
const startCountdown = (setter: React.Dispatch<React.SetStateAction<number>>) => {
setter(60);
const timer = setInterval(() => {
setter((c) => { if (c <= 1) { clearInterval(timer); return 0; } return c - 1; });
}, 1000);
};
const handleSendCode = async (phone: string, isReg?: boolean) => {
try {
if (!phone || !/^1\d{10}$/.test(phone)) {
message.error('请输入正确的手机号');
return;
}
await sendSms(phone);
startCountdown(isReg ? setRegCountdown : setCountdown);
message.success('验证码已发送');
} catch {
message.error('发送失败');
}
};
const switchTab = (t: 'password' | 'phone') => {
setTab(t);
setMode(t);
};
const features = [
{ icon: <BulbOutlined />, title: 'AI 智能优化', desc: '输入原始提示词,AI 自动为您生成专业级视频描述' },
{ icon: <PlayCircleOutlined />, title: '一键生成视频', desc: '支持多种画质与时长选择,最快 30 秒出片' },
{ icon: <HistoryOutlined />, title: '项目维度管理', desc: '按项目组织视频,支持多种行业模板' },
];
const inputStyle: React.CSSProperties = {
background: '#fff',
border: '1.5px solid #e2e8f0',
color: '#1e293b',
height: 48,
borderRadius: 10,
fontSize: 14,
};
const openPdf = (url: string) => {
if (url) window.open(`${API_BASE.replace(/\/api$/, '')}${url}`, '_blank');
};
return (
<div style={{
minHeight: '100vh',
display: 'flex',
background: 'linear-gradient(135deg, #f0f4ff 0%, #e8ecf8 40%, #f0f0ff 70%, #f8f9ff 100%)',
position: 'relative',
overflow: 'hidden',
}}>
<div style={{
position: 'absolute',
width: 500, height: 500, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
top: -150, right: -100, filter: 'blur(40px)',
}} />
<div style={{
position: 'absolute',
width: 400, height: 400, borderRadius: '50%',
background: 'radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%)',
bottom: -100, left: -80, filter: 'blur(50px)',
}} />
{/* Left side - features */}
<div style={{
flex: 1, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '0 80px', zIndex: 1,
}}>
<Space direction="vertical" size={36}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 52, height: 52, borderRadius: 14, objectFit: 'contain' }} />
) : (
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}>
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
)}
<span style={{ color: '#1e293b', fontSize: 28, fontWeight: 800, letterSpacing: -0.5 }}>
{siteName}
</span>
</div>
<Typography.Paragraph style={{ color: '#64748b', fontSize: 17, maxWidth: 480, lineHeight: 1.8 }}>
AI <br />
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{features.map((f, i) => (
<div key={i} style={{
display: 'flex', gap: 16, alignItems: 'flex-start',
padding: '18px 22px', borderRadius: 14,
background: 'rgba(255,255,255,0.7)',
border: '1px solid rgba(99,102,241,0.1)',
backdropFilter: 'blur(10px)',
}}>
<div style={{
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6366f1', fontSize: 20,
}}>{f.icon}</div>
<div>
<Typography.Text style={{ color: '#1e293b', fontSize: 15, fontWeight: 600, display: 'block', marginBottom: 4 }}>{f.title}</Typography.Text>
<Typography.Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>{f.desc}</Typography.Text>
</div>
</div>
))}
</div>
</Space>
</div>
{/* Right side - login/register form */}
<div style={{
flex: 1, display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1, padding: '40px 24px',
}}>
<Card style={{
width: 440, maxWidth: '100%', borderRadius: 20,
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
border: '1px solid #e2e8f0', background: '#fff',
}} styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 6, color: '#1e293b', fontWeight: 700 }}>
{mode === 'register' ? '创建账号' : '欢迎回来'}
</Typography.Title>
<Typography.Text style={{ display: 'block', textAlign: 'center', marginBottom: 28, color: '#94a3b8', fontSize: 14 }}>
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{/* Tab switcher - only for login modes */}
{mode !== 'register' && (
<div style={{
display: 'flex', gap: 0, marginBottom: 24,
background: '#f1f5f9', borderRadius: 10, padding: 4,
border: '1px solid #e2e8f0',
}}>
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)} style={{
flex: 1, textAlign: 'center', padding: '10px 0',
borderRadius: 8, cursor: 'pointer', fontSize: 14,
fontWeight: tab === t ? 600 : 400,
color: tab === t ? '#6366f1' : '#64748b',
background: tab === t ? '#fff' : 'transparent',
border: tab === t ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
boxShadow: tab === t ? '0 2px 8px rgba(99,102,241,0.1)' : 'none',
transition: 'all 0.3s ease',
}}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{/* Password Login */}
{mode === 'password' && (
<Form form={pwdForm} onFinish={handlePasswordLogin} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" style={inputStyle} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入密码" style={inputStyle} />
</Form.Item>
<Form.Item name="rememberMe" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox></Checkbox>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
</Form.Item>
</Form>
)}
{/* Phone Login */}
{mode === 'phone' && (
<Form form={phoneForm} onFinish={handlePhoneLogin} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入验证码" maxLength={6}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={countdown > 0}
onClick={() => handleSendCode(phoneForm.getFieldValue('phone'))}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: countdown > 0 ? '#f1f5f9' : 'rgba(99,102,241,0.1)',
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: countdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
</Form.Item>
</Form>
)}
{/* Register */}
{mode === 'register' && (
<Form form={regForm} onFinish={handleRegister} size="large" layout="vertical">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入手机号" maxLength={11} style={inputStyle} />
</Form.Item>
<Form.Item name="regCode" rules={[{ required: true, message: '请输入验证码' }]}>
<Space.Compact style={{ width: '100%' }}>
<Input prefix={<SafetyOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请输入验证码" maxLength={6}
style={{ ...inputStyle, borderRadius: '10px 0 0 10px', flex: 1 }} />
<Button disabled={regCountdown > 0}
onClick={() => handleSendCode(regForm.getFieldValue('phone'), true)}
style={{
height: 48, borderRadius: '0 10px 10px 0',
background: regCountdown > 0 ? '#f1f5f9' : 'rgba(99,102,241,0.1)',
border: '1.5px solid #e2e8f0', borderLeft: 'none',
color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
fontWeight: 600, minWidth: 100,
}}>
{regCountdown > 0 ? `${regCountdown}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请设置密码' }, { min: 6, message: '密码至少6位' }]}>
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
}}> </Button>
</Form.Item>
</Form>
)}
{/* Agreement checkbox */}
<div style={{ marginBottom: 16 }}>
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
<span style={{ fontSize: 13, color: '#64748b' }}>
<span
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
></span>
<span
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
style={{ color: '#6366f1', cursor: 'pointer' }}
></span>
</span>
</Checkbox>
</div>
{/* Bottom left: switch between login and register */}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{mode === 'register' ? (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
onClick={() => { setMode('password'); setTab('password'); }}
>
</Typography.Text>
) : (
<Typography.Text
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
onClick={() => { setMode('register'); }}
>
</Typography.Text>
)}
</div>
</Card>
</div>
</div>
);
};
export default LoginPage;
+193
View File
@@ -0,0 +1,193 @@
import React, { useEffect, useState } from 'react';
import {
Button, Empty, Form, Input, message, Modal,
Popconfirm, Select, Space, Tag, Typography,
} from 'antd';
import {
PlusOutlined, DeleteOutlined, PlayCircleOutlined,
FolderOpenOutlined, CalendarOutlined, AppstoreOutlined, RightOutlined,
ShoppingCartOutlined, BookOutlined, HomeOutlined, FireOutlined, RocketOutlined,
SkinOutlined, CompassOutlined, HeartOutlined, CarOutlined, CameraOutlined,
CloudOutlined, StarOutlined, TrophyOutlined, ThunderboltOutlined, BulbOutlined,
CoffeeOutlined, CrownOutlined, DashboardOutlined, FlagOutlined, GlobalOutlined,
GiftOutlined, LaptopOutlined, MobileOutlined, MonitorOutlined, PayCircleOutlined,
PictureOutlined, SafetyOutlined, ShoppingOutlined, SmileOutlined, SoundOutlined,
TagOutlined, TeamOutlined, ToolOutlined, TruckOutlined, VideoCameraOutlined,
WalletOutlined, BankOutlined, BuildOutlined, ExperimentOutlined, HighlightOutlined,
IdcardOutlined, MedicineBoxOutlined, ReadOutlined, RestOutlined, SketchOutlined,
SolutionOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAppStore } from '../store/useAppStore';
import { getIndustries } from '../api';
import type { IndustryConfig } from '../types';
import { formatDate } from '../utils/formatDate';
const defaultColors = ['#f97316', '#3b82f6', '#8b5cf6', '#10b981', '#eab308', '#6366f1', '#ef4444', '#06b6d4', '#64748b', '#78716c'];
const iconMap: Record<string, React.ReactNode> = {
ShoppingCartOutlined: <ShoppingCartOutlined />,
BookOutlined: <BookOutlined />,
HomeOutlined: <HomeOutlined />,
FireOutlined: <FireOutlined />,
RocketOutlined: <RocketOutlined />,
SkinOutlined: <SkinOutlined />,
CompassOutlined: <CompassOutlined />,
HeartOutlined: <HeartOutlined />,
CarOutlined: <CarOutlined />,
CameraOutlined: <CameraOutlined />,
CloudOutlined: <CloudOutlined />,
StarOutlined: <StarOutlined />,
TrophyOutlined: <TrophyOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
BulbOutlined: <BulbOutlined />,
CoffeeOutlined: <CoffeeOutlined />,
CrownOutlined: <CrownOutlined />,
DashboardOutlined: <DashboardOutlined />,
FlagOutlined: <FlagOutlined />,
GlobalOutlined: <GlobalOutlined />,
GiftOutlined: <GiftOutlined />,
LaptopOutlined: <LaptopOutlined />,
MobileOutlined: <MobileOutlined />,
MonitorOutlined: <MonitorOutlined />,
PayCircleOutlined: <PayCircleOutlined />,
PictureOutlined: <PictureOutlined />,
PlayCircleOutlined: <PlayCircleOutlined />,
SafetyOutlined: <SafetyOutlined />,
ShoppingOutlined: <ShoppingOutlined />,
SmileOutlined: <SmileOutlined />,
SoundOutlined: <SoundOutlined />,
TagOutlined: <TagOutlined />,
TeamOutlined: <TeamOutlined />,
ToolOutlined: <ToolOutlined />,
TruckOutlined: <TruckOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
WalletOutlined: <WalletOutlined />,
BankOutlined: <BankOutlined />,
BuildOutlined: <BuildOutlined />,
ExperimentOutlined: <ExperimentOutlined />,
HighlightOutlined: <HighlightOutlined />,
IdcardOutlined: <IdcardOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
ReadOutlined: <ReadOutlined />,
RestOutlined: <RestOutlined />,
SketchOutlined: <SketchOutlined />,
SolutionOutlined: <SolutionOutlined />,
};
const ProjectsPage: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const navigate = useNavigate();
const { projects, fetchProjects, createProject, deleteProject } = useAppStore();
const [industries, setIndustries] = useState<IndustryConfig[]>([]);
useEffect(() => { fetchProjects(); getIndustries().then(setIndustries).catch(() => {}); }, []);
const industryMap = Object.fromEntries(industries.map((ind, i) => [ind.key, { label: ind.label, color: defaultColors[i % defaultColors.length], icon: ind.icon || 'AppstoreOutlined' }]));
const handleCreate = async () => {
try {
const values = await form.validateFields();
const project = await createProject(values.name, values.industry);
message.success('项目创建成功');
setModalOpen(false); form.resetFields();
navigate(`/projects/${project.id}/generate`);
} catch { /* */ }
};
return (
<div>
{/* Header banner */}
<div className="animate-fadeInUp" style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 24, padding: '22px 24px', borderRadius: 16,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
position: 'relative', overflow: 'hidden', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ position: 'absolute', right: -20, top: -20, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.08)' }} />
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Title level={3} style={{ margin: '0 0 4px', color: '#fff', fontWeight: 700 }}></Typography.Title>
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>
{projects.length} ·
</Typography.Text>
</div>
<Button icon={<PlusOutlined />} onClick={() => setModalOpen(true)} size="large" style={{
background: 'rgba(255,255,255,0.15)', border: '1px solid rgba(255,255,255,0.3)',
color: '#fff', fontWeight: 600, backdropFilter: 'blur(10px)', borderRadius: 10, height: 42,
}}></Button>
</div>
{projects.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有项目,创建您的第一个视频项目" style={{ padding: '60px 0' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} size="large"></Button>
</Empty>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }} className="stagger-children">
{projects.map((project, i) => (
<div key={project.id} className="project-card project-item animate-slideInCard" onClick={() => navigate(`/projects/${project.id}/generate`)}
style={{
display: 'flex', alignItems: 'center', padding: '18px 22px', borderRadius: 16,
background: '#fff', border: '1px solid #f0f0f5', cursor: 'pointer',
animationDelay: `${i * 0.06}s`,
}}>
<div style={{
width: 50, height: 50, borderRadius: 14, flexShrink: 0,
background: `${industryMap[project.industry]?.color || '#64748b'}12`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24, marginRight: 16, color: industryMap[project.industry]?.color || '#64748b',
}}>{iconMap[industryMap[project.industry]?.icon] || <AppstoreOutlined />}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4, flexWrap: 'wrap' }}>
<Typography.Text strong style={{ fontSize: 15, color: '#1a1a2e' }}>{project.name}</Typography.Text>
<Tag color={industryMap[project.industry]?.color || '#64748b'} style={{ borderRadius: 6, margin: 0 }}>{industryMap[project.industry]?.label || project.industry}</Tag>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<CalendarOutlined style={{ fontSize: 12, color: '#94a3b8' }} />
<span className="date-display" translate="no" style={{ fontSize: 12, color: '#94a3b8' }}> {formatDate(project.createdAt)}</span>
</div>
</div>
<div className="project-actions" style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 16 }}>
<Button type="primary" icon={<PlayCircleOutlined />}
onClick={(e) => { e.stopPropagation(); navigate(`/projects/${project.id}/generate`); }}
style={{
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none',
borderRadius: 10, fontWeight: 600, height: 36,
boxShadow: '0 4px 12px rgba(99,102,241,0.25)',
}}></Button>
<Popconfirm title="确定删除该项目?" description="项目下的生成记录将一并删除"
onConfirm={async (e) => { e?.stopPropagation(); await deleteProject(project.id); message.success('项目已删除'); }}
onCancel={(e) => e?.stopPropagation()}>
<Button danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()}
style={{ borderRadius: 10, height: 36, background: 'rgba(239,68,68,0.06)', border: '1px solid rgba(239,68,68,0.15)', color: '#ef4444' }} />
</Popconfirm>
<RightOutlined className="mobile-hide" style={{ color: '#cbd5e1', fontSize: 12 }} />
</div>
</div>
))}
</div>
)}
<Modal title={<Space><AppstoreOutlined /></Space>} open={modalOpen} onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
okText="创建" cancelText="取消" width={480}>
<Form form={form} layout="vertical" style={{ marginTop: 20 }}>
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请输入项目名称' }]}>
<Input placeholder="例如:618电商大促宣传片" prefix={<FolderOpenOutlined />} size="large" />
</Form.Item>
<Form.Item name="industry" label="所属行业" rules={[{ required: true, message: '请选择行业' }]}>
<Select placeholder="选择项目所属行业" size="large">
{industries.map((ind, i) => (
<Select.Option key={ind.key} value={ind.key}><Space>{iconMap[ind.icon] || <AppstoreOutlined />} {ind.label}</Space></Select.Option>
))}
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ProjectsPage;
+461
View File
@@ -0,0 +1,461 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Empty,
Input,
message,
Modal,
Select,
Space,
Tag,
Tooltip,
Typography,
} from 'antd';
import {
PlayCircleOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
LoadingOutlined,
CopyOutlined,
FilterOutlined,
CloseCircleOutlined,
EditOutlined,
CaretRightOutlined,
CaretDownOutlined,
VideoCameraOutlined,
DownloadOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { useAppStore } from '../store/useAppStore';
import type { GenerationStatus, AspectRatio, Resolution } from '../types';
import { formatDate } from '../utils/formatDate';
const statusConfig: Record<GenerationStatus, { color: string; text: string; icon: React.ReactNode }> = {
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
prompt_optimized: { color: 'processing', text: '待生成', icon: <ClockCircleOutlined /> },
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
};
const RecordsPage: React.FC = () => {
const { records, projects, fetchRecords, fetchProjects, generateVideo } = useAppStore();
const [generating, setGenerating] = useState<Record<string, boolean>>({});
const [filterProject, setFilterProject] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [editablePrompts, setEditablePrompts] = useState<Record<string, string>>({});
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
// Generate modal for param selection
const [genModal, setGenModal] = useState<{ recordId: string; projectName: string; ratio: AspectRatio; resolution: Resolution } | null>(null);
useEffect(() => { fetchRecords(); fetchProjects(); }, []);
const handleGenerate = async () => {
if (!genModal) return;
const { recordId, projectName, ratio, resolution } = genModal;
setGenerating((p) => ({ ...p, [recordId]: true }));
setGenModal(null);
message.loading({ content: `${projectName}」正在生成视频...`, duration: 0, key: recordId });
try {
await generateVideo(recordId, { aspectRatio: ratio, resolution });
message.success({ content: `${projectName}」视频生成成功!`, key: recordId, duration: 3 });
} catch {
message.error({ content: `${projectName}」视频生成失败`, key: recordId, duration: 3 });
} finally {
setGenerating((p) => ({ ...p, [recordId]: false }));
}
};
const openGenModal = (record: any) => {
setGenModal({
recordId: record.id,
projectName: record.projectName,
ratio: (record.aspectRatio as AspectRatio) || '16:9',
resolution: (record.resolution as Resolution) || '720p',
});
};
const filtered = records.filter((r) => {
if (filterProject && r.projectId !== filterProject) return false;
if (filterStatus && r.status !== filterStatus) return false;
return true;
});
return (
<div>
<div style={{ marginBottom: 20 }}>
<Typography.Text style={{ fontSize: 14, color: '#94a3b8' }}>
</Typography.Text>
</div>
{/* Filters */}
<div className="record-filter-bar animate-fadeInUp" style={{
display: 'flex', gap: 12, marginBottom: 20,
padding: '14px 20px', borderRadius: 14,
background: '#fff', border: '1px solid #f0f0f5',
}}>
<Space>
<FilterOutlined style={{ color: '#94a3b8' }} />
<Select placeholder="全部项目" allowClear style={{ width: 200 }}
onChange={(v) => setFilterProject(v)}>
{projects.map((p) => <Select.Option key={p.id} value={p.id}>{p.name}</Select.Option>)}
</Select>
<Select placeholder="全部状态" allowClear style={{ width: 140 }}
onChange={(v) => setFilterStatus(v)}>
<Select.Option value="prompt_optimized"></Select.Option>
<Select.Option value="generating"></Select.Option>
<Select.Option value="completed"></Select.Option>
<Select.Option value="failed"></Select.Option>
</Select>
</Space>
<Typography.Text style={{ marginLeft: 'auto', lineHeight: '32px', color: '#94a3b8', fontSize: 13 }}>
{filtered.length}
</Typography.Text>
</div>
{filtered.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无生成记录" style={{ padding: 60 }} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }} className="stagger-children">
{filtered.map((record, i) => {
const isEditing = editingRecordId === record.id;
const prompt = editablePrompts[record.id] ?? record.optimizedPrompt;
const isGenerating = generating[record.id];
const isExpanded = expandedId === record.id;
return (
<div key={record.id} className="animate-slideInCard" style={{ animationDelay: `${i * 0.04}s` }}>
{/* Record summary row */}
<div
className="record-item"
onClick={() => setExpandedId(isExpanded ? null : record.id)}
style={{
padding: '14px 20px', borderRadius: isExpanded ? '14px 14px 0 0' : 14,
background: '#fff', border: '1px solid #f0f0f5',
cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', gap: 12,
}}
>
{/* Expand icon */}
{isExpanded
? <CaretDownOutlined style={{ color: '#6366f1', fontSize: 12, flexShrink: 0 }} />
: <CaretRightOutlined style={{ color: '#cbd5e1', fontSize: 12, flexShrink: 0 }} />}
{/* Status */}
<Tag color={statusConfig[record.status].color} icon={statusConfig[record.status].icon} style={{ margin: 0 }}>
{statusConfig[record.status].text}
</Tag>
{/* Project */}
<Tag style={{ background: '#f0f0ff', border: 'none', color: '#6366f1', margin: 0 }}>
{record.projectName}
</Tag>
{/* Prompt preview */}
<Typography.Text style={{
flex: 1, fontSize: 13, color: '#475569',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{prompt}
</Typography.Text>
{/* Meta */}
<span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
{record.duration ? `${record.duration}` : '-'} · {record.aspectRatio || '-'} · {record.resolution || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
</span>
{/* Quick actions */}
<div className="record-actions" onClick={(e) => e.stopPropagation()}>
<Space>
{record.status === 'prompt_optimized' && (
<Button type="primary" size="small" icon={<PlayCircleOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
}}>
</Button>
)}
{record.status === 'generating' && (
<Tag color="processing" icon={<LoadingOutlined spin />} style={{ padding: '4px 12px', fontSize: 12 }}>
...
</Tag>
)}
{record.status === 'failed' && (
<Button type="primary" danger size="small" icon={<PlayCircleOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
style={{ borderRadius: 8 }}>
</Button>
)}
</Space>
</div>
</div>
{/* Expanded detail panel */}
{isExpanded && (
<div style={{
background: '#fff',
border: '1px solid #f0f0f5',
borderTop: 'none',
borderRadius: '0 0 14px 14px',
padding: '20px 24px',
animation: 'fadeInUp 0.25s ease both',
}}>
<div className="gen-detail-row" style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
{/* Left: full info */}
<div style={{ flex: '1 1 50%', minWidth: 280 }}>
{/* Original prompt */}
<div style={{ marginBottom: 16 }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
</Typography.Text>
<div style={{
padding: 12, borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', lineHeight: 1.7 }}>
{record.originalPrompt}
</Typography.Text>
</div>
</div>
{/* Optimized prompt (editable) */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>
{record.status === 'prompt_optimized' && <span style={{ color: '#6366f1' }}></span>}
</Typography.Text>
{!isEditing && (
<Space size={4}>
<Tooltip title="复制">
<Button type="text" size="small" icon={<CopyOutlined />}
onClick={() => { navigator.clipboard.writeText(prompt); message.success('已复制'); }}
style={{ color: '#94a3b8' }} />
</Tooltip>
{record.status === 'prompt_optimized' && (
<Tooltip title="编辑">
<Button type="text" size="small" icon={<EditOutlined />}
onClick={() => {
setEditingRecordId(record.id);
setEditablePrompts((p) => ({ ...p, [record.id]: p[record.id] ?? record.optimizedPrompt }));
}}
style={{ color: '#6366f1' }} />
</Tooltip>
)}
</Space>
)}
</div>
{isEditing ? (
<div>
<Input.TextArea value={prompt}
onChange={(e) => setEditablePrompts((p) => ({ ...p, [record.id]: e.target.value }))}
rows={4}
style={{
borderRadius: 10, fontSize: 13, lineHeight: 1.7,
border: '1px solid rgba(99,102,241,0.25)',
background: 'rgba(99,102,241,0.02)',
}} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 8 }}>
<Button size="small" onClick={() => setEditingRecordId(null)}></Button>
<Button size="small" type="primary" onClick={() => {
setEditablePrompts((p) => ({ ...p, [record.id]: prompt }));
setEditingRecordId(null);
message.success('提示词已更新');
}} style={{
borderRadius: 6,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', border: 'none',
}}></Button>
</div>
</div>
) : (
<div style={{
padding: 12, borderRadius: 10,
background: 'rgba(99,102,241,0.02)', border: '1px solid rgba(99,102,241,0.1)',
}}>
<Typography.Text style={{ fontSize: 13, color: '#1a1a2e', lineHeight: 1.7 }}>
{prompt}
</Typography.Text>
</div>
)}
</div>
{/* Params */}
<div style={{
display: 'flex', gap: 16, padding: '12px 16px',
borderRadius: 10, background: '#f8f9fc',
}}>
{[
{ label: '时长', value: record.duration ? `${record.duration}` : '-' },
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '待选择' : '-') },
{ label: '分辨率', value: record.resolution || (record.status === 'prompt_optimized' ? '待选择' : '-') },
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost}` : '-', highlight: !!record.creditsCost },
].map((item, j) => (
<div key={j} style={{ flex: 1 }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>{item.label}</Typography.Text>
<Typography.Text strong style={{ fontSize: 14, color: item.highlight ? '#6366f1' : '#1a1a2e' }}>{item.value}</Typography.Text>
</div>
))}
</div>
{/* Generate button in detail */}
{record.status === 'prompt_optimized' && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" size="large" icon={<RocketOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
style={{
borderRadius: 12, fontWeight: 600, height: 44,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
}}>
</Button>
</div>
)}
{/* Failed retry */}
{record.status === 'failed' && (
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />}
loading={isGenerating}
onClick={() => openGenModal(record)}
style={{ borderRadius: 12, fontWeight: 600, height: 44 }}>
</Button>
</div>
)}
</div>
{/* Right: video preview */}
<div style={{ flex: '1 1 40%', minWidth: 240 }}>
{record.status === 'generating' ? (
<div style={{
height: '100%', minHeight: 200, borderRadius: 14,
background: 'linear-gradient(135deg, rgba(99,102,241,0.04), rgba(139,92,246,0.04))',
border: '1px dashed rgba(99,102,241,0.2)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
}}>
<LoadingOutlined style={{ color: '#6366f1', fontSize: 32 }} spin />
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}>...</Typography.Text>
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}></Typography.Text>
</div>
) : record.status === 'completed' && record.videoUrl ? (
<div style={{ borderRadius: 14, overflow: 'hidden', border: '1px solid #f0f0f5', position: 'relative' }}>
<div style={{ position: 'absolute', top: 10, right: 10, zIndex: 10 }}>
<Tooltip title="下载视频">
<Button size="small" icon={<DownloadOutlined />}
onClick={() => { const a = document.createElement('a'); a.href = `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.videoUrl}`; a.download = `${record.projectName}.mp4`; a.click(); }}
style={{ background: 'rgba(0,0,0,0.5)', border: 'none', color: '#fff', backdropFilter: 'blur(4px)', borderRadius: 8 }}>
</Button>
</Tooltip>
</div>
<div style={{ minHeight: 280, overflow: 'hidden' }}>
<video
src={`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${record.videoUrl}`}
controls
preload="metadata"
style={{ width: '100%', display: 'block' }}
/>
</div>
<div style={{
padding: '10px 14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: '#fafbff',
}}>
<Space>
<VideoCameraOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ fontSize: 13, color: '#475569' }}>
{record.projectName}
</Typography.Text>
</Space>
{record.generatedAt && (
<span className="date-display" translate="no" style={{ fontSize: 12, color: '#94a3b8' }}>
{formatDate(record.generatedAt)}
</span>
)}
</div>
</div>
) : record.status === 'failed' ? (
<div style={{
height: '100%', minHeight: 200, borderRadius: 14,
background: 'rgba(239,68,68,0.03)',
border: '1px dashed rgba(239,68,68,0.2)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
}}>
<CloseCircleOutlined style={{ color: '#ef4444', fontSize: 32 }} />
<Typography.Text style={{ color: '#ef4444', fontSize: 14 }}></Typography.Text>
<Typography.Text style={{ color: '#94a3b8', fontSize: 12 }}></Typography.Text>
</div>
) : (
<div style={{
height: '100%', minHeight: 200, borderRadius: 14,
background: '#f8f9fc', border: '1px dashed #e2e8f0',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
}}>
<ClockCircleOutlined style={{ color: '#94a3b8', fontSize: 32 }} />
<Typography.Text style={{ color: '#94a3b8', fontSize: 14 }}></Typography.Text>
<Typography.Text style={{ color: '#cbd5e1', fontSize: 12 }}></Typography.Text>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
{/* Generate modal */}
<Modal
title={<Space><RocketOutlined /></Space>}
open={!!genModal}
onCancel={() => setGenModal(null)}
onOk={handleGenerate}
okText="提交生成"
cancelText="取消"
width={420}
>
{genModal && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
{(() => {
const rec = records.find(r => r.id === genModal.recordId);
return (
<>
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}></Typography.Text>
<Typography.Text strong>{rec?.duration || 5}</Typography.Text>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select value={genModal.ratio} onChange={(v) => setGenModal(prev => prev ? { ...prev, ratio: v } : null)}
style={{ width: '100%' }}
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map(r => ({ value: r, label: r }))}
/>
</div>
<div>
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}></Typography.Text>
<Select value={genModal.resolution} onChange={(v) => setGenModal(prev => prev ? { ...prev, resolution: v } : null)}
style={{ width: '100%' }}
options={['480p', '720p', '1080p'].map(r => ({ value: r, label: r }))}
/>
</div>
</>
);
})()}
</div>
)}
</Modal>
</div>
);
};
export default RecordsPage;
@@ -0,0 +1,175 @@
import React, { useState } from 'react';
import {
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
interface CreditRatio {
id: string;
modelName: string;
resolution: string;
ratio: number;
baseCredits: number;
perSecondCredits: number;
}
const MOCK_RATIOS: CreditRatio[] = [
{ id: 'cr-1', modelName: 'GPT-4o', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 },
{ id: 'cr-2', modelName: 'GPT-4o', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 },
{ id: 'cr-3', modelName: 'GPT-4o', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 },
{ id: 'cr-4', modelName: 'DeepSeek-V3', resolution: '720p', ratio: 0.8, baseCredits: 48, perSecondCredits: 2 },
{ id: 'cr-5', modelName: 'DeepSeek-V3', resolution: '1080p', ratio: 1.2, baseCredits: 72, perSecondCredits: 3 },
{ id: 'cr-6', modelName: 'DeepSeek-V3', resolution: '4K', ratio: 2.0, baseCredits: 120, perSecondCredits: 4 },
{ id: 'cr-7', modelName: '通用', resolution: '720p', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 },
{ id: 'cr-8', modelName: '通用', resolution: '1080p', ratio: 1.5, baseCredits: 90, perSecondCredits: 3 },
{ id: 'cr-9', modelName: '通用', resolution: '4K', ratio: 2.5, baseCredits: 150, perSecondCredits: 5 },
];
const AdminCreditRatios: React.FC = () => {
const [ratios, setRatios] = useState<CreditRatio[]>(MOCK_RATIOS);
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
if (modal.ratio) {
setRatios(prev => prev.map(r => r.id === modal.ratio!.id ? { ...r, ...values } : r));
message.success('已更新');
} else {
setRatios(prev => [...prev, { id: `cr-${Date.now()}`, ...values }]);
message.success('已添加');
}
setModal({ open: false, ratio: null });
form.resetFields();
} catch { /* validation */ }
};
const handleDelete = (id: string) => {
setRatios(prev => prev.filter(r => r.id !== id));
message.success('已删除');
};
const openEdit = (ratio?: CreditRatio) => {
setModal({ open: true, ratio: ratio || null });
if (ratio) form.setFieldsValue(ratio);
else { form.resetFields(); form.setFieldsValue({ ratio: 1.0, baseCredits: 60, perSecondCredits: 2 }); }
};
const columns = [
{
title: '模型', dataIndex: 'modelName', width: 150,
render: (v: string) => <Tag color="purple">{v}</Tag>,
},
{
title: '分辨率', dataIndex: 'resolution', width: 100,
render: (v: string) => {
const colors: Record<string, string> = { '720p': 'default', '1080p': 'blue', '4K': 'gold' };
return <Tag color={colors[v] || 'default'}>{v}</Tag>;
},
},
{
title: '倍率', dataIndex: 'ratio', width: 100, sorter: (a: CreditRatio, b: CreditRatio) => a.ratio - b.ratio,
render: (v: number) => (
<Typography.Text strong style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>
x{v}
</Typography.Text>
),
},
{
title: '基础积分', dataIndex: 'baseCredits', width: 100,
render: (v: number) => <Typography.Text>{v} </Typography.Text>,
},
{
title: '每秒积分', dataIndex: 'perSecondCredits', width: 100,
render: (v: number) => <Typography.Text>{v} /</Typography.Text>,
},
{
title: '示例计算 (15秒)', key: 'example', width: 120,
render: (_: any, r: CreditRatio) => {
const total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} </Typography.Text>;
},
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: CreditRatio) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{ratios.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
( + x ) x
</Typography.Text>
<Table
columns={columns}
dataSource={ratios}
rowKey="id"
pagination={false}
scroll={{ x: 800 }}
/>
</Card>
<Modal
title={<Space><CalculatorOutlined />{modal.ratio ? '编辑比例' : '添加比例'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, ratio: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={480}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="modelName" label="模型" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'GPT-4o', label: 'GPT-4o' },
{ value: 'DeepSeek-V3', label: 'DeepSeek-V3' },
{ value: '通用', label: '通用 (默认)' },
]} />
</Form.Item>
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: '720p', label: '720p' },
{ value: '1080p', label: '1080p' },
{ value: '4K', label: '4K' },
]} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="ratio" label="倍率" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0.1} max={10} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="baseCredits" label="基础积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0} max={1000} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="perSecondCredits" label="每秒积分" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminCreditRatios;
@@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, DatePicker, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, SearchOutlined,
} from '@ant-design/icons';
interface CreditRecord {
id: string;
username: string;
type: 'recharge' | 'consume';
amount: number;
balanceAfter: number;
description: string;
createdAt: string;
}
const MOCK_RECORDS: CreditRecord[] = [
{ id: 'cr-1', username: 'videomaker', type: 'recharge', amount: 3000, balanceAfter: 3000, description: '会员充值赠送', createdAt: '2026-04-28 10:00:00' },
{ id: 'cr-2', username: 'videomaker', type: 'consume', amount: -120, balanceAfter: 2880, description: '提示词优化 - 电商广告视频', createdAt: '2026-04-29 14:22:00' },
{ id: 'cr-3', username: 'designer', type: 'recharge', amount: 2000, balanceAfter: 2000, description: '进阶包充值', createdAt: '2026-04-29 16:00:00' },
{ id: 'cr-4', username: 'videomaker', type: 'consume', amount: -80, balanceAfter: 2800, description: '提示词优化 - 教育课程视频', createdAt: '2026-04-30 09:15:00' },
{ id: 'cr-5', username: 'designer', type: 'consume', amount: -100, balanceAfter: 1900, description: '提示词优化 - 品牌故事视频', createdAt: '2026-05-01 11:30:00' },
{ id: 'cr-6', username: 'marketer', type: 'recharge', amount: 5000, balanceAfter: 5000, description: '专业包充值', createdAt: '2026-05-02 08:00:00' },
{ id: 'cr-7', username: 'videomaker', type: 'recharge', amount: 500, balanceAfter: 3300, description: '活动赠送积分', createdAt: '2026-05-02 11:00:00' },
{ id: 'cr-8', username: 'marketer', type: 'consume', amount: -120, balanceAfter: 4880, description: '提示词优化 - 产品宣传视频', createdAt: '2026-05-03 15:20:00' },
{ id: 'cr-9', username: 'editor', type: 'recharge', amount: 1500, balanceAfter: 1500, description: '体验包充值', createdAt: '2026-05-04 10:00:00' },
{ id: 'cr-10', username: 'designer', type: 'consume', amount: -200, balanceAfter: 1700, description: '提示词优化 - 游戏预告片', createdAt: '2026-05-05 14:45:00' },
];
const AdminCreditRecords: React.FC = () => {
const [records, setRecords] = useState<CreditRecord[]>(MOCK_RECORDS);
const [typeFilter, setTypeFilter] = useState<string>('');
const [loading, setLoading] = useState(false);
const filtered = typeFilter ? records.filter(r => r.type === typeFilter) : records;
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
const columns = [
{
title: '用户', dataIndex: 'username', width: 120,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '类型', dataIndex: 'type', width: 100,
render: (v: string) => (
<Tag color={v === 'recharge' ? 'green' : 'red'} icon={v === 'recharge' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}>
{v === 'recharge' ? '充值' : '消费'}
</Tag>
),
filters: [
{ text: '充值', value: 'recharge' },
{ text: '消费', value: 'consume' },
],
onFilter: (value: any, record: CreditRecord) => record.type === value,
},
{
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v > 0 ? '+' : ''}{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
},
{
title: '说明', dataIndex: 'description', ellipsis: true,
},
{
title: '时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
];
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(16,185,129,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#10b981',
}}><ArrowUpOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#ef4444',
}}><ArrowDownOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}><WalletOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{records.length}</div>
</div>
</div>
</Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Table
columns={columns}
dataSource={filtered}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showTotal: (t) => `${t} 条记录` }}
scroll={{ x: 800 }}
/>
</Card>
</div>
);
};
export default AdminCreditRecords;
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { Card, Col, Row, Statistic, Typography, Table, Tag } from 'antd';
import {
UserOutlined,
ProjectOutlined,
PlayCircleOutlined,
DollarOutlined,
ThunderboltOutlined,
ArrowUpOutlined,
} from '@ant-design/icons';
import { getAdminStats } from '../../api';
import type { AdminStats } from '../../types';
const AdminDashboard: React.FC = () => {
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
setLoading(true);
const data = await getAdminStats();
setStats(data);
setLoading(false);
};
load();
}, []);
const statCards = stats ? [
{ title: '总用户数', value: stats.totalUsers, icon: <UserOutlined />, color: '#6366f1', bg: 'rgba(99,102,241,0.08)' },
{ title: '总项目数', value: stats.totalProjects, icon: <ProjectOutlined />, color: '#06b6d4', bg: 'rgba(6,182,212,0.08)' },
{ title: '总生成次数', value: stats.totalGenerations, icon: <PlayCircleOutlined />, color: '#10b981', bg: 'rgba(16,185,129,0.08)' },
{ title: '总收入(元)', value: stats.totalRevenue, icon: <DollarOutlined />, color: '#f59e0b', bg: 'rgba(245,158,11,0.08)', prefix: '¥' },
{ title: '今日消耗积分', value: stats.creditsConsumedToday, icon: <ThunderboltOutlined />, color: '#ef4444', bg: 'rgba(239,68,68,0.08)' },
] : [];
return (
<div>
{/* Stats Cards */}
<Row gutter={[16, 16]}>
{statCards.map((s, i) => (
<Col xs={12} sm={8} lg={i < 4 ? 6 : 24} key={s.title}>
<Card bordered={false} loading={loading}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: s.bg, display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: s.color, flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12, marginBottom: 2 }}>{s.title}</div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>
{s.prefix}{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
</div>
</div>
</Card>
</Col>
))}
</Row>
{/* Quick Info */}
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
<Col xs={24} lg={12}>
<Card title="系统信息" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ label: '平台名称', value: 'VideoGen.AI' },
{ label: 'API版本', value: 'v1.0.0' },
{ label: '数据库', value: 'SQLite (本地开发)' },
{ label: 'LLM模式', value: 'Mock (模拟)' },
{ label: '视频引擎', value: 'Seedance 2.0' },
].map(item => (
<div key={item.label} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<Typography.Text type="secondary">{item.label}</Typography.Text>
<Typography.Text strong>{item.value}</Typography.Text>
</div>
))}
</div>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="充值套餐" bordered={false}
style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ name: '体验包', credits: 500, price: 49, color: '#f59e0b' },
{ name: '进阶包', credits: 2000, price: 168, color: '#6366f1', hot: true },
{ name: '专业包', credits: 5000, price: 388, color: '#06b6d4' },
{ name: '企业包', credits: 20000, price: 1280, color: '#10b981' },
].map(p => (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f5f6fa' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
<Typography.Text strong>{p.name}</Typography.Text>
{p.hot && <Tag color="purple" style={{ fontSize: 10, lineHeight: '16px' }}></Tag>}
</div>
<div>
<Typography.Text strong style={{ color: p.color }}>¥{p.price}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()}</Typography.Text>
</div>
</div>
))}
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default AdminDashboard;
@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, MinusCircleOutlined,
} from '@ant-design/icons';
interface OptionGroup {
name: string;
options: string[];
}
interface IndustryItem {
id: string;
key: string;
label: string;
description: string;
skills: string[];
optionGroups: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
const MOCK_INDUSTRIES: IndustryItem[] = [
{
id: 'ind-1', key: 'ecommerce', label: '电商', description: '电商直播、产品展示、促销活动',
skills: ['你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言,注重画面节奏和消费者心理'],
optionGroups: [
{ name: '视频风格', options: ['实拍展示', '3D动画', '混剪快闪', '沉浸体验'] },
{ name: '目标受众', options: ['年轻女性', '家庭用户', '商务人士', '学生群体'] },
],
isActive: true, sortOrder: 1,
},
{
id: 'ind-2', key: 'education', label: '教育', description: '在线课程、知识付费、培训',
skills: ['你是一位专业的教育视频策划专家,擅长将复杂知识点转化为生动易懂的视觉叙事'],
optionGroups: [
{ name: '课程类型', options: ['知识讲解', '操作演示', '故事叙事', '互动问答'] },
],
isActive: true, sortOrder: 2,
},
{
id: 'ind-3', key: 'gaming', label: '游戏', description: '游戏预告、赛事宣传、角色展示',
skills: ['你是一位专业的游戏视频创意专家,擅长打造震撼视觉体验和沉浸式叙事'],
optionGroups: [
{ name: '游戏类型', options: ['RPG', 'FPS', 'MOBA', '休闲'] },
{ name: '视频类型', options: ['预告片', '宣传片', '教程', '赛事回顾'] },
],
isActive: true, sortOrder: 3,
},
{
id: 'ind-4', key: 'medical', label: '医疗', description: '医疗健康、药品宣传、科普',
skills: ['你是一位专业的医疗健康视频文案专家,擅长将医学知识转化为通俗易懂的视觉内容'],
optionGroups: [],
isActive: true, sortOrder: 4,
},
{
id: 'ind-5', key: 'finance', label: '金融', description: '理财产品、保险、银行服务',
skills: ['你是一位专业的金融视频文案专家,擅长将复杂的金融产品转化为易于理解的视觉表达'],
optionGroups: [],
isActive: true, sortOrder: 5,
},
{
id: 'ind-6', key: 'realestate', label: '房产', description: '楼盘展示、户型介绍、周边配套',
skills: ['你是一位专业的房产视频策划专家,擅长通过镜头语言展现空间美感和生活场景'],
optionGroups: [
{ name: '展示方式', options: ['航拍全景', '室内漫游', '样板间', '周边实景'] },
],
isActive: true, sortOrder: 6,
},
{
id: 'ind-7', key: 'food', label: '餐饮', description: '美食制作、餐厅宣传、食材展示',
skills: ['你是一位专业的美食视频创意专家,擅长用镜头捕捉食物的色香味,营造食欲感'],
optionGroups: [
{ name: '拍摄风格', options: ['特写慢放', '制作过程', '美食探店', '食材溯源'] },
],
isActive: true, sortOrder: 7,
},
{
id: 'ind-8', key: 'travel', label: '旅游', description: '景点宣传、酒店推荐、旅行攻略',
skills: ['你是一位专业的旅游视频文案专家,擅长用镜头语言展现目的地魅力和旅行体验'],
optionGroups: [
{ name: '内容形式', options: ['Vlog', '攻略指南', '风景大片', '人文记录'] },
],
isActive: true, sortOrder: 8,
},
{
id: 'ind-9', key: 'tech', label: '科技', description: '科技产品、SaaS服务、AI应用',
skills: ['你是一位专业的科技视频策划专家,擅长将技术概念转化为直观的视觉演示'],
optionGroups: [
{ name: '演示方式', options: ['产品演示', '对比评测', '概念解析', '场景模拟'] },
],
isActive: true, sortOrder: 9,
},
{
id: 'ind-10', key: 'other', label: '其他', description: '通用行业',
skills: ['你是一位专业的视频导演和文案专家,擅长将主题转化为富有感染力的视觉叙事'],
optionGroups: [],
isActive: true, sortOrder: 10,
},
];
const AdminIndustries: React.FC = () => {
const [industries, setIndustries] = useState<IndustryItem[]>(MOCK_INDUSTRIES);
const [saving, setSaving] = useState(false);
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const skills = values.skills_wentutujie?.trim() ? [values.skills_wentutujie.trim()] : [];
const optionGroups: OptionGroup[] = (values.optionGroups || [])
.filter((g: any) => g?.name?.trim())
.map((g: any) => ({
name: g.name.trim(),
options: (g.options || []).filter((o: string) => o?.trim()),
}))
.filter((g: OptionGroup) => g.options.length > 0);
if (modal.item) {
setIndustries(prev => prev.map(i => i.id === modal.item!.id ? { ...i, ...values, skills, optionGroups } : i));
message.success('已更新');
} else {
const newItem: IndustryItem = {
id: `ind-${Date.now()}`,
key: values.key,
label: values.label,
description: values.description || '',
skills,
optionGroups,
isActive: values.isActive !== false,
sortOrder: industries.length + 1,
};
setIndustries(prev => [...prev, newItem]);
message.success('已添加');
}
setModal({ open: false, item: null });
form.resetFields();
} catch (e: any) {
if (e?.errorFields) return;
message.error(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = (id: string) => {
setIndustries(prev => prev.filter(i => i.id !== id));
message.success('已删除');
};
const openEdit = (item?: IndustryItem) => {
setModal({ open: true, item: item || null });
if (item) {
form.setFieldsValue({
key: item.key,
label: item.label,
description: item.description,
skills_wentutujie: item.skills[0] || '',
optionGroups: item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }],
isActive: item.isActive,
});
} else {
form.resetFields();
form.setFieldsValue({ isActive: true, optionGroups: [{ name: '', options: [] }] });
}
};
const columns = [
{
title: '行业', key: 'industry', width: 160,
render: (_: any, r: IndustryItem) => (
<div>
<Typography.Text strong>{r.label}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
</div>
),
},
{
title: '描述', dataIndex: 'description', ellipsis: true,
},
{
title: '选项配置', key: 'optionGroups', width: 260,
render: (_: any, r: IndustryItem) => {
if (!r.optionGroups || r.optionGroups.length === 0) {
return <Typography.Text style={{ fontSize: 12, color: '#cbd5e1' }}></Typography.Text>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{r.optionGroups.map((g, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11 }}>{g.name}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{g.options.slice(0, 3).join('、')}{g.options.length > 3 ? `...${g.options.length}` : ''}
</Typography.Text>
</div>
))}
</div>
);
},
},
{
title: '文图理解提示词', dataIndex: 'skills', width: 200,
render: (skills: string[]) => (
<Typography.Text ellipsis style={{ fontSize: 12 }}>
{skills[0] || '-'}
</Typography.Text>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: IndustryItem) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<AppstoreOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{industries.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={industries}
rowKey="id"
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><AppstoreOutlined />{modal.item ? '编辑行业' : '添加行业'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={640}
confirmLoading={saving}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="key" label="行业标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入标识' }]}>
<Input placeholder="例如:ecommerce" size="large" />
</Form.Item>
<Form.Item name="label" label="行业名称" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如:电商" size="large" />
</Form.Item>
</div>
<Form.Item name="description" label="行业描述">
<Input.TextArea rows={2} placeholder="行业描述" size="large" />
</Form.Item>
<Form.Item name="skills_wentutujie" label="文图理解提示词" extra="用于LLM优化提示词的系统指令,根据行业特点引导AI理解文案与画面的关系">
<Input.TextArea rows={3} placeholder="请输入文图理解提示词,例如:&#10;你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" size="large" />
</Form.Item>
{/* Option Groups */}
<div style={{ marginBottom: 8 }}>
<Typography.Text strong style={{ fontSize: 13 }}></Typography.Text>
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
</Typography.Text>
</div>
<Form.List name="optionGroups">
{(fields, { add, remove }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
{fields.map(({ key, name, ...restField }) => (
<div key={key} style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
padding: '10px 12px', borderRadius: 10,
background: '#f8f9fc', border: '1px solid #f0f0f5',
}}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Form.Item {...restField} name={[name, 'name']} label="选项名称" style={{ marginBottom: 0 }}
rules={[{ required: true, message: '请输入选项名称' }]}>
<Input placeholder="例如:视频风格" size="middle" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item {...restField} name={[name, 'options']} label="选项内容" style={{ marginBottom: 0 }}>
<Select
mode="tags"
size="middle"
placeholder="输入选项后回车添加"
style={{ borderRadius: 8 }}
tokenSeparators={[',', '', '、']}
/>
</Form.Item>
</div>
<MinusCircleOutlined
onClick={() => remove(name)}
style={{ color: '#ef4444', fontSize: 16, marginTop: 34, cursor: 'pointer', flexShrink: 0 }}
/>
</div>
))}
<Button
type="dashed" onClick={() => add()} block
icon={<PlusOutlined />}
style={{ borderRadius: 8, height: 36 }}
>
</Button>
</div>
)}
</Form.List>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminIndustries;
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { Layout, Menu, Avatar, Typography, Space, Dropdown, Spin } from 'antd';
import {
DashboardOutlined,
UserOutlined,
RobotOutlined,
SettingOutlined,
BellOutlined,
ThunderboltOutlined,
LogoutOutlined,
LeftOutlined,
RightOutlined,
WalletOutlined,
CalculatorOutlined,
DollarOutlined,
AppstoreOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const { Sider, Content } = Layout;
const AdminLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, loading, logout } = useAuthStore();
const [collapsed, setCollapsed] = useState(false);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!user) {
return <Navigate to="/admin/login" replace />;
}
const menuItems = [
{ key: '/admin', icon: <DashboardOutlined />, label: '数据概览' },
{ key: '/admin/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/admin/credit-records', icon: <WalletOutlined />, label: '交易流水' },
{ key: '/admin/models', icon: <RobotOutlined />, label: '模型配置' },
{ key: '/admin/credit-ratios', icon: <CalculatorOutlined />, label: '积分比例' },
{ key: '/admin/video-engines', icon: <PlayCircleOutlined />, label: '视频引擎' },
{ key: '/admin/industries', icon: <AppstoreOutlined />, label: '行业配置' },
{ key: '/admin/payment', icon: <DollarOutlined />, label: '支付配置' },
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/admin/notifications', icon: <BellOutlined />, label: '消息推送' },
];
const selectedKey = location.pathname;
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
theme="dark"
style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
}}
>
{/* Logo */}
<div style={{
height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
{!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}>
</span>
)}
</div>
{/* Menu */}
<Menu
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark"
/>
{/* User block */}
<div style={{
position: 'absolute', bottom: 48, left: 0, right: 0,
padding: collapsed ? '12px 8px' : '12px 16px',
borderTop: '1px solid rgba(255,255,255,0.06)',
}}>
<Dropdown menu={{
items: [
{ key: 'front', icon: <ThunderboltOutlined />, label: '返回前台' },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
],
onClick: ({ key }) => {
if (key === 'front') navigate('/projects');
else if (key === 'logout') { logout(); navigate('/admin/login'); }
},
}} placement="topRight" arrow>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: 10, padding: '8px 10px', borderRadius: 10,
cursor: 'pointer', background: 'rgba(255,255,255,0.04)',
transition: 'background 0.2s',
}}>
<Avatar size={28} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#e2e8f0', fontSize: 12, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user?.username}
</div>
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 10 }}></div>
</div>
)}
</div>
</Dropdown>
</div>
</Sider>
<Layout>
{/* Header */}
<div style={{
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px',
}}>
<Typography.Text strong style={{ fontSize: 16 }}>
{menuItems.find(m => m.key === selectedKey)?.label || '管理后台'}
</Typography.Text>
<Space>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{user?.username}
</Typography.Text>
</Space>
</div>
{/* Content */}
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
@@ -0,0 +1,78 @@
import { useState } from 'react';
import { Button, Card, Form, Input, message, Typography } from 'antd';
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const AdminLoginPage = () => {
const navigate = useNavigate();
const { login } = useAuthStore();
const [loading, setLoading] = useState(false);
const handleLogin = async (values: { username: string; password: string }) => {
setLoading(true);
try {
await login(values.username, values.password);
message.success('登录成功');
navigate('/admin');
} catch {
message.error('登录失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a35 50%, #0f0f23 100%)',
}}>
<Card bordered={false} style={{
width: 420, borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
}}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{
width: 56, height: 56, borderRadius: 14, margin: '0 auto 16px',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
}}>
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
</div>
<Typography.Title level={3} style={{ margin: 0 }}>
VideoGen<span style={{ color: '#6366f1' }}>.AI</span>
</Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
</div>
<Form onFinish={handleLogin} layout="vertical" initialValues={{ username: 'admin', password: 'admin123' }}>
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input placeholder="用户名" size="large" prefix={<UserOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item style={{ marginBottom: 8 }}>
<Button type="primary" htmlType="submit" loading={loading} block size="large"
style={{
borderRadius: 10, fontWeight: 600, height: 44,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
}}>
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
演示账号: admin / admin123
</Typography.Text>
</div>
</Card>
</div>
);
};
export default AdminLoginPage;
@@ -0,0 +1,222 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
RobotOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import { getModelConfigs, saveModelConfig, deleteModelConfig } from '../../api';
import type { ModelConfig } from '../../types';
const AdminModels: React.FC = () => {
const [models, setModels] = useState<ModelConfig[]>([]);
const [loading, setLoading] = useState(true);
const [modal, setModal] = useState<{ open: boolean; model: ModelConfig | null }>({ open: false, model: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
const data = await getModelConfigs();
setModels(data);
setLoading(false);
};
useEffect(() => { load(); }, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
await saveModelConfig({
...modal.model,
...values,
id: modal.model?.id,
});
message.success(modal.model?.id ? '模型配置已更新' : '模型配置已添加');
setModal({ open: false, model: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleDelete = async (id: string) => {
await deleteModelConfig(id);
message.success('模型配置已删除');
load();
};
const openEdit = (model?: ModelConfig) => {
setModal({ open: true, model: model || null });
if (model) {
form.setFieldsValue(model);
} else {
form.resetFields();
form.setFieldsValue({
provider: 'sdk',
weight: 1,
maxTokens: 4096,
temperature: 0.7,
isActive: true,
priority: 0,
});
}
};
const columns = [
{
title: '模型名称', dataIndex: 'name', width: 150,
render: (v: string, r: ModelConfig) => (
<Space>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 14,
}}>
<RobotOutlined />
</div>
<div>
<div style={{ fontWeight: 600 }}>{v}</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
</div>
</Space>
),
},
{
title: '提供商', dataIndex: 'provider', width: 140,
render: (v: string) => {
const labelMap: Record<string, string> = {
sdk: 'SDK模式',
openai_compatible: 'OpenAI兼容',
mock: 'Mock模式',
};
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
},
},
{
title: 'API地址', dataIndex: 'apiBase', width: 200,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>
{v || '-'}
</Typography.Text>
),
},
{
title: '权重', dataIndex: 'weight', width: 80, sorter: (a: ModelConfig, b: ModelConfig) => a.weight - b.weight,
},
{
title: 'Max Tokens', dataIndex: 'maxTokens', width: 100,
},
{
title: 'Temperature', dataIndex: 'temperature', width: 100,
render: (v: number) => v.toFixed(1),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>
),
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: ModelConfig) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />}
onClick={() => openEdit(r)}>
</Button>
<Popconfirm title="确定删除该模型配置?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Text type="secondary">
{models.length}
</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={models}
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
{/* Edit Modal */}
<Modal
title={<Space><RobotOutlined />{modal.model?.id ? '编辑模型' : '添加模型'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, model: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={560}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="显示名称"
rules={[{ required: true, message: '请输入模型名称' }]}>
<Input placeholder="例如:GPT-4o" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'sdk', label: 'SDK模式' },
{ value: 'openai_compatible', label: 'OpenAI兼容' },
{ value: 'mock', label: 'Mock模式' },
]} />
</Form.Item>
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
rules={[{ required: true, message: '请输入模型标识' }]}>
<Input placeholder="例如:gpt-4o" size="large" />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API地址">
<Input placeholder="https://api.openai.com/v1" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="weight" label="权重" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="maxTokens" label="Max Tokens" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={256} max={128000} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="temperature" label="Temperature" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} size="large" />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}
rules={[{ required: true }]}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ flex: 1, paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminModels;
@@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import {
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined,
} from '@ant-design/icons';
interface NotificationRecord {
id: string;
title: string;
content: string;
type: string;
target: string;
createdAt: string;
}
const MOCK_NOTIFICATIONS: NotificationRecord[] = [
{ id: 'n-1', title: '系统上线通知', content: 'VideoGen.AI 平台正式上线!', type: 'system', target: '全部用户', createdAt: '2026-05-01 09:00:00' },
{ id: 'n-2', title: '积分充值优惠', content: '限时活动:充值进阶包额外赠送200积分', type: 'credit', target: '全部用户', createdAt: '2026-05-03 10:00:00' },
{ id: 'n-3', title: '账户审核通过', content: '您的账户已通过实名审核', type: 'system', target: 'videomaker', createdAt: '2026-05-05 14:00:00' },
];
const MOCK_USERS = [
{ id: 'u-001', username: 'videomaker' },
{ id: 'u-002', username: 'designer' },
{ id: 'u-003', username: 'marketer' },
{ id: 'u-004', username: 'editor' },
];
const AdminNotificationManager: React.FC = () => {
const [notifications, setNotifications] = useState<NotificationRecord[]>(MOCK_NOTIFICATIONS);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const handleSend = async () => {
try {
const values = await form.validateFields();
const newRecord: NotificationRecord = {
id: `n-${Date.now()}`,
title: values.title,
content: values.content,
type: values.type,
target: values.target_user_id
? MOCK_USERS.find(u => u.id === values.target_user_id)?.username || '指定用户'
: '全部用户',
createdAt: new Date().toLocaleString('zh-CN'),
};
setNotifications(prev => [newRecord, ...prev]);
message.success('消息已发送');
setModalOpen(false);
form.resetFields();
} catch { /* validation */ }
};
const handleDelete = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
message.success('已删除');
};
const getTypeColor = (type: string) => {
switch (type) {
case 'system': return 'blue';
case 'credit': return 'orange';
case 'promo': return 'purple';
default: return 'default';
}
};
const columns = [
{
title: '标题', dataIndex: 'title', width: 200,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '内容', dataIndex: 'content', ellipsis: true,
},
{
title: '类型', dataIndex: 'type', width: 80,
render: (v: string) => {
const labels: Record<string, string> = { system: '系统', credit: '积分', promo: '活动' };
return <Tag color={getTypeColor(v)}>{labels[v] || v}</Tag>;
},
},
{
title: '发送目标', dataIndex: 'target', width: 120,
render: (v: string) => (
<Tag color={v === '全部用户' ? 'green' : 'blue'}>{v}</Tag>
),
},
{
title: '发送时间', dataIndex: 'createdAt', width: 160,
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: NotificationRecord) => (
<Popconfirm title="确定删除该消息?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}></Button>
</Popconfirm>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{notifications.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={notifications}
rowKey="id"
pagination={{ pageSize: 10, showTotal: (t) => `${t} 条消息` }}
scroll={{ x: 800 }}
/>
</Card>
{/* Send Notification Modal */}
<Modal
title={<Space><SendOutlined /></Space>}
open={modalOpen}
onOk={handleSend}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
okText="发送" cancelText="取消" width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="title" label="消息标题"
rules={[{ required: true, message: '请输入标题' }]}>
<Input placeholder="请输入消息标题" size="large" />
</Form.Item>
<Form.Item name="content" label="消息内容"
rules={[{ required: true, message: '请输入内容' }]}>
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
initialValue="system" rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'system', label: '系统通知' },
{ value: 'credit', label: '积分通知' },
{ value: 'promo', label: '活动通知' },
]} />
</Form.Item>
<Form.Item name="target_user_id" label="发送目标" style={{ flex: 1 }}
extra="留空则发送给全部用户">
<Select size="large" allowClear placeholder="全部用户"
options={MOCK_USERS.map(u => ({ value: u.id, label: u.username }))} />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminNotificationManager;
@@ -0,0 +1,114 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Empty, Space, Tag, Typography,
} from 'antd';
import {
BellOutlined, CheckOutlined, InfoCircleOutlined, CreditCardOutlined, ExclamationCircleOutlined,
} from '@ant-design/icons';
import { getNotifications } from '../../api';
import type { AdminNotification } from '../../types';
const AdminNotifications: React.FC = () => {
const [notifications, setNotifications] = useState<AdminNotification[]>([]);
const [loading, setLoading] = useState(true);
const load = async () => {
setLoading(true);
const data = await getNotifications();
setNotifications(data);
setLoading(false);
};
useEffect(() => { load(); }, []);
const getTypeIcon = (type: string) => {
switch (type) {
case 'system': return <InfoCircleOutlined style={{ color: '#6366f1' }} />;
case 'credit': return <CreditCardOutlined style={{ color: '#f59e0b' }} />;
default: return <ExclamationCircleOutlined style={{ color: '#94a3b8' }} />;
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case 'system': return <Tag color="blue"></Tag>;
case 'credit': return <Tag color="orange"></Tag>;
default: return <Tag></Tag>;
}
};
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{notifications.filter(n => !n.isRead).length} </Tag>
</Space>
</div>
{notifications.length === 0 ? (
<Empty description="暂无通知" />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{notifications.map(n => (
<Card
key={n.id}
size="small"
bordered
style={{
borderRadius: 10,
borderColor: n.isRead ? '#f0f0f5' : '#e0e7ff',
background: n.isRead ? '#fff' : '#fafbff',
transition: 'all 0.2s',
}}
>
<div style={{ display: 'flex', gap: 14 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: n.isRead ? '#f8fafc' : 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18,
}}>
{getTypeIcon(n.type)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14 }}>
{n.title}
</Typography.Text>
{getTypeLabel(n.type)}
{!n.isRead && (
<Tag color="red" style={{ fontSize: 10 }}></Tag>
)}
</div>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 4, lineHeight: 1.6 }}>
{n.content}
</Typography.Paragraph>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{n.createdAt}
</Typography.Text>
</div>
{!n.isRead && (
<Button type="text" size="small" icon={<CheckOutlined />}
style={{ flexShrink: 0, color: '#6366f1' }}
onClick={() => {
setNotifications(prev =>
prev.map(item => item.id === n.id ? { ...item, isRead: true } : item)
);
}}>
</Button>
)}
</div>
</Card>
))}
</div>
)}
</Card>
</div>
);
};
export default AdminNotifications;
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import {
Button, Card, Form, Input, message, Switch, Typography, Divider,
} from 'antd';
import {
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined,
} from '@ant-design/icons';
interface PaymentSetting {
key: string;
value: string;
label: string;
description: string;
secret?: boolean;
}
const AdminPaymentConfig: React.FC = () => {
const [saving, setSaving] = useState(false);
const [wechatEnabled, setWechatEnabled] = useState(false);
const [alipayEnabled, setAlipayEnabled] = useState(false);
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
await new Promise(r => setTimeout(r, 500));
message.success('支付配置已保存');
setSaving(false);
} catch { setSaving(false); }
};
return (
<div style={{ maxWidth: 720 }}>
{/* WeChat Pay */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(7,193,96,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#07c160',
}}><WechatOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
<Switch checked={wechatEnabled} onChange={setWechatEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
</div>
<Form form={form} layout="vertical" initialValues={{
wechat_mch_id: '',
wechat_api_key: '',
wechat_cert_path: '',
wechat_notify_url: '',
}}>
<Form.Item name="wechat_mch_id" label="商户号 (MchID)">
<Input placeholder="微信支付商户号" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_api_key" label="API密钥">
<Input.Password placeholder="微信支付API密钥" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_cert_path" label="证书路径">
<Input placeholder="apiclient_cert.pem 路径" size="large" disabled={!wechatEnabled} />
</Form.Item>
<Form.Item name="wechat_notify_url" label="回调地址">
<Input placeholder="https://yourdomain.com/api/payments/wechat/callback" size="large" disabled={!wechatEnabled} />
</Form.Item>
</Form>
</Card>
{/* Alipay */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(0,122,255,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, color: '#007aff',
}}><AlipayCircleOutlined /></div>
<div>
<Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
</div>
</div>
<Switch checked={alipayEnabled} onChange={setAlipayEnabled} checkedChildren="已启用" unCheckedChildren="未启用" />
</div>
<Form form={form} layout="vertical" initialValues={{
alipay_app_id: '',
alipay_private_key: '',
alipay_public_key: '',
alipay_notify_url: '',
}}>
<Form.Item name="alipay_app_id" label="AppID">
<Input placeholder="支付宝应用AppID" size="large" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_private_key" label="应用私钥">
<Input.TextArea rows={3} placeholder="支付宝应用私钥 (PKCS8格式)" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_public_key" label="支付宝公钥">
<Input.TextArea rows={3} placeholder="支付宝公钥" disabled={!alipayEnabled} />
</Form.Item>
<Form.Item name="alipay_notify_url" label="回调地址">
<Input placeholder="https://yourdomain.com/api/payments/alipay/callback" size="large" disabled={!alipayEnabled} />
</Form.Item>
</Form>
</Card>
{/* Recharge Packages */}
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
title={<span><DollarOutlined style={{ marginRight: 8 }} /></span>}>
{[
{ name: '体验包', credits: 500, price: 49, color: '#f59e0b' },
{ name: '进阶包', credits: 2000, price: 168, color: '#6366f1' },
{ name: '专业包', credits: 5000, price: 388, color: '#06b6d4' },
{ name: '企业包', credits: 20000, price: 1280, color: '#10b981' },
].map(p => (
<div key={p.name} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 0', borderBottom: '1px solid #f5f6fa',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: p.color }} />
<Typography.Text strong>{p.name}</Typography.Text>
</div>
<div>
<Typography.Text strong style={{ color: p.color, fontSize: 16 }}>¥{p.price}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{p.credits.toLocaleString()} </Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>({(p.price / p.credits * 100).toFixed(1)}/)</Typography.Text>
</div>
</div>
))}
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
</Button>
</div>
</div>
);
};
export default AdminPaymentConfig;
@@ -0,0 +1,128 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, message, Space, Typography,
} from 'antd';
import {
SettingOutlined, SaveOutlined,
} from '@ant-design/icons';
import { getSystemConfigs, updateSystemConfig } from '../../api';
import type { SystemConfig } from '../../types';
const AdminSettings: React.FC = () => {
const [configs, setConfigs] = useState<SystemConfig[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
useEffect(() => {
const load = async () => {
setLoading(true);
const data = await getSystemConfigs();
setConfigs(data);
const formValues: Record<string, string> = {};
data.forEach(c => { formValues[c.key] = c.value; });
form.setFieldsValue(formValues);
setLoading(false);
};
load();
}, []);
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
for (const config of configs) {
const newVal = values[config.key];
if (newVal !== config.value) {
await updateSystemConfig(config.id, newVal);
}
}
message.success('系统配置已保存');
const data = await getSystemConfigs();
setConfigs(data);
setSaving(false);
} catch {
setSaving(false);
}
};
const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')),
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
};
const getFieldDescription = (config: SystemConfig): string => {
const descMap: Record<string, string> = {
site_name: '平台显示名称,将展示在页面标题和导航栏',
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
seo_title: '搜索引擎结果中显示的标题',
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
seo_keywords: '用逗号分隔的关键词列表',
};
return descMap[config.key] || config.description || '';
};
const getFieldComponent = (config: SystemConfig) => {
if (config.key === 'seo_description') {
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
}
if (config.key === 'seo_keywords') {
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
if (loading) {
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
}
return (
<div style={{ maxWidth: 720 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}>
<SettingOutlined />
</div>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text>
</div>
</div>
<Form form={form} layout="vertical">
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{items.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
))}
</Form>
</Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}
size="large" style={{ borderRadius: 8, minWidth: 140 }}>
</Button>
</div>
</div>
);
};
export default AdminSettings;
@@ -0,0 +1,182 @@
import React, { useEffect, useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined,
} from '@ant-design/icons';
import { getAdminUsers, adjustCredits, toggleUserStatus } from '../../api';
import type { AdminUser } from '../../types';
const AdminUsers: React.FC = () => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
const [form] = Form.useForm();
const load = async () => {
setLoading(true);
const data = await getAdminUsers(search || undefined);
setUsers(data);
setLoading(false);
};
useEffect(() => { load(); }, []);
const handleSearch = () => load();
const handleAdjustCredits = async () => {
try {
const values = await form.validateFields();
const { user } = creditModal;
if (!user) return;
await adjustCredits(user.id, values.amount, values.reason);
message.success(`${values.amount > 0 ? '增加' : '扣除'} ${Math.abs(values.amount)} 积分`);
setCreditModal({ open: false, user: null });
form.resetFields();
load();
} catch { /* validation */ }
};
const handleToggleStatus = async (user: AdminUser) => {
await toggleUserStatus(user.id, !user.isActive);
message.success(user.isActive ? '已禁用该用户' : '已启用该用户');
load();
};
const columns = [
{
title: '用户', key: 'user', width: 200,
render: (_: any, r: AdminUser) => (
<Space>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: r.isAdmin ? 'linear-gradient(135deg, #f59e0b, #f97316)' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 13, fontWeight: 700,
}}>
{r.username.charAt(0).toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 600 }}>
{r.username}
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
</div>
</Space>
),
},
{
title: '积分余额', dataIndex: 'credits', width: 120, sorter: (a: AdminUser, b: AdminUser) => a.credits - b.credits,
render: (v: number) => (
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
{v.toLocaleString()}
</Typography.Text>
),
},
{
title: '手机号', dataIndex: 'phone', width: 130,
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => (
<Tag color={v ? 'green' : 'red'}>{v ? '正常' : '禁用'}</Tag>
),
},
{
title: '注册时间', dataIndex: 'createdAt', width: 120,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
},
{
title: '最后登录', dataIndex: 'lastLoginAt', width: 140,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v || '-'}</Typography.Text>,
},
{
title: '操作', key: 'action', width: 200, fixed: 'right' as const,
render: (_: any, r: AdminUser) => (
<Space size={4}>
<Button type="link" size="small" icon={<WalletOutlined />}
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
</Button>
{!r.isAdmin && (
<Popconfirm
title={r.isActive ? '确定禁用该用户?' : '确定启用该用户?'}
onConfirm={() => handleToggleStatus(r)}
>
<Button type="link" size="small" danger={r.isActive}
icon={r.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
{r.isActive ? '禁用' : '启用'}
</Button>
</Popconfirm>
)}
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
{/* Search bar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<Input
placeholder="搜索用户名或邮箱"
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={search}
onChange={e => setSearch(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 280, borderRadius: 8 }}
allowClear
/>
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}></Button>
</div>
<Table
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10, showTotal: (t) => `${t} 个用户` }}
scroll={{ x: 900 }}
/>
</Card>
{/* Adjust Credits Modal */}
<Modal
title={<Space><WalletOutlined /> - {creditModal.user?.username}</Space>}
open={creditModal.open}
onOk={handleAdjustCredits}
onCancel={() => { setCreditModal({ open: false, user: null }); form.resetFields(); }}
okText="确认" cancelText="取消" width={440}
>
<div style={{ marginBottom: 16, padding: '12px 16px', background: '#f8fafc', borderRadius: 8 }}>
<span style={{ color: '#64748b' }}></span>
<span style={{ fontWeight: 800, fontSize: 18, color: '#6366f1' }}>
{creditModal.user?.credits.toLocaleString()}
</span>
</div>
<Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
placeholder="正数增加,负数扣除"
formatter={v => `${v}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
/>
</Form.Item>
<Form.Item name="reason" label="原因"
rules={[{ required: true, message: '请输入调整原因' }]}>
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminUsers;
@@ -0,0 +1,214 @@
import React, { useState } from 'react';
import {
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd';
import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
interface VideoEngine {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
supportedRatios: string[];
supportedResolutions: string[];
maxDuration: number;
isActive: boolean;
priority: number;
}
const MOCK_ENGINES: VideoEngine[] = [
{
id: 've-1', name: 'Seedance 2.0', provider: 'seedance',
apiBase: 'https://ark.cn-beijing.volces.com/api/v3',
apiKey: 'sk-****', modelName: 'seedance-2.0',
supportedRatios: ['16:9', '9:16', '1:1', '4:3'],
supportedResolutions: ['720p', '1080p', '4K'],
maxDuration: 60, isActive: true, priority: 1,
},
];
const AdminVideoEngines: React.FC = () => {
const [engines, setEngines] = useState<VideoEngine[]>(MOCK_ENGINES);
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm();
const handleSave = async () => {
try {
const values = await form.validateFields();
if (modal.engine) {
setEngines(prev => prev.map(e => e.id === modal.engine!.id ? { ...e, ...values } : e));
message.success('已更新');
} else {
const newEngine: VideoEngine = {
id: `ve-${Date.now()}`,
...values,
};
setEngines(prev => [...prev, newEngine]);
message.success('已添加');
}
setModal({ open: false, engine: null });
form.resetFields();
} catch { /* validation */ }
};
const handleDelete = (id: string) => {
setEngines(prev => prev.filter(e => e.id !== id));
message.success('已删除');
};
const openEdit = (engine?: VideoEngine) => {
setModal({ open: true, engine: engine || null });
if (engine) {
form.setFieldsValue(engine);
} else {
form.resetFields();
form.setFieldsValue({
isActive: true, priority: 0, maxDuration: 60,
supportedRatios: ['16:9', '9:16', '1:1'],
supportedResolutions: ['720p', '1080p'],
});
}
};
const columns = [
{
title: '引擎名称', key: 'name', width: 180,
render: (_: any, r: VideoEngine) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: r.isActive
? 'linear-gradient(135deg, #6366f1, #8b5cf6)'
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 16,
}}><PlayCircleOutlined /></div>
<div>
<Typography.Text strong>{r.name}</Typography.Text>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
</div>
</div>
),
},
{
title: 'API地址', dataIndex: 'apiBase', width: 250,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis>{v}</Typography.Text>,
},
{
title: '支持比例', dataIndex: 'supportedRatios', width: 180,
render: (ratios: string[]) => ratios.map(r => <Tag key={r}>{r}</Tag>),
},
{
title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150,
render: (res: string[]) => res.map(r => <Tag key={r} color="blue">{r}</Tag>),
},
{
title: '最大时长', dataIndex: 'maxDuration', width: 80,
render: (v: number) => `${v}s`,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
render: (_: any, r: VideoEngine) => (
<Space size={4}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}></Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Tag color="purple">{engines.length} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
</Button>
</div>
<Table
columns={columns}
dataSource={engines}
rowKey="id"
pagination={false}
scroll={{ x: 900 }}
/>
</Card>
<Modal
title={<Space><PlayCircleOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={560}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="name" label="引擎名称" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Input placeholder="Seedance 2.0" size="large" />
</Form.Item>
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
rules={[{ required: true }]}>
<Select size="large" options={[
{ value: 'seedance', label: 'Seedance (火山引擎)' },
{ value: 'kling', label: 'Kling (快手)' },
{ value: 'runway', label: 'Runway' },
{ value: 'pika', label: 'Pika' },
]} />
</Form.Item>
</div>
<Form.Item name="apiBase" label="API地址" rules={[{ required: true }]}>
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3" size="large" />
</Form.Item>
<Form.Item name="apiKey" label="API Key">
<Input.Password placeholder="sk-****" size="large" />
</Form.Item>
<Form.Item name="modelName" label="模型名称">
<Input placeholder="seedance-2.0" size="large" />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="supportedRatios" label="支持比例" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={[
{ value: '16:9' }, { value: '9:16' }, { value: '1:1' }, { value: '4:3' },
]} />
</Form.Item>
<Form.Item name="supportedResolutions" label="支持分辨率" style={{ flex: 1 }}>
<Select mode="multiple" size="large" options={[
{ value: '720p' }, { value: '1080p' }, { value: '4K' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
<InputNumber min={5} max={300} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<InputNumber min={0} max={10} style={{ width: '100%' }} size="large" />
</Form.Item>
<Form.Item name="isActive" label="启用" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
</div>
);
};
export default AdminVideoEngines;
+78
View File
@@ -0,0 +1,78 @@
import { create } from 'zustand';
import type { Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, Industry, MediaReference } from '../types';
import * as api from '../api';
import { useAuthStore } from './useAuthStore';
interface AppState {
projects: Project[];
records: GenerationRecord[];
loading: boolean;
fetchProjects: () => Promise<void>;
createProject: (name: string, industry: Industry) => Promise<Project>;
deleteProject: (id: string) => Promise<void>;
fetchRecords: (projectId?: string) => Promise<void>;
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>;
updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
}
export const useAppStore = create<AppState>((set, get) => ({
projects: [],
records: [],
loading: false,
fetchProjects: async () => {
set({ loading: true });
try {
const projects = await api.getProjects();
set({ projects, loading: false });
} catch {
set({ loading: false });
}
},
createProject: async (name, industry) => {
const project = await api.createProject(name, industry);
set({ projects: [project, ...get().projects] });
return project;
},
deleteProject: async (id) => {
await api.deleteProject(id);
set({ projects: get().projects.filter((p) => p.id !== id) });
},
fetchRecords: async (projectId) => {
set({ loading: true });
try {
const records = await api.getRecords(projectId);
set({ records, loading: false });
} catch {
set({ loading: false });
}
},
optimizePrompt: async (projectId, params) => {
const result = await api.optimizePrompt(projectId, params);
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
set({ records: [result.record, ...get().records] });
return result;
},
generateVideo: async (recordId, params) => {
const record = await api.generateVideo(recordId, params);
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
set({
records: get().records.map((r) => (r.id === recordId ? record : r)),
});
return record;
},
updateRecordReferences: (recordId, references) => {
set({
records: get().records.map((r) => (r.id === recordId ? { ...r, references } : r)),
});
},
}));
+40
View File
@@ -0,0 +1,40 @@
import { create } from 'zustand';
import type { User } from '../types';
import * as api from '../api';
interface AuthState {
user: User | null;
loading: boolean;
login: (username: string, password: string, captchaToken?: string, rememberMe?: boolean) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
changePassword: (oldPwd: string, newPwd: string) => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
loading: true,
login: async (username, password, captchaToken?, rememberMe?) => {
const user = await api.login(username, password, captchaToken, rememberMe);
set({ user });
},
logout: async () => {
await api.logout();
set({ user: null });
},
checkAuth: async () => {
try {
const user = await api.getUser();
set({ user, loading: false });
} catch {
set({ user: null, loading: false });
}
},
changePassword: async (oldPwd, newPwd) => {
await api.changePassword(oldPwd, newPwd);
},
}));
+157
View File
@@ -0,0 +1,157 @@
export interface User {
id: string;
username: string;
email: string;
avatar?: string;
credits: number;
allowedMenus?: string[] | null;
}
export interface CreditRecord {
id: string;
type: 'consume' | 'recharge';
amount: number;
description: string;
createdAt: string;
}
export type Industry = string;
export interface OptionGroup {
name: string;
options: string[];
}
export interface IndustryConfig {
id: string;
key: string;
label: string;
icon: string;
description: string;
skills: { key: string; label: string }[];
optionGroups?: OptionGroup[];
isActive: boolean;
sortOrder: number;
}
export interface Project {
id: string;
name: string;
industry: Industry;
createdAt: string;
updatedAt: string;
}
export type AspectRatio = '16:9' | '4:3' | '1:1' | '3:4' | '9:16' | '21:9' | string;
export type Resolution = '480p' | '720p' | '1080p' | string;
export type GenerationStatus =
| 'optimizing'
| 'prompt_optimized'
| 'generating'
| 'completed'
| 'failed';
export interface MediaReference {
url: string;
type: 'image' | 'video';
name: string;
}
export interface GenerationRecord {
id: string;
projectId: string;
projectName: string;
originalPrompt: string;
optimizedPrompt?: string;
duration?: number;
aspectRatio?: AspectRatio;
resolution?: Resolution;
status: GenerationStatus;
videoUrl?: string;
references?: MediaReference[];
textCreditsCost: number;
textTokensUsed: number;
creditsCost: number;
videoTokensUsed: number;
errorMessage?: string;
createdAt: string;
generatedAt?: string;
}
export interface OptimizeParams {
prompt: string;
duration: number;
references?: MediaReference[];
idempotencyKey?: string;
}
export interface GenerateParams {
aspectRatio: AspectRatio;
resolution: Resolution;
}
export interface OptimizeResult {
optimizedPrompt: string;
textCreditsCost: number;
textTokensUsed: number;
record: GenerationRecord;
}
export interface LoginParams {
username: string;
password: string;
}
// ── Admin Types ──────────────────────────────────────
export interface AdminUser {
id: string;
username: string;
email: string;
phone?: string;
credits: number;
isActive: boolean;
isAdmin: boolean;
userType: string;
createdAt: string;
lastLoginAt?: string;
}
export interface AdminStats {
totalUsers: number;
totalProjects: number;
totalGenerations: number;
totalRevenue: number;
creditsConsumedToday: number;
}
export interface ModelConfig {
id: string;
name: string;
provider: string;
apiBase: string;
apiKey: string;
modelName: string;
weight: number;
maxTokens: number;
temperature: number;
isActive: boolean;
priority: number;
}
export interface SystemConfig {
id: string;
key: string;
value: string;
description?: string;
}
export interface AdminNotification {
id: string;
title: string;
content: string;
type: string;
isRead: boolean;
createdAt: string;
}
+12
View File
@@ -0,0 +1,12 @@
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);
}