1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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="请输入文图理解提示词,例如: 你是一位专业的电商视频文案专家,擅长将产品卖点转化为视觉语言" 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;
|
||||
@@ -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)),
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user