1
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { 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 AdminLayout from './pages/AdminLayout';
|
||||
import AdminLoginPage from './pages/AdminLoginPage';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminUsers from './pages/AdminUsers';
|
||||
import AdminModels from './pages/AdminModels';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||
import AdminCreditRecords from './pages/AdminCreditRecords';
|
||||
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
||||
import AdminIndustries from './pages/AdminIndustries';
|
||||
import AdminVideoEngines from './pages/AdminVideoEngines';
|
||||
import AdminImageEngines from './pages/AdminImageEngines';
|
||||
import AdminCreditRatios from './pages/AdminCreditRatios';
|
||||
import AdminMenuConfig from './pages/AdminMenuConfig';
|
||||
import AdminRechargePackages from './pages/AdminRechargePackages';
|
||||
import AdminOperationLogs from './pages/AdminOperationLogs';
|
||||
import AdminGenerationRecords from './pages/AdminGenerationRecords';
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading, checkAuth } = useAdminStore();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token && !loading && !user) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, [user, loading]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
window.location.href = '/login';
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const { checkAuth } = useAdminStore();
|
||||
useEffect(() => { checkAuth(); }, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={{
|
||||
token: { colorPrimary: '#6366f1', borderRadius: 8 },
|
||||
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={<AdminLoginPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||
<Route path="models" element={<AdminModels />} />
|
||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||
<Route path="video-engines" element={<AdminVideoEngines />} />
|
||||
<Route path="image-engines" element={<AdminImageEngines />} />
|
||||
<Route path="industries" element={<AdminIndustries />} />
|
||||
<Route path="menu-configs" element={<AdminMenuConfig />} />
|
||||
<Route path="recharge-packages" element={<AdminRechargePackages />} />
|
||||
<Route path="payment" element={<AdminPaymentConfig />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
<Route path="generation-records" element={<AdminGenerationRecords />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { 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 AdminLayout from './pages/AdminLayout';
|
||||
import AdminLoginPage from './pages/AdminLoginPage';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminUsers from './pages/AdminUsers';
|
||||
import AdminModels from './pages/AdminModels';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||
import AdminCreditRecords from './pages/AdminCreditRecords';
|
||||
import AdminPaymentConfig from './pages/AdminPaymentConfig';
|
||||
import AdminIndustries from './pages/AdminIndustries';
|
||||
import AdminVideoEngines from './pages/AdminVideoEngines';
|
||||
import AdminImageEngines from './pages/AdminImageEngines';
|
||||
import AdminCreditRatios from './pages/AdminCreditRatios';
|
||||
import AdminMenuConfig from './pages/AdminMenuConfig';
|
||||
import AdminRechargePackages from './pages/AdminRechargePackages';
|
||||
import AdminOperationLogs from './pages/AdminOperationLogs';
|
||||
import AdminGenerationRecords from './pages/AdminGenerationRecords';
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { user, loading } = useAdminStore();
|
||||
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 } = useAdminStore();
|
||||
useEffect(() => { checkAuth(); }, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={{
|
||||
token: { colorPrimary: '#6366f1', borderRadius: 8 },
|
||||
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={<AdminLoginPage />} />
|
||||
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||
<Route path="models" element={<AdminModels />} />
|
||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||
<Route path="video-engines" element={<AdminVideoEngines />} />
|
||||
<Route path="industries" element={<AdminIndustries />} />
|
||||
<Route path="menu-configs" element={<AdminMenuConfig />} />
|
||||
<Route path="recharge-packages" element={<AdminRechargePackages />} />
|
||||
<Route path="payment" element={<AdminPaymentConfig />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
<Route path="generation-records" element={<AdminGenerationRecords />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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();
|
||||
window.location.href = '/login';
|
||||
}
|
||||
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,304 @@
|
||||
/**
|
||||
* Admin API layer - connects to the FastAPI backend.
|
||||
*/
|
||||
|
||||
import { api, setToken, clearToken } from './client';
|
||||
import type {
|
||||
User, CreditRecord, Project, GenerationRecord, GenerationParams,
|
||||
Industry, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||
} from '../types';
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
|
||||
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
|
||||
const res = await api.post<{ accessToken: string; user: User }>('/auth/admin-login', { username, password, captcha_token: captchaToken, remember_me: rememberMe || false }, false);
|
||||
setToken(res.accessToken);
|
||||
return res.user;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/auth/logout');
|
||||
clearToken();
|
||||
}
|
||||
|
||||
export async function getUser(): Promise<User | null> {
|
||||
try {
|
||||
return await api.get<User>('/auth/me');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(oldPwd: string, newPwd: string): Promise<void> {
|
||||
await api.post('/auth/change-password', { old_password: oldPwd, new_password: newPwd });
|
||||
}
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
return api.get<Project[]>('/projects');
|
||||
}
|
||||
|
||||
export async function createProject(name: string, industry: Industry): Promise<Project> {
|
||||
return api.post<Project>('/projects', { name, industry });
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
await api.delete(`/projects/${id}`);
|
||||
}
|
||||
|
||||
// ── Generation ────────────────────────────────────────────
|
||||
|
||||
export async function getRecords(projectId?: string): Promise<GenerationRecord[]> {
|
||||
const q = projectId ? `?project_id=${projectId}` : '';
|
||||
return api.get<GenerationRecord[]>(`/generation-records${q}`);
|
||||
}
|
||||
|
||||
export async function optimizePrompt(projectId: string, params: GenerationParams): Promise<any> {
|
||||
return api.post('/generation-records/optimize', { project_id: projectId, ...params });
|
||||
}
|
||||
|
||||
export async function generateVideo(recordId: string): Promise<GenerationRecord> {
|
||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`);
|
||||
}
|
||||
|
||||
// ── Credits ───────────────────────────────────────────────
|
||||
|
||||
export async function getCredits(): Promise<{ credits: number; records: CreditRecord[] }> {
|
||||
return api.get('/credits');
|
||||
}
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────
|
||||
|
||||
export async function getNotifications(): Promise<AdminNotification[]> {
|
||||
return api.get('/notifications');
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const res = await api.get<{ count: number }>('/notifications/unread-count');
|
||||
return res.count;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
await api.put(`/notifications/${id}/read`);
|
||||
}
|
||||
|
||||
// ── Admin ─────────────────────────────────────────────────
|
||||
|
||||
export async function getAdminStats(): Promise<AdminStats> {
|
||||
return api.get('/admin/stats');
|
||||
}
|
||||
|
||||
export async function getAdminUsers(search?: string): Promise<AdminUser[]> {
|
||||
const q = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||
return api.get(`/admin/users${q}`);
|
||||
}
|
||||
|
||||
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||
await api.post(`/admin/users/${userId}/credits`, { amount, description });
|
||||
}
|
||||
|
||||
export async function toggleUserStatus(userId: string, isActive: boolean): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/status`, { is_active: isActive });
|
||||
}
|
||||
|
||||
export async function getModelConfigs(): Promise<ModelConfig[]> {
|
||||
return api.get('/admin/model-configs');
|
||||
}
|
||||
|
||||
export async function saveModelConfig(config: Partial<ModelConfig> & { id?: string }): Promise<ModelConfig> {
|
||||
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> {
|
||||
await api.delete(`/admin/model-configs/${id}`);
|
||||
}
|
||||
|
||||
export async function getSystemConfigs(): Promise<SystemConfig[]> {
|
||||
return api.get('/admin/system-configs');
|
||||
}
|
||||
|
||||
export async function updateSystemConfig(id: string, value: string): Promise<void> {
|
||||
await api.put(`/admin/system-configs/${id}`, { value });
|
||||
}
|
||||
|
||||
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('config_key', configKey);
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const baseUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:8000/api';
|
||||
const res = await fetch(`${baseUrl}/admin/upload-pdf`, {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error('上传失败');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getCreditRecords(filters?: { user_id?: string; type?: string }): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.user_id) params.set('user_id', filters.user_id);
|
||||
if (filters?.type) params.set('type', filters.type);
|
||||
const q = params.toString() ? `?${params}` : '';
|
||||
return api.get(`/admin/credit-records${q}`);
|
||||
}
|
||||
|
||||
export async function getIndustryConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/industry-configs');
|
||||
}
|
||||
|
||||
export async function saveIndustryConfig(config: any): Promise<any> {
|
||||
if (config.id) return api.put(`/admin/industry-configs/${config.id}`, config);
|
||||
return api.post('/admin/industry-configs', config);
|
||||
}
|
||||
|
||||
export async function deleteIndustryConfig(id: string): Promise<void> {
|
||||
await api.delete(`/admin/industry-configs/${id}`);
|
||||
}
|
||||
|
||||
export async function getVideoEngines(): Promise<any[]> {
|
||||
return api.get('/admin/video-engines');
|
||||
}
|
||||
|
||||
export async function saveVideoEngine(engine: any): Promise<any> {
|
||||
if (engine.id) return api.put(`/admin/video-engines/${engine.id}`, engine);
|
||||
return api.post('/admin/video-engines', engine);
|
||||
}
|
||||
|
||||
export async function deleteVideoEngine(id: string): Promise<void> {
|
||||
await api.delete(`/admin/video-engines/${id}`);
|
||||
}
|
||||
|
||||
export async function getImageEngines(): Promise<any[]> {
|
||||
return api.get('/admin/image-engines');
|
||||
}
|
||||
|
||||
export async function saveImageEngine(engine: any): Promise<any> {
|
||||
if (engine.id) return api.put(`/admin/image-engines/${engine.id}`, engine);
|
||||
return api.post('/admin/image-engines', engine);
|
||||
}
|
||||
|
||||
export async function deleteImageEngine(id: string): Promise<void> {
|
||||
await api.delete(`/admin/image-engines/${id}`);
|
||||
}
|
||||
|
||||
export async function getCreditRatios(): Promise<any[]> {
|
||||
return api.get('/admin/credit-ratios');
|
||||
}
|
||||
|
||||
export async function saveCreditRatio(ratio: any): Promise<any> {
|
||||
if (ratio.id) return api.put(`/admin/credit-ratios/${ratio.id}`, ratio);
|
||||
return api.post('/admin/credit-ratios', ratio);
|
||||
}
|
||||
|
||||
export async function deleteCreditRatio(id: string): Promise<void> {
|
||||
await api.delete(`/admin/credit-ratios/${id}`);
|
||||
}
|
||||
|
||||
export async function getPaymentConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/payment-configs');
|
||||
}
|
||||
|
||||
export async function updatePaymentConfig(id: string, value: string): Promise<void> {
|
||||
await api.put(`/admin/payment-configs/${id}`, { value });
|
||||
}
|
||||
|
||||
export async function getAdminNotifications(): Promise<{ total: number; items: any[] }> {
|
||||
return api.get('/admin/notifications');
|
||||
}
|
||||
|
||||
export async function createAdminNotification(data: { title: string; content: string; type: string; target_user_id?: string }): Promise<void> {
|
||||
await api.post('/admin/notifications', data);
|
||||
}
|
||||
|
||||
export async function deleteAdminNotification(id: string): Promise<void> {
|
||||
await api.delete(`/admin/notifications/${id}`);
|
||||
}
|
||||
|
||||
export async function getNotificationReadUsers(id: string): Promise<{ total: number; items: { userId: string; username: string; readAt: string }[] }> {
|
||||
return api.get(`/admin/notifications/${id}/read-users`);
|
||||
}
|
||||
|
||||
// ── Menu Config ─────────────────────────────────────────
|
||||
|
||||
export async function getMenuConfigs(): Promise<any[]> {
|
||||
return api.get('/admin/menu-configs');
|
||||
}
|
||||
|
||||
export async function saveMenuConfig(config: any): Promise<any> {
|
||||
if (config.id) return api.put(`/admin/menu-configs/${config.id}`, config);
|
||||
return api.post('/admin/menu-configs', config);
|
||||
}
|
||||
|
||||
export async function deleteMenuConfig(id: string): Promise<void> {
|
||||
await api.delete(`/admin/menu-configs/${id}`);
|
||||
}
|
||||
|
||||
// ── User Creation ───────────────────────────────────────
|
||||
|
||||
export async function createUser(data: { username: string; password: string; email?: string; phone?: string; credits: number; user_type: string; allowed_menus?: string[] | null }): Promise<any> {
|
||||
return api.post('/admin/users', data);
|
||||
}
|
||||
|
||||
export async function updateUserMenus(userId: string, allowedMenus: string[] | null): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
|
||||
}
|
||||
|
||||
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
|
||||
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
|
||||
}
|
||||
|
||||
export async function adminChangePassword(oldPwd: string, newPwd: string): Promise<void> {
|
||||
await api.post('/admin/change-password', { old_password: oldPwd, new_password: newPwd });
|
||||
}
|
||||
|
||||
// ── Recharge Packages ───────────────────────────────────
|
||||
|
||||
export async function getRechargePackages(): Promise<any[]> {
|
||||
return api.get('/admin/recharge-packages');
|
||||
}
|
||||
|
||||
export async function saveRechargePackage(pkg: any): Promise<any> {
|
||||
if (pkg.id) return api.put(`/admin/recharge-packages/${pkg.id}`, pkg);
|
||||
return api.post('/admin/recharge-packages', pkg);
|
||||
}
|
||||
|
||||
export async function deleteRechargePackage(id: string): Promise<void> {
|
||||
await api.delete(`/admin/recharge-packages/${id}`);
|
||||
}
|
||||
|
||||
// ── Operation Logs ──────────────────────────────────────
|
||||
|
||||
export async function getOperationLogs(page?: number): Promise<{ total: number; items: any[] }> {
|
||||
const q = page ? `?page=${page}` : '';
|
||||
return api.get(`/admin/operation-logs${q}`);
|
||||
}
|
||||
|
||||
// ── Generation Records (Admin) ─────────────────────────────
|
||||
|
||||
export async function getAdminGenerationRecords(params?: {
|
||||
userId?: string; status?: string; page?: number; pageSize?: number;
|
||||
}): Promise<{ total: number; items: any[] }> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.userId) q.set('user_id', params.userId);
|
||||
if (params?.status) q.set('status', params.status);
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||
const qs = q.toString();
|
||||
return api.get(`/admin/generation-records${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function adminUpdateGenerationStatus(
|
||||
recordId: string, status: string, videoUrl?: string
|
||||
): Promise<void> {
|
||||
await api.put(`/admin/generation-records/${recordId}/status`, { status, video_url: videoUrl });
|
||||
}
|
||||
|
||||
export async function adminGenerateVideo(
|
||||
recordId: string, aspectRatio: string, resolution: string
|
||||
): Promise<void> {
|
||||
await api.post(`/admin/generation-records/${recordId}/generate`, { aspect_ratio: aspectRatio, resolution });
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string
|
||||
readonly VITE_USE_MOCK: string
|
||||
readonly VITE_ENCRYPTION_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getCreditRatios, saveCreditRatio, deleteCreditRatio, getModelConfigs, getSystemConfigs, updateSystemConfig } from '../api';
|
||||
import type { ModelConfig } from '../types';
|
||||
|
||||
interface CreditRatio {
|
||||
id: string;
|
||||
modelConfigId: string;
|
||||
genType: string;
|
||||
resolution: string;
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits: number;
|
||||
}
|
||||
|
||||
const AdminCreditRatios: React.FC = () => {
|
||||
const [ratios, setRatios] = useState<CreditRatio[]>([]);
|
||||
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
|
||||
const [form] = Form.useForm();
|
||||
const [textRate, setTextRate] = useState<number>(10);
|
||||
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
|
||||
const [savingTextRate, setSavingTextRate] = useState(false);
|
||||
const genType = Form.useWatch('genType', form);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ratioData, modelData, sysConfigs] = await Promise.all([
|
||||
getCreditRatios(),
|
||||
getModelConfigs(),
|
||||
getSystemConfigs(),
|
||||
]);
|
||||
setRatios(ratioData);
|
||||
setModels(modelData);
|
||||
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
|
||||
if (textCfg) {
|
||||
setTextRate(Number(textCfg.value) || 10);
|
||||
setTextRateConfig({ id: textCfg.id });
|
||||
}
|
||||
} catch {
|
||||
message.error('加载积分比例失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const modelNameOf = (id: string) => models.find(m => m.id === id)?.name || id;
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
model_config_id: values.modelConfigId,
|
||||
gen_type: values.genType,
|
||||
resolution: values.resolution,
|
||||
ratio: values.ratio,
|
||||
base_credits: values.baseCredits,
|
||||
per_second_credits: values.genType === 'image' ? 0 : values.perSecondCredits,
|
||||
};
|
||||
if (modal.ratio) {
|
||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveCreditRatio(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, ratio: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteCreditRatio(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTextRate = async () => {
|
||||
if (!textRateConfig) return;
|
||||
setSavingTextRate(true);
|
||||
try {
|
||||
await updateSystemConfig(textRateConfig.id, String(textRate));
|
||||
message.success('文字积分费率已更新');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSavingTextRate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (ratio?: CreditRatio) => {
|
||||
setModal({ open: true, ratio: ratio || null });
|
||||
if (ratio) {
|
||||
form.setFieldsValue(ratio);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '类型', dataIndex: 'genType', width: 80,
|
||||
render: (v: string) => <Tag color={v === 'image' ? 'cyan' : 'orange'}>{v === 'image' ? '图片' : '视频'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '模型', dataIndex: 'modelConfigId', width: 150,
|
||||
render: (v: string) => <Tag color="purple">{modelNameOf(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '分辨率/尺寸', dataIndex: 'resolution', width: 110,
|
||||
render: (v: string) => {
|
||||
const colors: Record<string, string> = { '480p': 'blue', '720p': 'blue', '1080p': 'blue', '4K': 'green', '2K': 'green' };
|
||||
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: '示例计算', key: 'example', width: 120,
|
||||
render: (_: any, r: CreditRatio) => {
|
||||
let total: number;
|
||||
if (r.genType === 'image') {
|
||||
total = Math.round(r.baseCredits * r.ratio);
|
||||
} else {
|
||||
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 style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Text Credit Rate */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>文字积分费率</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
|
||||
文字积分计算公式:ceil(总token数 x 费率 / 1000),最低1积分
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Typography.Text>每1000 token消耗积分:</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000}
|
||||
step={0.01}
|
||||
value={textRate}
|
||||
onChange={(v) => setTextRate(v || 0)}
|
||||
size="large"
|
||||
style={{ width: 160 }}
|
||||
addonAfter="积分"
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Video Credit Ratios */}
|
||||
<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"
|
||||
loading={loading}
|
||||
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="modelConfigId" label="模型" rules={[{ required: true, message: '请选择模型' }]}>
|
||||
<Select size="large" options={models.map(m => ({ value: m.id, label: m.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="genType" label="生成类型" rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'image', label: '图片' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="resolution" label={genType === 'image' ? '图片尺寸' : '分辨率'} rules={[{ required: true }]}>
|
||||
<Select size="large" options={genType === 'image' ? [
|
||||
{ value: '2K', label: '2K' },
|
||||
{ value: '4K', label: '4K' },
|
||||
] : [
|
||||
{ value: '480p', label: '480p' },
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
]} />
|
||||
</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>
|
||||
{genType !== 'image' && (
|
||||
<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,161 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getCreditRecords } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface CreditRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const AdminCreditRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<CreditRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
|
||||
const load = async (type?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getCreditRecords(type ? { type } : undefined);
|
||||
setRecords(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
message.error('加载积分记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleTypeFilter = (value: string) => {
|
||||
setTypeFilter(value);
|
||||
load(value || undefined);
|
||||
};
|
||||
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 }}>{formatDate(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' }}>{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={handleTypeFilter}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load(typeFilter || undefined)}>刷新</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 条记录` }}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCreditRecords;
|
||||
@@ -0,0 +1,92 @@
|
||||
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);
|
||||
try {
|
||||
const data = await getAdminStats();
|
||||
setStats(data);
|
||||
} catch { /* auth error handled by client */ }
|
||||
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: 'PostgreSQL' },
|
||||
{ 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>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -0,0 +1,455 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Input, message, Modal, Select, Space, Table, Tag, Typography, Tooltip, Image,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined, EyeOutlined, ClockCircleOutlined, CheckCircleOutlined,
|
||||
LoadingOutlined, CloseCircleOutlined, SearchOutlined, VideoCameraOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
|
||||
import type { AdminGenerationRecord } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const apiUrl = (url?: string) => url ? (url.startsWith('http') ? url : `${API_BASE}${url}`) : '';
|
||||
|
||||
const STATUS_MAP: Record<string, { 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 AdminGenerationRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(20);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
const [filterUserId, setFilterUserId] = useState<string>('');
|
||||
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string } | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminGenerationRecords({
|
||||
userId: filterUserId || undefined,
|
||||
status: filterStatus || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setRecords(res.items.map((item: any) => ({
|
||||
id: item.id,
|
||||
userId: item.userId,
|
||||
username: item.username,
|
||||
projectId: item.projectId,
|
||||
projectName: item.projectName,
|
||||
originalPrompt: item.originalPrompt,
|
||||
optimizedPrompt: item.optimizedPrompt,
|
||||
duration: item.duration,
|
||||
aspectRatio: item.aspectRatio,
|
||||
resolution: item.resolution,
|
||||
status: item.status,
|
||||
videoUrl: item.videoUrl,
|
||||
references: item.references,
|
||||
creditsCost: item.creditsCost,
|
||||
textCreditsCost: item.textCreditsCost || 0,
|
||||
textTokensUsed: item.textTokensUsed || 0,
|
||||
videoTokensUsed: item.videoTokensUsed || 0,
|
||||
errorMessage: item.errorMessage,
|
||||
createdAt: item.createdAt,
|
||||
generatedAt: item.generatedAt,
|
||||
})));
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
message.error('加载记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, filterStatus]);
|
||||
|
||||
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
|
||||
setUpdating(recordId);
|
||||
try {
|
||||
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
|
||||
message.success('状态已更新');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!genModal) return;
|
||||
setUpdating(genModal.record.id);
|
||||
try {
|
||||
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution);
|
||||
message.success('已提交视频生成');
|
||||
setGenModal(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '生成失败');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '用户', key: 'user', width: 120,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>{r.username}</Typography.Text>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8' }}>{r.userId.slice(0, 8)}...</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
||||
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '提示词', key: 'prompt', ellipsis: true,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<Tooltip title={r.originalPrompt} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12, color: '#475569' }} ellipsis>
|
||||
{r.originalPrompt}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '参数', key: 'params', width: 140,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
r.duration ? (
|
||||
<Space size={4} wrap>
|
||||
<Tag>{r.duration}s</Tag>
|
||||
<Tag>{r.aspectRatio}</Tag>
|
||||
<Tag>{r.resolution}</Tag>
|
||||
</Space>
|
||||
) : <Tag color="default">待配置</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分', key: 'credits', width: 120,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
{r.textCreditsCost > 0 && (
|
||||
<div style={{ color: '#f59e0b' }}>文字: {r.textCreditsCost}</div>
|
||||
)}
|
||||
{r.creditsCost > 0 && (
|
||||
<div style={{ color: '#6366f1' }}>视频: {r.creditsCost}</div>
|
||||
)}
|
||||
{r.textCreditsCost === 0 && r.creditsCost === 0 && (
|
||||
<Typography.Text style={{ color: '#94a3b8' }}>0</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render: (v: string) => {
|
||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v, icon: null };
|
||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '时间', key: 'time', width: 140,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>
|
||||
<div>{formatDate(r.createdAt)}</div>
|
||||
{r.generatedAt && <div style={{ color: '#10b981' }}>生成: {formatDate(r.generatedAt)}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<Space size={4} wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => setPreview(r)}>
|
||||
详情
|
||||
</Button>
|
||||
{r.status === 'generating' && (
|
||||
<Button size="small" danger loading={updating === r.id}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定将此记录标记为失败?',
|
||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
||||
});
|
||||
}}>
|
||||
标记失败
|
||||
</Button>
|
||||
)}
|
||||
{r.status === 'failed' && (
|
||||
<Button size="small" type="primary" danger loading={updating === r.id}
|
||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p' })}>
|
||||
重试生成
|
||||
</Button>
|
||||
)}
|
||||
{r.status === 'prompt_optimized' && (
|
||||
<>
|
||||
<Button size="small" type="primary" loading={updating === r.id}
|
||||
onClick={() => setGenModal({ record: r, ratio: '16:9', resolution: '720p' })}
|
||||
style={{ background: '#6366f1', border: 'none' }}>
|
||||
生成视频
|
||||
</Button>
|
||||
<Button size="small" danger loading={updating === r.id}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定将此记录标记为失败?',
|
||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
||||
});
|
||||
}}>
|
||||
标记失败
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>生成记录管理</Typography.Text>
|
||||
<Tag color="purple">{total} 条记录</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus || undefined}
|
||||
onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
|
||||
options={[
|
||||
{ value: 'optimizing', label: '优化中' },
|
||||
{ value: 'prompt_optimized', label: '待生成' },
|
||||
{ value: 'generating', label: '生成中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID搜索"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
style={{ width: 200 }}
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
onPressEnter={() => { setPage(1); load(); }}
|
||||
allowClear
|
||||
/>
|
||||
<Button type="primary" onClick={() => { setPage(1); load(); }} style={{ borderRadius: 8 }}>
|
||||
搜索
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: setPage,
|
||||
showSizeChanger: false,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Detail modal */}
|
||||
<Modal
|
||||
title={<Space><EyeOutlined />生成记录详情</Space>}
|
||||
open={!!preview}
|
||||
onCancel={() => setPreview(null)}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
{preview && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
||||
{/* User & Project info */}
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>用户</Typography.Text>
|
||||
<Typography.Text strong>{preview.username}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>项目</Typography.Text>
|
||||
<Typography.Text strong>{preview.projectName}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>状态</Typography.Text>
|
||||
<Tag color={STATUS_MAP[preview.status]?.color} icon={STATUS_MAP[preview.status]?.icon}>
|
||||
{STATUS_MAP[preview.status]?.text}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompts */}
|
||||
<div>
|
||||
<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: '#475569', lineHeight: 1.7 }}>{preview.originalPrompt}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>优化后提示词</Typography.Text>
|
||||
<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 }}>{preview.optimizedPrompt}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits */}
|
||||
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>文字积分</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#f59e0b' }}>{preview.textCreditsCost}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.textTokensUsed} tokens)</Typography.Text>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>视频积分</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#6366f1' }}>{preview.creditsCost}</Typography.Text>
|
||||
{preview.videoTokensUsed > 0 && (
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8' }}> ({preview.videoTokensUsed} tokens)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>总积分</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>{preview.textCreditsCost + preview.creditsCost}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Params */}
|
||||
{preview.duration ? (
|
||||
<div style={{ display: 'flex', gap: 16, padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
{[
|
||||
{ label: '时长', value: `${preview.duration}秒` },
|
||||
{ label: '比例', value: preview.aspectRatio },
|
||||
{ label: '分辨率', value: preview.resolution },
|
||||
].map((item, i) => (
|
||||
<div key={i} style={{ flex: 1 }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>{item.label}</Typography.Text>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>{item.value}</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc', textAlign: 'center' }}>
|
||||
<Tag color="default">视频参数待用户配置</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference images */}
|
||||
{preview.references && preview.references.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>参考内容</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{preview.references.map((ref, i) => (
|
||||
<div key={i} style={{
|
||||
width: 64, height: 64, borderRadius: 10, overflow: 'hidden',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
{ref.type === 'image' ? (
|
||||
<Image src={apiUrl(ref.url)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%', background: '#1a1a2e',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 10,
|
||||
}}>
|
||||
视频
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video preview */}
|
||||
{preview.status === 'completed' && preview.videoUrl && (
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>生成视频</Typography.Text>
|
||||
<video
|
||||
src={apiUrl(preview.videoUrl)}
|
||||
controls
|
||||
style={{ width: '100%', maxHeight: 360, borderRadius: 12, background: '#000' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{preview.status === 'failed' && preview.errorMessage && (
|
||||
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: '#94a3b8' }}>
|
||||
<span>创建: {formatDate(preview.createdAt)}</span>
|
||||
{preview.generatedAt && <span>生成: {formatDate(preview.generatedAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Generate modal */}
|
||||
<Modal
|
||||
title={<Space><PlayCircleOutlined />生成视频</Space>}
|
||||
open={!!genModal}
|
||||
onCancel={() => setGenModal(null)}
|
||||
onOk={handleGenerate}
|
||||
okText="提交生成"
|
||||
cancelText="取消"
|
||||
confirmLoading={genModal ? updating === genModal.record.id : false}
|
||||
width={420}
|
||||
>
|
||||
{genModal && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>时长</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.duration || 5}s</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 AdminGenerationRecords;
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
|
||||
|
||||
interface ImageEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
supportedModels: string[];
|
||||
supportedSizes: Record<string, Record<string, string>>;
|
||||
defaultSize: string;
|
||||
generateUrl: string;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function parseJsonArray(val: unknown): any[] {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val); } catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseSizes(val: unknown): Record<string, Record<string, string>> {
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record<string, Record<string, string>>;
|
||||
if (typeof val === 'string') {
|
||||
try { const p = JSON.parse(val); return (p && typeof p === 'object' && !Array.isArray(p)) ? p : {}; } catch { return {}; }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Default size options with pixel mappings
|
||||
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
||||
"2K": {
|
||||
"1:1": "2048×2048",
|
||||
"4:3": "2304×1728",
|
||||
"3:4": "1728×2304",
|
||||
"16:9": "2560×1440",
|
||||
"9:16": "1600×2848",
|
||||
"3:2": "2496×1664",
|
||||
"2:3": "1664×2496",
|
||||
"21:9": "3024×1296",
|
||||
},
|
||||
"4K": {
|
||||
"1:1": "4096×4096",
|
||||
"4:3": "4608×3456",
|
||||
"3:4": "3520×4704",
|
||||
"16:9": "5404×3040",
|
||||
"9:16": "3040×5504",
|
||||
"3:2": "4992×3328",
|
||||
"2:3": "3328×4992",
|
||||
"21:9": "6197×2656",
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
|
||||
|
||||
const AdminImageEngines: React.FC = () => {
|
||||
const [engines, setEngines] = useState<ImageEngine[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getImageEngines();
|
||||
setEngines(data.map((e: any) => ({
|
||||
...e,
|
||||
supportedModels: parseJsonArray(e.supportedModels),
|
||||
supportedSizes: parseSizes(e.supportedSizes),
|
||||
})));
|
||||
} catch {
|
||||
message.error('加载图片引擎失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// Build supportedSizes from form values
|
||||
const sizes: Record<string, Record<string, string>> = {};
|
||||
for (const tier of ["2K", "4K"]) {
|
||||
const selected: string[] = values[`size_${tier}`] || [];
|
||||
if (selected.length > 0) {
|
||||
sizes[tier] = {};
|
||||
for (const ratio of selected) {
|
||||
sizes[tier][ratio] = SIZE_OPTIONS[tier]?.[ratio] || ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
api_base: values.apiBase,
|
||||
api_key: values.apiKey,
|
||||
model_name: values.modelName,
|
||||
supported_models: JSON.stringify(values.supportedModels || []),
|
||||
supported_sizes: JSON.stringify(sizes),
|
||||
default_size: values.defaultSize || '2K',
|
||||
generate_url: values.generateUrl || '',
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
if (modal.engine) {
|
||||
await saveImageEngine({ id: modal.engine.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveImageEngine(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, engine: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteImageEngine(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (engine?: ImageEngine) => {
|
||||
setModal({ open: true, engine: engine || null });
|
||||
if (engine) {
|
||||
const sizeFields: Record<string, string[]> = {};
|
||||
for (const tier of ["2K", "4K"]) {
|
||||
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
||||
}
|
||||
form.setFieldsValue({
|
||||
...engine,
|
||||
...sizeFields,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||
defaultSize: '2K',
|
||||
size_2K: ALL_RATIOS,
|
||||
size_4K: ALL_RATIOS,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '引擎名称', key: 'name', width: 180,
|
||||
render: (_: any, r: ImageEngine) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: r.isActive
|
||||
? 'linear-gradient(135deg, #10b981, #059669)'
|
||||
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 16,
|
||||
}}><PictureOutlined /></div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.name}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
||||
render: (_: any, r: ImageEngine) => {
|
||||
const ratios = Object.keys(r.supportedSizes?.["2K"] || {});
|
||||
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
return <Space size={2} wrap>{ratios.map(ratio => (
|
||||
<Tag key={ratio} color="blue">{ratio} {r.supportedSizes["2K"][ratio]}</Tag>
|
||||
))}</Space>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '4K 支持比例', key: 'sizes_4k', width: 260,
|
||||
render: (_: any, r: ImageEngine) => {
|
||||
const ratios = Object.keys(r.supportedSizes?.["4K"] || {});
|
||||
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
return <Space size={2} wrap>{ratios.map(ratio => (
|
||||
<Tag key={ratio} color="purple">{ratio} {r.supportedSizes["4K"][ratio]}</Tag>
|
||||
))}</Space>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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: ImageEngine) => (
|
||||
<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 style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
|
||||
<Tag color="green">{engines.length} 个引擎</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加引擎
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={engines}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={680}
|
||||
>
|
||||
<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="豆包文生图" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'ark', label: '火山引擎 (Ark)' },
|
||||
]} />
|
||||
</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="doubao-seedream-5-0-260128" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedModels" label="支持模型列表">
|
||||
<Select mode="tags" size="large" placeholder="输入模型ID后回车添加" tokenSeparators={[',', ',']}
|
||||
options={[{ value: 'doubao-seedream-5-0-260128' }]} />
|
||||
</Form.Item>
|
||||
|
||||
{/* Size config */}
|
||||
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>尺寸配置</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
|
||||
勾选每个档位支持的比例,前台选择后传对应像素值给SDK
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{["2K", "4K"].map(tier => (
|
||||
<div key={tier} style={{
|
||||
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
||||
marginBottom: 12, border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<Typography.Text strong style={{ fontSize: 13, color: tier === '2K' ? '#3b82f6' : '#8b5cf6' }}>
|
||||
{tier}
|
||||
</Typography.Text>
|
||||
<Form.Item name={`size_${tier}`} style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px 0' }}>
|
||||
{ALL_RATIOS.map(ratio => (
|
||||
<Checkbox key={ratio} value={ratio} style={{ fontSize: 12 }}>
|
||||
{ratio} <span style={{ color: '#94a3b8', fontSize: 11 }}>{SIZE_OPTIONS[tier]?.[ratio]}</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Form.Item name="defaultSize" label="默认尺寸档位">
|
||||
<Select size="large" options={[
|
||||
{ value: '2K', label: '2K' },
|
||||
{ value: '4K', label: '4K' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="generateUrl" label="生成接口地址">
|
||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' }, { value: 2, label: '2' }, { value: 3, label: '3' },
|
||||
{ value: 5, label: '5' }, { value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminImageEngines;
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
|
||||
|
||||
interface ImageEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
supportedSizes: string[];
|
||||
supportedStyles: string[];
|
||||
generateUrl: string;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function parseJsonArray(val: unknown): any[] {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val); } catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const AdminImageEngines: React.FC = () => {
|
||||
const [engines, setEngines] = useState<ImageEngine[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getImageEngines();
|
||||
setEngines(data.map((e: any) => ({
|
||||
...e,
|
||||
supportedSizes: parseJsonArray(e.supportedSizes),
|
||||
supportedStyles: parseJsonArray(e.supportedStyles),
|
||||
})));
|
||||
} catch {
|
||||
message.error('加载图片引擎失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
api_base: values.apiBase,
|
||||
api_key: values.apiKey,
|
||||
model_name: values.modelName,
|
||||
supported_sizes: JSON.stringify(values.supportedSizes || []),
|
||||
supported_styles: JSON.stringify(values.supportedStyles || []),
|
||||
generate_url: values.generateUrl || '',
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
if (modal.engine) {
|
||||
await saveImageEngine({ id: modal.engine.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveImageEngine(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, engine: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteImageEngine(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (engine?: ImageEngine) => {
|
||||
setModal({ open: true, engine: engine || null });
|
||||
if (engine) {
|
||||
form.setFieldsValue(engine);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
supportedSizes: ['1024x1024', '1024x1792', '1792x1024'],
|
||||
supportedStyles: ['realistic', 'anime', 'oil_painting'],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '引擎名称', key: 'name', width: 180,
|
||||
render: (_: any, r: ImageEngine) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: r.isActive
|
||||
? 'linear-gradient(135deg, #10b981, #059669)'
|
||||
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 16,
|
||||
}}><PictureOutlined /></div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.name}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.provider}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '支持尺寸', dataIndex: 'supportedSizes', width: 280,
|
||||
render: (sizes: string[]) => <Space size={2} wrap>{sizes.map(s => <Tag key={s}>{s}</Tag>)}</Space>,
|
||||
},
|
||||
{
|
||||
title: '支持风格', dataIndex: 'supportedStyles', width: 280,
|
||||
render: (styles: string[]) => <Space size={2} wrap>{styles.map(s => <Tag key={s} color="blue">{s}</Tag>)}</Space>,
|
||||
},
|
||||
{
|
||||
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: ImageEngine) => (
|
||||
<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>
|
||||
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
|
||||
<Tag color="green">{engines.length} 个引擎</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加引擎
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={engines}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={620}
|
||||
>
|
||||
<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="豆包文生图" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'ark', label: '火山引擎 (Ark)' },
|
||||
]} />
|
||||
</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="doubao-seedream-3-0-t2i-250415" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="generateUrl" label="生成接口地址">
|
||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedSizes" label="支持尺寸">
|
||||
<Select mode="tags" size="large" placeholder="输入尺寸后回车添加" tokenSeparators={[',', ',']}
|
||||
options={[
|
||||
{ value: '1024x1024' }, { value: '1024x1792' }, { value: '1792x1024' },
|
||||
{ value: '512x512' }, { value: '768x768' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedStyles" label="支持风格">
|
||||
<Select mode="tags" size="large" placeholder="输入风格后回车添加" tokenSeparators={[',', ',']}
|
||||
options={[
|
||||
{ value: 'realistic', label: '写实' },
|
||||
{ value: 'anime', label: '动漫' },
|
||||
{ value: 'oil_painting', label: '油画' },
|
||||
{ value: 'watercolor', label: '水彩' },
|
||||
{ value: 'sketch', label: '素描' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminImageEngines;
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getImageEngines, saveImageEngine, deleteImageEngine } from '../api';
|
||||
|
||||
interface ImageEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
supportedModels: string[];
|
||||
supportedSizes: Record<string, Record<string, string>>;
|
||||
defaultSize: string;
|
||||
generateUrl: string;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function parseJsonArray(val: unknown): any[] {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val); } catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseSizes(val: unknown): Record<string, Record<string, string>> {
|
||||
if (val && typeof val === 'object' && !Array.isArray(val)) return val as Record<string, Record<string, string>>;
|
||||
if (typeof val === 'string') {
|
||||
try { const p = JSON.parse(val); return (p && typeof p === 'object' && !Array.isArray(p)) ? p : {}; } catch { return {}; }
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Default size options with pixel mappings
|
||||
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
||||
"2K": {
|
||||
"1:1": "2048×2048",
|
||||
"4:3": "2304×1728",
|
||||
"3:4": "1728×2304",
|
||||
"16:9": "2560×1440",
|
||||
"9:16": "1600×2848",
|
||||
"3:2": "2496×1664",
|
||||
"2:3": "1664×2496",
|
||||
"21:9": "3024×1296",
|
||||
},
|
||||
"4K": {
|
||||
"1:1": "4096×4096",
|
||||
"4:3": "4608×3456",
|
||||
"3:4": "3520×4704",
|
||||
"16:9": "5404×3040",
|
||||
"9:16": "3040×5504",
|
||||
"3:2": "4992×3328",
|
||||
"2:3": "3328×4992",
|
||||
"21:9": "6197×2656",
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "21:9"];
|
||||
|
||||
const AdminImageEngines: React.FC = () => {
|
||||
const [engines, setEngines] = useState<ImageEngine[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getImageEngines();
|
||||
setEngines(data.map((e: any) => ({
|
||||
...e,
|
||||
supportedModels: parseJsonArray(e.supportedModels),
|
||||
supportedSizes: parseSizes(e.supportedSizes),
|
||||
})));
|
||||
} catch {
|
||||
message.error('加载图片引擎失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// Build supportedSizes from form values
|
||||
const sizes: Record<string, Record<string, string>> = {};
|
||||
for (const tier of ["2K", "4K"]) {
|
||||
const selected: string[] = values[`size_${tier}`] || [];
|
||||
if (selected.length > 0) {
|
||||
sizes[tier] = {};
|
||||
for (const ratio of selected) {
|
||||
sizes[tier][ratio] = SIZE_OPTIONS[tier]?.[ratio] || ratio;
|
||||
}
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
api_base: values.apiBase,
|
||||
api_key: values.apiKey,
|
||||
model_name: values.modelName,
|
||||
supported_models: JSON.stringify(values.supportedModels || []),
|
||||
supported_sizes: JSON.stringify(sizes),
|
||||
default_size: values.defaultSize || '2K',
|
||||
generate_url: values.generateUrl || '',
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
if (modal.engine) {
|
||||
await saveImageEngine({ id: modal.engine.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveImageEngine(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, engine: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteImageEngine(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (engine?: ImageEngine) => {
|
||||
setModal({ open: true, engine: engine || null });
|
||||
if (engine) {
|
||||
const sizeFields: Record<string, string[]> = {};
|
||||
for (const tier of ["2K", "4K"]) {
|
||||
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
||||
}
|
||||
form.setFieldsValue({
|
||||
...engine,
|
||||
...sizeFields,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||
defaultSize: '2K',
|
||||
size_2K: ALL_RATIOS,
|
||||
size_4K: ALL_RATIOS,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '引擎名称', key: 'name', width: 180,
|
||||
render: (_: any, r: ImageEngine) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 8,
|
||||
background: r.isActive
|
||||
? 'linear-gradient(135deg, #10b981, #059669)'
|
||||
: 'linear-gradient(135deg, #94a3b8, #cbd5e1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: '#fff', fontSize: 16,
|
||||
}}><PictureOutlined /></div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.name}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.modelName}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
||||
render: (_: any, r: ImageEngine) => {
|
||||
const ratios = Object.keys(r.supportedSizes?.["2K"] || {});
|
||||
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
return <Space size={2} wrap>{ratios.map(ratio => (
|
||||
<Tag key={ratio} color="blue">{ratio} {r.supportedSizes["2K"][ratio]}</Tag>
|
||||
))}</Space>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '4K 支持比例', key: 'sizes_4k', width: 260,
|
||||
render: (_: any, r: ImageEngine) => {
|
||||
const ratios = Object.keys(r.supportedSizes?.["4K"] || {});
|
||||
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
return <Space size={2} wrap>{ratios.map(ratio => (
|
||||
<Tag key={ratio} color="purple">{ratio} {r.supportedSizes["4K"][ratio]}</Tag>
|
||||
))}</Space>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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: ImageEngine) => (
|
||||
<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 style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<PictureOutlined style={{ fontSize: 18, color: '#10b981' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>图片引擎配置</Typography.Text>
|
||||
<Tag color="green">{engines.length} 个引擎</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加引擎
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={engines}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><PictureOutlined />{modal.engine ? '编辑引擎' : '添加引擎'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, engine: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={680}
|
||||
>
|
||||
<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="豆包文生图" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="提供商" style={{ flex: 1 }}
|
||||
rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'ark', label: '火山引擎 (Ark)' },
|
||||
]} />
|
||||
</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="doubao-seedream-5-0-260128" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedModels" label="支持模型列表">
|
||||
<Select mode="tags" size="large" placeholder="输入模型ID后回车添加" tokenSeparators={[',', ',']}
|
||||
options={[{ value: 'doubao-seedream-5-0-260128' }]} />
|
||||
</Form.Item>
|
||||
|
||||
{/* Size config */}
|
||||
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 8 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>尺寸配置</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', marginLeft: 8 }}>
|
||||
勾选每个档位支持的比例,前台选择后传对应像素值给SDK
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{["2K", "4K"].map(tier => (
|
||||
<div key={tier} style={{
|
||||
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
||||
marginBottom: 12, border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<Typography.Text strong style={{ fontSize: 13, color: tier === '2K' ? '#3b82f6' : '#8b5cf6' }}>
|
||||
{tier}
|
||||
</Typography.Text>
|
||||
<Form.Item name={`size_${tier}`} style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
<Checkbox.Group style={{ width: '100%' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '6px 0' }}>
|
||||
{ALL_RATIOS.map(ratio => (
|
||||
<Checkbox key={ratio} value={ratio} style={{ fontSize: 12 }}>
|
||||
{ratio} <span style={{ color: '#94a3b8', fontSize: 11 }}>{SIZE_OPTIONS[tier]?.[ratio]}</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Form.Item name="defaultSize" label="默认尺寸档位">
|
||||
<Select size="large" options={[
|
||||
{ value: '2K', label: '2K' },
|
||||
{ value: '4K', label: '4K' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="generateUrl" label="生成接口地址">
|
||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' }, { value: 2, label: '2' }, { value: 3, label: '3' },
|
||||
{ value: 5, label: '5' }, { value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminImageEngines;
|
||||
@@ -0,0 +1,496 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
|
||||
} from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
|
||||
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, PlayCircleOutlined, 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 { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
|
||||
|
||||
interface OptionGroup {
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface IndustryItem {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
skills: string;
|
||||
optionGroups: OptionGroup[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// Icon library for industries
|
||||
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
|
||||
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
|
||||
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
|
||||
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
|
||||
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
|
||||
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
|
||||
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
|
||||
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
|
||||
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
|
||||
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
|
||||
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
|
||||
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
|
||||
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
|
||||
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
|
||||
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
|
||||
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
|
||||
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
|
||||
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
|
||||
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
|
||||
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
|
||||
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
|
||||
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
|
||||
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
|
||||
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
|
||||
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
|
||||
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
|
||||
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
|
||||
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
|
||||
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
|
||||
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
|
||||
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
|
||||
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
|
||||
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
|
||||
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
|
||||
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
|
||||
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
|
||||
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
|
||||
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
|
||||
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
|
||||
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
|
||||
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
|
||||
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
|
||||
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
|
||||
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
|
||||
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
|
||||
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
|
||||
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
|
||||
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
|
||||
];
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
|
||||
ICON_OPTIONS.map(o => [o.name, o.component])
|
||||
);
|
||||
|
||||
function getIconComponent(name: string): React.ReactNode {
|
||||
return iconMap[name] || <AppstoreOutlined />;
|
||||
}
|
||||
|
||||
// Parse skills JSON into { skillItems, optionGroups }
|
||||
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
|
||||
const skillItems: { key: string; label: string }[] = [];
|
||||
const optionGroups: OptionGroup[] = [];
|
||||
for (const item of arr) {
|
||||
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
|
||||
optionGroups.push({ name: item.name, options: item.options });
|
||||
} else if (item && item.key && item.label) {
|
||||
skillItems.push({ key: item.key, label: item.label });
|
||||
}
|
||||
}
|
||||
return { skillItems, optionGroups };
|
||||
} catch {
|
||||
return { skillItems: [], optionGroups: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Picker Component
|
||||
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const selected = ICON_OPTIONS.find(o => o.name === value);
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 12, padding: 12, width: 360,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
|
||||
{ICON_OPTIONS.map(opt => (
|
||||
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
|
||||
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
|
||||
background: value === opt.name ? '#6366f1' : 'transparent',
|
||||
color: value === opt.name ? '#fff' : '#64748b',
|
||||
border: value === opt.name ? 'none' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
|
||||
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{opt.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
||||
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
|
||||
}}>
|
||||
{value ? (
|
||||
<>
|
||||
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
|
||||
)}
|
||||
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminIndustries: React.FC = () => {
|
||||
const [industries, setIndustries] = useState<IndustryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getIndustryConfigs();
|
||||
setIndustries(data.map((item: any) => {
|
||||
const { skillItems, optionGroups } = parseSkills(item.skills);
|
||||
return {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description || '',
|
||||
skills: JSON.stringify(skillItems),
|
||||
optionGroups,
|
||||
isActive: item.is_active ?? item.isActive ?? true,
|
||||
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
|
||||
};
|
||||
}));
|
||||
} catch {
|
||||
message.error('加载行业配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// Build skills array: skill items + option groups
|
||||
const skillsArr: any[] = [];
|
||||
if (values.skills_wentutujie?.trim()) {
|
||||
skillsArr.push({ type: 'skill', key: '文图理解生成视频提示词', label: values.skills_wentutujie.trim() });
|
||||
}
|
||||
if (values.skills_wentutujie_image?.trim()) {
|
||||
skillsArr.push({ type: 'skill', key: '文图理解生成图片提示词', label: values.skills_wentutujie_image.trim() });
|
||||
}
|
||||
// Add option groups
|
||||
const groups: 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);
|
||||
for (const g of groups) {
|
||||
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
key: values.key,
|
||||
label: values.label,
|
||||
icon: values.icon || '',
|
||||
description: values.description || '',
|
||||
skills: skillsArr,
|
||||
is_active: values.is_active ?? true,
|
||||
sort_order: values.sort_order ?? 0,
|
||||
};
|
||||
if (modal.item?.id) {
|
||||
await saveIndustryConfig({ id: modal.item.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveIndustryConfig(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteIndustryConfig(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item?: IndustryItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
let wentutujie = '';
|
||||
let wentutujieImage = '';
|
||||
let optionGroups: OptionGroup[] = [];
|
||||
if (item) {
|
||||
// Parse skills for 文图理解 video and image prompts
|
||||
try {
|
||||
const arr = JSON.parse(item.skills);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const s of arr) {
|
||||
if (s.key === '文图理解生成视频提示词' || s.key === '文图理解') { wentutujie = s.label || ''; }
|
||||
if (s.key === '文图理解生成图片提示词') { wentutujieImage = s.label || ''; }
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
|
||||
}
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description,
|
||||
skills_wentutujie: wentutujie,
|
||||
skills_wentutujie_image: wentutujieImage,
|
||||
optionGroups,
|
||||
is_active: item.isActive,
|
||||
sort_order: item.sortOrder,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '行业', key: 'industry', width: 180,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#6366f1', flexShrink: 0,
|
||||
}}>
|
||||
{getIconComponent(r.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.label}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
|
||||
</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', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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"
|
||||
loading={loading}
|
||||
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="icon" label="行业图标">
|
||||
<IconPicker />
|
||||
</Form.Item>
|
||||
<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>
|
||||
<Form.Item name="skills_wentutujie_image" 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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIndustries;
|
||||
@@ -0,0 +1,487 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
|
||||
} from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
|
||||
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, PlayCircleOutlined, 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 { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
|
||||
|
||||
interface OptionGroup {
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface IndustryItem {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
skills: string;
|
||||
optionGroups: OptionGroup[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// Icon library for industries
|
||||
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
|
||||
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
|
||||
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
|
||||
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
|
||||
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
|
||||
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
|
||||
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
|
||||
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
|
||||
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
|
||||
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
|
||||
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
|
||||
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
|
||||
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
|
||||
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
|
||||
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
|
||||
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
|
||||
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
|
||||
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
|
||||
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
|
||||
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
|
||||
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
|
||||
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
|
||||
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
|
||||
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
|
||||
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
|
||||
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
|
||||
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
|
||||
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
|
||||
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
|
||||
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
|
||||
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
|
||||
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
|
||||
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
|
||||
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
|
||||
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
|
||||
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
|
||||
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
|
||||
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
|
||||
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
|
||||
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
|
||||
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
|
||||
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
|
||||
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
|
||||
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
|
||||
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
|
||||
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
|
||||
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
|
||||
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
|
||||
];
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
|
||||
ICON_OPTIONS.map(o => [o.name, o.component])
|
||||
);
|
||||
|
||||
function getIconComponent(name: string): React.ReactNode {
|
||||
return iconMap[name] || <AppstoreOutlined />;
|
||||
}
|
||||
|
||||
// Parse skills JSON into { skillItems, optionGroups }
|
||||
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
|
||||
const skillItems: { key: string; label: string }[] = [];
|
||||
const optionGroups: OptionGroup[] = [];
|
||||
for (const item of arr) {
|
||||
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
|
||||
optionGroups.push({ name: item.name, options: item.options });
|
||||
} else if (item && item.key && item.label) {
|
||||
skillItems.push({ key: item.key, label: item.label });
|
||||
}
|
||||
}
|
||||
return { skillItems, optionGroups };
|
||||
} catch {
|
||||
return { skillItems: [], optionGroups: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Picker Component
|
||||
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const selected = ICON_OPTIONS.find(o => o.name === value);
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 12, padding: 12, width: 360,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
|
||||
{ICON_OPTIONS.map(opt => (
|
||||
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
|
||||
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
|
||||
background: value === opt.name ? '#6366f1' : 'transparent',
|
||||
color: value === opt.name ? '#fff' : '#64748b',
|
||||
border: value === opt.name ? 'none' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
|
||||
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{opt.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
||||
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
|
||||
}}>
|
||||
{value ? (
|
||||
<>
|
||||
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
|
||||
)}
|
||||
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminIndustries: React.FC = () => {
|
||||
const [industries, setIndustries] = useState<IndustryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getIndustryConfigs();
|
||||
setIndustries(data.map((item: any) => {
|
||||
const { skillItems, optionGroups } = parseSkills(item.skills);
|
||||
return {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description || '',
|
||||
skills: JSON.stringify(skillItems),
|
||||
optionGroups,
|
||||
isActive: item.is_active ?? item.isActive ?? true,
|
||||
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
|
||||
};
|
||||
}));
|
||||
} catch {
|
||||
message.error('加载行业配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// Build skills array: skill items + option groups
|
||||
const skillsArr: any[] = [];
|
||||
if (values.skills_wentutujie?.trim()) {
|
||||
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
|
||||
}
|
||||
// Add option groups
|
||||
const groups: 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);
|
||||
for (const g of groups) {
|
||||
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
key: values.key,
|
||||
label: values.label,
|
||||
icon: values.icon || '',
|
||||
description: values.description || '',
|
||||
skills: skillsArr,
|
||||
is_active: values.is_active ?? true,
|
||||
sort_order: values.sort_order ?? 0,
|
||||
};
|
||||
if (modal.item?.id) {
|
||||
await saveIndustryConfig({ id: modal.item.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveIndustryConfig(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteIndustryConfig(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item?: IndustryItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
let wentutujie = '';
|
||||
let optionGroups: OptionGroup[] = [];
|
||||
if (item) {
|
||||
// Parse skills for 文图理解
|
||||
try {
|
||||
const arr = JSON.parse(item.skills);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const s of arr) {
|
||||
if (s.key === '文图理解') { wentutujie = s.label || ''; break; }
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
|
||||
}
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description,
|
||||
skills_wentutujie: wentutujie,
|
||||
optionGroups,
|
||||
is_active: item.isActive,
|
||||
sort_order: item.sortOrder,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '行业', key: 'industry', width: 180,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#6366f1', flexShrink: 0,
|
||||
}}>
|
||||
{getIconComponent(r.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.label}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
|
||||
</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', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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"
|
||||
loading={loading}
|
||||
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="icon" label="行业图标">
|
||||
<IconPicker />
|
||||
</Form.Item>
|
||||
<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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIndustries;
|
||||
@@ -0,0 +1,490 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Space, Switch, Table, Tag, Typography, Dropdown, Select,
|
||||
} from 'antd';
|
||||
import {
|
||||
AppstoreOutlined, PlusOutlined, EditOutlined, DeleteOutlined, DownOutlined, MinusCircleOutlined,
|
||||
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, PlayCircleOutlined, 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 { getIndustryConfigs, saveIndustryConfig, deleteIndustryConfig } from '../api';
|
||||
|
||||
interface OptionGroup {
|
||||
name: string;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
interface IndustryItem {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
skills: string;
|
||||
optionGroups: OptionGroup[];
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
// Icon library for industries
|
||||
const ICON_OPTIONS: { name: string; label: string; component: React.ReactNode }[] = [
|
||||
{ name: 'ShoppingCartOutlined', label: '购物', component: <ShoppingCartOutlined /> },
|
||||
{ name: 'BookOutlined', label: '书籍', component: <BookOutlined /> },
|
||||
{ name: 'HomeOutlined', label: '房产', component: <HomeOutlined /> },
|
||||
{ name: 'FireOutlined', label: '火', component: <FireOutlined /> },
|
||||
{ name: 'RocketOutlined', label: '科技', component: <RocketOutlined /> },
|
||||
{ name: 'SkinOutlined', label: '时尚', component: <SkinOutlined /> },
|
||||
{ name: 'CompassOutlined', label: '指南', component: <CompassOutlined /> },
|
||||
{ name: 'HeartOutlined', label: '健康', component: <HeartOutlined /> },
|
||||
{ name: 'CarOutlined', label: '汽车', component: <CarOutlined /> },
|
||||
{ name: 'CameraOutlined', label: '相机', component: <CameraOutlined /> },
|
||||
{ name: 'CloudOutlined', label: '云', component: <CloudOutlined /> },
|
||||
{ name: 'StarOutlined', label: '星', component: <StarOutlined /> },
|
||||
{ name: 'TrophyOutlined', label: '奖杯', component: <TrophyOutlined /> },
|
||||
{ name: 'ThunderboltOutlined', label: '闪电', component: <ThunderboltOutlined /> },
|
||||
{ name: 'BulbOutlined', label: '灯泡', component: <BulbOutlined /> },
|
||||
{ name: 'CoffeeOutlined', label: '咖啡', component: <CoffeeOutlined /> },
|
||||
{ name: 'CrownOutlined', label: '皇冠', component: <CrownOutlined /> },
|
||||
{ name: 'DashboardOutlined', label: '仪表', component: <DashboardOutlined /> },
|
||||
{ name: 'FlagOutlined', label: '旗帜', component: <FlagOutlined /> },
|
||||
{ name: 'GlobalOutlined', label: '全球', component: <GlobalOutlined /> },
|
||||
{ name: 'GiftOutlined', label: '礼物', component: <GiftOutlined /> },
|
||||
{ name: 'LaptopOutlined', label: '笔记本', component: <LaptopOutlined /> },
|
||||
{ name: 'MobileOutlined', label: '手机', component: <MobileOutlined /> },
|
||||
{ name: 'MonitorOutlined', label: '显示器', component: <MonitorOutlined /> },
|
||||
{ name: 'PayCircleOutlined', label: '支付', component: <PayCircleOutlined /> },
|
||||
{ name: 'PictureOutlined', label: '图片', component: <PictureOutlined /> },
|
||||
{ name: 'PlayCircleOutlined', label: '播放', component: <PlayCircleOutlined /> },
|
||||
{ name: 'SafetyOutlined', label: '安全', component: <SafetyOutlined /> },
|
||||
{ name: 'ShoppingOutlined', label: '商店', component: <ShoppingOutlined /> },
|
||||
{ name: 'SmileOutlined', label: '笑脸', component: <SmileOutlined /> },
|
||||
{ name: 'SoundOutlined', label: '声音', component: <SoundOutlined /> },
|
||||
{ name: 'TagOutlined', label: '标签', component: <TagOutlined /> },
|
||||
{ name: 'TeamOutlined', label: '团队', component: <TeamOutlined /> },
|
||||
{ name: 'ToolOutlined', label: '工具', component: <ToolOutlined /> },
|
||||
{ name: 'TruckOutlined', label: '物流', component: <TruckOutlined /> },
|
||||
{ name: 'VideoCameraOutlined', label: '视频', component: <VideoCameraOutlined /> },
|
||||
{ name: 'WalletOutlined', label: '钱包', component: <WalletOutlined /> },
|
||||
{ name: 'BankOutlined', label: '银行', component: <BankOutlined /> },
|
||||
{ name: 'BuildOutlined', label: '建筑', component: <BuildOutlined /> },
|
||||
{ name: 'ExperimentOutlined', label: '实验', component: <ExperimentOutlined /> },
|
||||
{ name: 'HighlightOutlined', label: '高亮', component: <HighlightOutlined /> },
|
||||
{ name: 'IdcardOutlined', label: '名片', component: <IdcardOutlined /> },
|
||||
{ name: 'MedicineBoxOutlined', label: '医药', component: <MedicineBoxOutlined /> },
|
||||
{ name: 'ReadOutlined', label: '阅读', component: <ReadOutlined /> },
|
||||
{ name: 'RestOutlined', label: '休息', component: <RestOutlined /> },
|
||||
{ name: 'SketchOutlined', label: '钻石', component: <SketchOutlined /> },
|
||||
{ name: 'SolutionOutlined', label: '方案', component: <SolutionOutlined /> },
|
||||
];
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = Object.fromEntries(
|
||||
ICON_OPTIONS.map(o => [o.name, o.component])
|
||||
);
|
||||
|
||||
function getIconComponent(name: string): React.ReactNode {
|
||||
return iconMap[name] || <AppstoreOutlined />;
|
||||
}
|
||||
|
||||
// Parse skills JSON into { skillItems, optionGroups }
|
||||
function parseSkills(raw: string | any[]): { skillItems: { key: string; label: string }[]; optionGroups: OptionGroup[] } {
|
||||
try {
|
||||
const arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
if (!Array.isArray(arr)) return { skillItems: [], optionGroups: [] };
|
||||
const skillItems: { key: string; label: string }[] = [];
|
||||
const optionGroups: OptionGroup[] = [];
|
||||
for (const item of arr) {
|
||||
if (item && item.type === 'option_group' && item.name && Array.isArray(item.options)) {
|
||||
optionGroups.push({ name: item.name, options: item.options });
|
||||
} else if (item && item.key && item.label) {
|
||||
skillItems.push({ key: item.key, label: item.label });
|
||||
}
|
||||
}
|
||||
return { skillItems, optionGroups };
|
||||
} catch {
|
||||
return { skillItems: [], optionGroups: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Icon Picker Component
|
||||
const IconPicker: React.FC<{ value?: string; onChange?: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const selected = ICON_OPTIONS.find(o => o.name === value);
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={() => (
|
||||
<div style={{
|
||||
background: '#fff', borderRadius: 12, padding: 12, width: 360,
|
||||
boxShadow: '0 12px 40px rgba(0,0,0,0.15)', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: 4 }}>
|
||||
{ICON_OPTIONS.map(opt => (
|
||||
<div key={opt.name} onMouseDown={e => { e.preventDefault(); e.stopPropagation(); onChange?.(opt.name); }} title={opt.label} style={{
|
||||
width: 40, height: 40, borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', fontSize: 18, transition: 'all 0.15s',
|
||||
background: value === opt.name ? '#6366f1' : 'transparent',
|
||||
color: value === opt.name ? '#fff' : '#64748b',
|
||||
border: value === opt.name ? 'none' : '1px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (value !== opt.name) e.currentTarget.style.background = '#f1f5f9'; }}
|
||||
onMouseLeave={e => { if (value !== opt.name) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
{opt.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
||||
borderRadius: 8, border: '1px solid #d9d9d9', cursor: 'pointer', height: 40,
|
||||
}}>
|
||||
{value ? (
|
||||
<>
|
||||
<span style={{ fontSize: 18, color: '#6366f1' }}>{getIconComponent(value)}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 13 }}>{selected?.label || value}</span>
|
||||
</>
|
||||
) : (
|
||||
<span style={{ color: '#bfbfbf', fontSize: 13 }}>选择图标</span>
|
||||
)}
|
||||
<DownOutlined style={{ fontSize: 10, color: '#bfbfbf', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminIndustries: React.FC = () => {
|
||||
const [industries, setIndustries] = useState<IndustryItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: IndustryItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getIndustryConfigs();
|
||||
setIndustries(data.map((item: any) => {
|
||||
const { skillItems, optionGroups } = parseSkills(item.skills);
|
||||
return {
|
||||
id: item.id,
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description || '',
|
||||
skills: JSON.stringify(skillItems),
|
||||
optionGroups,
|
||||
isActive: item.is_active ?? item.isActive ?? true,
|
||||
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
|
||||
};
|
||||
}));
|
||||
} catch {
|
||||
message.error('加载行业配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// Build skills array: skill items + option groups
|
||||
const skillsArr: any[] = [];
|
||||
if (values.skills_wentutujie?.trim()) {
|
||||
skillsArr.push({ type: 'skill', key: '文图理解', label: values.skills_wentutujie.trim() });
|
||||
}
|
||||
// Add option groups
|
||||
const groups: 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);
|
||||
for (const g of groups) {
|
||||
skillsArr.push({ type: 'option_group', name: g.name, options: g.options });
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
key: values.key,
|
||||
label: values.label,
|
||||
icon: values.icon || '',
|
||||
description: values.description || '',
|
||||
skills: skillsArr,
|
||||
is_active: values.is_active ?? true,
|
||||
sort_order: values.sort_order ?? 0,
|
||||
};
|
||||
if (modal.item?.id) {
|
||||
await saveIndustryConfig({ id: modal.item.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveIndustryConfig(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteIndustryConfig(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item?: IndustryItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
let wentutujie = '';
|
||||
let wentutujieImage = '';
|
||||
let optionGroups: OptionGroup[] = [];
|
||||
if (item) {
|
||||
// Parse skills for 文图理解 video and image prompts
|
||||
try {
|
||||
const arr = JSON.parse(item.skills);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const s of arr) {
|
||||
if (s.key === '文图理解生成视频提示词' || s.key === '文图理解') { wentutujie = s.label || ''; }
|
||||
if (s.key === '文图理解生成图片提示词') { wentutujieImage = s.label || ''; }
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
optionGroups = item.optionGroups.length > 0 ? item.optionGroups : [{ name: '', options: [] }];
|
||||
}
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon || '',
|
||||
description: item.description,
|
||||
skills_wentutujie: wentutujie,
|
||||
skills_wentutujie_image: wentutujieImage,
|
||||
optionGroups,
|
||||
is_active: item.isActive,
|
||||
sort_order: item.sortOrder,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_active: true, sort_order: 0, optionGroups: [{ name: '', options: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '行业', key: 'industry', width: 180,
|
||||
render: (_: any, r: IndustryItem) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, background: '#f1f5f9',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#6366f1', flexShrink: 0,
|
||||
}}>
|
||||
{getIconComponent(r.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{r.label}</Typography.Text>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.key}</div>
|
||||
</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', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成视频提示词' || s.key === '文图理解');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '文图理解生成图片提示词', dataIndex: 'skills', ellipsis: true,
|
||||
render: (v: string) => {
|
||||
try {
|
||||
const arr = JSON.parse(v);
|
||||
if (Array.isArray(arr)) {
|
||||
const wtj = arr.find((s: any) => s.key === '文图理解生成图片提示词');
|
||||
if (wtj) return <Typography.Text ellipsis style={{ fontSize: 12 }}>{wtj.label}</Typography.Text>;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
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 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"
|
||||
loading={loading}
|
||||
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="icon" label="行业图标">
|
||||
<IconPicker />
|
||||
</Form.Item>
|
||||
<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>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="is_active" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminIndustries;
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
WalletOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
AppstoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
StarOutlined,
|
||||
HeartOutlined,
|
||||
CameraOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import { useAdminStore } from '../store';
|
||||
import { getMenuConfigs, adminChangePassword } from '../api';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
RobotOutlined: <RobotOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
LogoutOutlined: <LogoutOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
CalculatorOutlined: <CalculatorOutlined />,
|
||||
DollarOutlined: <DollarOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
GiftOutlined: <GiftOutlined />,
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
CameraOutlined: <CameraOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
PictureOutlined: <PictureOutlined />,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, loading, logout } = useAdminStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menuItems, setMenuItems] = useState<any[]>([]);
|
||||
const [pwdModal, setPwdModal] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let menus = data.filter((m: any) => {
|
||||
const target = m.menu_target ?? m.menuTarget ?? 'admin';
|
||||
const active = m.is_active ?? m.isActive ?? true;
|
||||
return active && (target === 'admin' || target === 'both');
|
||||
});
|
||||
// Non-admin backend users: filter by allowedMenus
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (user && !isAdminUser) {
|
||||
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
|
||||
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
|
||||
const allowedSet = new Set(allowed);
|
||||
menus = menus.filter((m: any) => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
if (menuType === 'group') {
|
||||
return menus.some((c: any) => {
|
||||
const pid = c.parent_id ?? c.parentId;
|
||||
return pid === m.id && allowedSet.has(c.path);
|
||||
});
|
||||
}
|
||||
return allowedSet.has(m.path);
|
||||
});
|
||||
} else {
|
||||
// No allowed menus set and not admin = show nothing
|
||||
menus = [];
|
||||
}
|
||||
}
|
||||
setMenuItems(menus);
|
||||
}).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
|
||||
const childMap: Record<string, any[]> = {};
|
||||
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
|
||||
// Sort all menu items by sortOrder, then build ant menu items
|
||||
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
|
||||
const antMenuItems: any[] = [];
|
||||
|
||||
sorted.forEach(m => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
const parentId = m.parent_id ?? m.parentId;
|
||||
if (menuType === 'group') {
|
||||
const children = (childMap[m.id] || [])
|
||||
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
|
||||
.map(c => ({
|
||||
key: c.path,
|
||||
icon: iconMap[c.icon] || undefined,
|
||||
label: c.label,
|
||||
}));
|
||||
if (children.length > 0) {
|
||||
antMenuItems.push({
|
||||
key: `group-${m.id}`,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
children,
|
||||
});
|
||||
}
|
||||
} else if (!parentId) {
|
||||
antMenuItems.push({
|
||||
key: m.path,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback only for admin users — non-admin users with no permissions see nothing
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (antMenuItems.length === 0 && isAdminUser) {
|
||||
antMenuItems.push(
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
);
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
|
||||
let activeKey = selectedKey;
|
||||
const allLeafKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.forEach((c: any) => allLeafKeys.push(c.key));
|
||||
} else {
|
||||
allLeafKeys.push(item.key);
|
||||
}
|
||||
});
|
||||
// Exact match or prefix match
|
||||
if (!allLeafKeys.includes(activeKey)) {
|
||||
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
|
||||
}
|
||||
|
||||
// Find open keys for sub-menus
|
||||
const openKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
if (item.children.some((c: any) => c.key === activeKey)) {
|
||||
openKeys.push(item.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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={[activeKey]}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={antMenuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('group-')) return;
|
||||
navigate(key);
|
||||
}}
|
||||
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
</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 }}>
|
||||
{antMenuItems.find(m => m.key === activeKey)?.label
|
||||
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|
||||
|| '管理后台'}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'logout') { logout(); navigate('/login'); }
|
||||
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
|
||||
},
|
||||
}} placement="bottomRight" arrow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
|
||||
<Avatar size={28} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
|
||||
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
|
||||
{antMenuItems.length === 0 && !isAdminUser ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
|
||||
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
|
||||
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={<Space><LockOutlined />修改密码</Space>}
|
||||
open={pwdModal}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
await adminChangePassword(values.oldPassword, values.newPassword);
|
||||
message.success('密码修改成功');
|
||||
setPwdModal(false);
|
||||
pwdForm.resetFields();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '修改失败');
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={420}
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password placeholder="请输入原密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password placeholder="请再次输入新密码" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
WalletOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
AppstoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
StarOutlined,
|
||||
HeartOutlined,
|
||||
CameraOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import { useAdminStore } from '../store';
|
||||
import { getMenuConfigs, adminChangePassword } from '../api';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
RobotOutlined: <RobotOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
LogoutOutlined: <LogoutOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
CalculatorOutlined: <CalculatorOutlined />,
|
||||
DollarOutlined: <DollarOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
GiftOutlined: <GiftOutlined />,
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
CameraOutlined: <CameraOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, loading, logout } = useAdminStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menuItems, setMenuItems] = useState<any[]>([]);
|
||||
const [pwdModal, setPwdModal] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let menus = data.filter((m: any) => {
|
||||
const target = m.menu_target ?? m.menuTarget ?? 'admin';
|
||||
const active = m.is_active ?? m.isActive ?? true;
|
||||
return active && (target === 'admin' || target === 'both');
|
||||
});
|
||||
// Non-admin backend users: filter by allowedMenus
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (user && !isAdminUser) {
|
||||
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
|
||||
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
|
||||
const allowedSet = new Set(allowed);
|
||||
menus = menus.filter((m: any) => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
if (menuType === 'group') {
|
||||
return menus.some((c: any) => {
|
||||
const pid = c.parent_id ?? c.parentId;
|
||||
return pid === m.id && allowedSet.has(c.path);
|
||||
});
|
||||
}
|
||||
return allowedSet.has(m.path);
|
||||
});
|
||||
} else {
|
||||
// No allowed menus set and not admin = show nothing
|
||||
menus = [];
|
||||
}
|
||||
}
|
||||
setMenuItems(menus);
|
||||
}).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
|
||||
const childMap: Record<string, any[]> = {};
|
||||
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
|
||||
// Sort all menu items by sortOrder, then build ant menu items
|
||||
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
|
||||
const antMenuItems: any[] = [];
|
||||
|
||||
sorted.forEach(m => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
const parentId = m.parent_id ?? m.parentId;
|
||||
if (menuType === 'group') {
|
||||
const children = (childMap[m.id] || [])
|
||||
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
|
||||
.map(c => ({
|
||||
key: c.path,
|
||||
icon: iconMap[c.icon] || undefined,
|
||||
label: c.label,
|
||||
}));
|
||||
if (children.length > 0) {
|
||||
antMenuItems.push({
|
||||
key: `group-${m.id}`,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
children,
|
||||
});
|
||||
}
|
||||
} else if (!parentId) {
|
||||
antMenuItems.push({
|
||||
key: m.path,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback only for admin users — non-admin users with no permissions see nothing
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (antMenuItems.length === 0 && isAdminUser) {
|
||||
antMenuItems.push(
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
);
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
|
||||
let activeKey = selectedKey;
|
||||
const allLeafKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.forEach((c: any) => allLeafKeys.push(c.key));
|
||||
} else {
|
||||
allLeafKeys.push(item.key);
|
||||
}
|
||||
});
|
||||
// Exact match or prefix match
|
||||
if (!allLeafKeys.includes(activeKey)) {
|
||||
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
|
||||
}
|
||||
|
||||
// Find open keys for sub-menus
|
||||
const openKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
if (item.children.some((c: any) => c.key === activeKey)) {
|
||||
openKeys.push(item.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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={[activeKey]}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={antMenuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('group-')) return;
|
||||
navigate(key);
|
||||
}}
|
||||
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
</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 }}>
|
||||
{antMenuItems.find(m => m.key === activeKey)?.label
|
||||
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|
||||
|| '管理后台'}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'logout') { logout(); navigate('/login'); }
|
||||
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
|
||||
},
|
||||
}} placement="bottomRight" arrow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
|
||||
<Avatar size={28} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
|
||||
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
|
||||
{antMenuItems.length === 0 && !isAdminUser ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
|
||||
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
|
||||
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={<Space><LockOutlined />修改密码</Space>}
|
||||
open={pwdModal}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
await adminChangePassword(values.oldPassword, values.newPassword);
|
||||
message.success('密码修改成功');
|
||||
setPwdModal(false);
|
||||
pwdForm.resetFields();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '修改失败');
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={420}
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password placeholder="请输入原密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password placeholder="请再次输入新密码" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Avatar, Typography, Dropdown, Spin, Modal, Form, Input, Space, message } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
RobotOutlined,
|
||||
SettingOutlined,
|
||||
BellOutlined,
|
||||
ThunderboltOutlined,
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
WalletOutlined,
|
||||
CalculatorOutlined,
|
||||
DollarOutlined,
|
||||
AppstoreOutlined,
|
||||
PlayCircleOutlined,
|
||||
GiftOutlined,
|
||||
HomeOutlined,
|
||||
StarOutlined,
|
||||
HeartOutlined,
|
||||
CameraOutlined,
|
||||
FileTextOutlined,
|
||||
HistoryOutlined,
|
||||
VideoCameraOutlined,
|
||||
PictureOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation, Navigate } from 'react-router-dom';
|
||||
import { useAdminStore } from '../store';
|
||||
import { getMenuConfigs, adminChangePassword } from '../api';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
DashboardOutlined: <DashboardOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
RobotOutlined: <RobotOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
BellOutlined: <BellOutlined />,
|
||||
LogoutOutlined: <LogoutOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
CalculatorOutlined: <CalculatorOutlined />,
|
||||
DollarOutlined: <DollarOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
GiftOutlined: <GiftOutlined />,
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />,
|
||||
CameraOutlined: <CameraOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />,
|
||||
HistoryOutlined: <HistoryOutlined />,
|
||||
VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
PictureOutlined: <PictureOutlined />,
|
||||
};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, loading, logout } = useAdminStore();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [menuItems, setMenuItems] = useState<any[]>([]);
|
||||
const [pwdModal, setPwdModal] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let menus = data.filter((m: any) => {
|
||||
const target = m.menu_target ?? m.menuTarget ?? 'admin';
|
||||
const active = m.is_active ?? m.isActive ?? true;
|
||||
return active && (target === 'admin' || target === 'both');
|
||||
});
|
||||
// Non-admin backend users: filter by allowedMenus
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (user && !isAdminUser) {
|
||||
const allowed = user.allowedMenus ?? (user as any)?.allowed_menus;
|
||||
if (allowed && Array.isArray(allowed) && allowed.length > 0) {
|
||||
const allowedSet = new Set(allowed);
|
||||
menus = menus.filter((m: any) => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
if (menuType === 'group') {
|
||||
return menus.some((c: any) => {
|
||||
const pid = c.parent_id ?? c.parentId;
|
||||
return pid === m.id && allowedSet.has(c.path);
|
||||
});
|
||||
}
|
||||
return allowedSet.has(m.path);
|
||||
});
|
||||
} else {
|
||||
// No allowed menus set and not admin = show nothing
|
||||
menus = [];
|
||||
}
|
||||
}
|
||||
setMenuItems(menus);
|
||||
}).catch(() => {});
|
||||
}, [user]);
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
// Build menu items for Ant Design Menu — single sorted list interleaving groups and pages
|
||||
const childMap: Record<string, any[]> = {};
|
||||
menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group' && (m.parent_id ?? m.parentId)).forEach(m => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
|
||||
// Sort all menu items by sortOrder, then build ant menu items
|
||||
const sorted = [...menuItems].sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0));
|
||||
const antMenuItems: any[] = [];
|
||||
|
||||
sorted.forEach(m => {
|
||||
const menuType = m.menu_type ?? m.menuType;
|
||||
const parentId = m.parent_id ?? m.parentId;
|
||||
if (menuType === 'group') {
|
||||
const children = (childMap[m.id] || [])
|
||||
.sort((a, b) => (a.sort_order ?? a.sortOrder ?? 0) - (b.sort_order ?? b.sortOrder ?? 0))
|
||||
.map(c => ({
|
||||
key: c.path,
|
||||
icon: iconMap[c.icon] || undefined,
|
||||
label: c.label,
|
||||
}));
|
||||
if (children.length > 0) {
|
||||
antMenuItems.push({
|
||||
key: `group-${m.id}`,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
children,
|
||||
});
|
||||
}
|
||||
} else if (!parentId) {
|
||||
antMenuItems.push({
|
||||
key: m.path,
|
||||
icon: iconMap[m.icon] || undefined,
|
||||
label: m.label,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback only for admin users — non-admin users with no permissions see nothing
|
||||
const isAdminUser = !!(user?.isAdmin ?? (user as any)?.is_admin);
|
||||
if (antMenuItems.length === 0 && isAdminUser) {
|
||||
antMenuItems.push(
|
||||
{ key: '/', icon: <DashboardOutlined />, label: '数据概览' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
);
|
||||
}
|
||||
|
||||
const selectedKey = location.pathname;
|
||||
|
||||
// Find the leaf menu key that matches (for sub-menus, need to find the right key)
|
||||
let activeKey = selectedKey;
|
||||
const allLeafKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.forEach((c: any) => allLeafKeys.push(c.key));
|
||||
} else {
|
||||
allLeafKeys.push(item.key);
|
||||
}
|
||||
});
|
||||
// Exact match or prefix match
|
||||
if (!allLeafKeys.includes(activeKey)) {
|
||||
activeKey = allLeafKeys.find(k => activeKey.startsWith(k)) || '/';
|
||||
}
|
||||
|
||||
// Find open keys for sub-menus
|
||||
const openKeys: string[] = [];
|
||||
antMenuItems.forEach(item => {
|
||||
if (item.children) {
|
||||
if (item.children.some((c: any) => c.key === activeKey)) {
|
||||
openKeys.push(item.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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={[activeKey]}
|
||||
defaultOpenKeys={openKeys}
|
||||
items={antMenuItems}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('group-')) return;
|
||||
navigate(key);
|
||||
}}
|
||||
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
|
||||
theme="dark"
|
||||
/>
|
||||
|
||||
</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 }}>
|
||||
{antMenuItems.find(m => m.key === activeKey)?.label
|
||||
|| antMenuItems.flatMap(m => m.children || []).find((c: any) => c.key === activeKey)?.label
|
||||
|| '管理后台'}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{
|
||||
items: [
|
||||
{ key: 'user', icon: <UserOutlined />, label: user?.username, disabled: true },
|
||||
{ type: 'divider' as const },
|
||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'logout') { logout(); navigate('/login'); }
|
||||
if (key === 'changePwd') { setPwdModal(true); pwdForm.resetFields(); }
|
||||
},
|
||||
}} placement="bottomRight" arrow>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '4px 8px', borderRadius: 8, transition: 'background 0.2s' }}>
|
||||
<Avatar size={28} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }} />
|
||||
<Typography.Text style={{ fontSize: 13, fontWeight: 500 }}>
|
||||
{user?.username}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Content style={{ padding: 24, background: '#f5f6fa', overflow: 'auto' }}>
|
||||
{antMenuItems.length === 0 && !isAdminUser ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', color: '#94a3b8' }}>
|
||||
<LockOutlined style={{ fontSize: 48, marginBottom: 16, color: '#cbd5e1' }} />
|
||||
<div style={{ fontSize: 16, fontWeight: 600, color: '#64748b' }}>暂无任何权限</div>
|
||||
<div style={{ fontSize: 13, marginTop: 8 }}>请联系管理员配置菜单权限</div>
|
||||
</div>
|
||||
) : (
|
||||
<Outlet />
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
<Modal
|
||||
title={<Space><LockOutlined />修改密码</Space>}
|
||||
open={pwdModal}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const values = await pwdForm.validateFields();
|
||||
if (values.newPassword !== values.confirmPassword) {
|
||||
message.error('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
await adminChangePassword(values.oldPassword, values.newPassword);
|
||||
message.success('密码修改成功');
|
||||
setPwdModal(false);
|
||||
pwdForm.resetFields();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '修改失败');
|
||||
}
|
||||
}}
|
||||
onCancel={() => { setPwdModal(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={420}
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="oldPassword" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password placeholder="请输入原密码" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirmPassword" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password placeholder="请再次输入新密码" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLayout;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Form, Input, message, Typography, Checkbox } from 'antd';
|
||||
import { UserOutlined, LockOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAdminStore } from '../store';
|
||||
|
||||
const AdminLoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAdminStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleLogin = async (values: { username: string; password: string; rememberMe?: boolean }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.username, values.password, values.rememberMe);
|
||||
message.success('登录成功');
|
||||
navigate('/');
|
||||
} 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">
|
||||
<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 name="rememberMe" valuePropName="checked">
|
||||
<Checkbox>记住我的登录状态</Checkbox>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLoginPage;
|
||||
@@ -0,0 +1,287 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
MenuOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
HomeOutlined, PlayCircleOutlined, WalletOutlined, RobotOutlined,
|
||||
SettingOutlined, BellOutlined, UserOutlined, AppstoreOutlined,
|
||||
FileTextOutlined, StarOutlined, HeartOutlined, CameraOutlined,
|
||||
DashboardOutlined, CalculatorOutlined, DollarOutlined, GiftOutlined,
|
||||
ThunderboltOutlined, FireOutlined, CloudOutlined, SmileOutlined,
|
||||
TrophyOutlined, RocketOutlined, BulbOutlined, CodeOutlined,
|
||||
PictureOutlined, VideoCameraOutlined, AudioOutlined,
|
||||
MailOutlined, PhoneOutlined, GlobalOutlined, ShoppingCartOutlined,
|
||||
TeamOutlined, BarChartOutlined, PieChartOutlined, LineChartOutlined,
|
||||
SecurityScanOutlined, ApiOutlined, DatabaseOutlined, CloudServerOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getMenuConfigs, saveMenuConfig, deleteMenuConfig } from '../api';
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
HomeOutlined: <HomeOutlined />, PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
WalletOutlined: <WalletOutlined />, RobotOutlined: <RobotOutlined />,
|
||||
SettingOutlined: <SettingOutlined />, BellOutlined: <BellOutlined />,
|
||||
UserOutlined: <UserOutlined />, AppstoreOutlined: <AppstoreOutlined />,
|
||||
FileTextOutlined: <FileTextOutlined />, StarOutlined: <StarOutlined />,
|
||||
HeartOutlined: <HeartOutlined />, CameraOutlined: <CameraOutlined />,
|
||||
DashboardOutlined: <DashboardOutlined />, CalculatorOutlined: <CalculatorOutlined />,
|
||||
DollarOutlined: <DollarOutlined />, GiftOutlined: <GiftOutlined />,
|
||||
ThunderboltOutlined: <ThunderboltOutlined />, FireOutlined: <FireOutlined />,
|
||||
CloudOutlined: <CloudOutlined />, SmileOutlined: <SmileOutlined />,
|
||||
TrophyOutlined: <TrophyOutlined />, RocketOutlined: <RocketOutlined />,
|
||||
BulbOutlined: <BulbOutlined />, CodeOutlined: <CodeOutlined />,
|
||||
PictureOutlined: <PictureOutlined />, VideoCameraOutlined: <VideoCameraOutlined />,
|
||||
AudioOutlined: <AudioOutlined />, MailOutlined: <MailOutlined />,
|
||||
PhoneOutlined: <PhoneOutlined />, GlobalOutlined: <GlobalOutlined />,
|
||||
ShoppingCartOutlined: <ShoppingCartOutlined />, TeamOutlined: <TeamOutlined />,
|
||||
BarChartOutlined: <BarChartOutlined />, PieChartOutlined: <PieChartOutlined />,
|
||||
LineChartOutlined: <LineChartOutlined />, SecurityScanOutlined: <SecurityScanOutlined />,
|
||||
ApiOutlined: <ApiOutlined />, DatabaseOutlined: <DatabaseOutlined />,
|
||||
CloudServerOutlined: <CloudServerOutlined />, MenuOutlined: <MenuOutlined />,
|
||||
};
|
||||
|
||||
const ICON_OPTIONS = Object.keys(ICON_MAP).map(key => ({
|
||||
value: key,
|
||||
label: <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>{ICON_MAP[key]} {key.replace('Outlined', '')}</span>,
|
||||
}));
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = { page: 'blue', group: 'purple' };
|
||||
const TYPE_LABELS: Record<string, string> = { page: '页面', group: '分组' };
|
||||
|
||||
const AdminMenuConfig: React.FC = () => {
|
||||
const [menus, setMenus] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string>('frontend');
|
||||
const [modal, setModal] = useState<{ open: boolean; menu: any | null }>({ open: false, menu: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMenuConfigs();
|
||||
setMenus(data);
|
||||
} catch { /* auth error handled by client */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
label: values.label,
|
||||
path: values.path || '',
|
||||
icon: values.icon || '',
|
||||
sort_order: values.sortOrder ?? 0,
|
||||
is_active: values.isActive ?? true,
|
||||
parent_id: values.parentId || null,
|
||||
menu_type: values.menuType || 'page',
|
||||
menu_target: values.menuTarget || 'frontend',
|
||||
is_default: values.isDefault ?? false,
|
||||
};
|
||||
if (modal.menu?.id) {
|
||||
await saveMenuConfig({ ...payload, id: modal.menu.id });
|
||||
} else {
|
||||
await saveMenuConfig(payload);
|
||||
}
|
||||
message.success(modal.menu?.id ? '菜单已更新' : '菜单已添加');
|
||||
setModal({ open: false, menu: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteMenuConfig(id);
|
||||
message.success('菜单已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (menu?: any) => {
|
||||
setModal({ open: true, menu: menu || null });
|
||||
if (menu) {
|
||||
form.setFieldsValue({
|
||||
label: menu.label,
|
||||
path: menu.path,
|
||||
icon: menu.icon,
|
||||
sortOrder: menu.sortOrder ?? 0,
|
||||
isActive: menu.isActive ?? true,
|
||||
parentId: menu.parentId ?? '',
|
||||
menuType: menu.menuType ?? 'page',
|
||||
menuTarget: menu.menuTarget ?? 'frontend',
|
||||
isDefault: menu.isDefault ?? false,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ icon: 'HomeOutlined', sortOrder: menus.length, isActive: true, menuType: 'page', menuTarget: activeTab, isDefault: false });
|
||||
}
|
||||
};
|
||||
|
||||
// Filter menus by active tab
|
||||
const filteredMenus = menus.filter(m => {
|
||||
const target = m.menuTarget ?? 'frontend';
|
||||
return target === activeTab || target === 'both';
|
||||
});
|
||||
|
||||
// Build tree: groups first, then pages under groups
|
||||
const groups = filteredMenus.filter(m => m.menuType === 'group');
|
||||
const parentOptions = [
|
||||
{ value: '', label: '顶级菜单' },
|
||||
...groups.map(g => ({ value: g.id, label: g.label })),
|
||||
];
|
||||
|
||||
// Build flat display with indentation
|
||||
const displayMenus: any[] = [];
|
||||
const topLevel = filteredMenus.filter(m => !m.parentId);
|
||||
const childMap: Record<string, any[]> = {};
|
||||
filteredMenus.filter(m => m.parentId).forEach(m => {
|
||||
const pid = m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
|
||||
displayMenus.push({ ...m, _depth: 0 });
|
||||
(childMap[m.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(c => {
|
||||
displayMenus.push({ ...c, _depth: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{
|
||||
title: '菜单名称', key: 'label', width: 180,
|
||||
render: (_: any, r: any) => (
|
||||
<span style={{ paddingLeft: r._depth * 20, fontWeight: r._depth === 0 ? 600 : 400 }}>
|
||||
{r._depth === 1 && <span style={{ color: '#cbd5e1', marginRight: 4 }}>└</span>}
|
||||
{r.label}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '路由路径', dataIndex: 'path', width: 160, render: (v: string) => v || <Typography.Text type="secondary">-</Typography.Text> },
|
||||
{
|
||||
title: '图标', dataIndex: 'icon', width: 100,
|
||||
render: (v: string) => v && ICON_MAP[v] ? (
|
||||
<span style={{ fontSize: 16, color: '#6366f1' }}>{ICON_MAP[v]}</span>
|
||||
) : '-',
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'menuType', width: 80,
|
||||
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 70,
|
||||
render: (v: boolean) => (
|
||||
<span style={{ color: v ? '#22c55e' : '#94a3b8' }}>{v ? '启用' : '停用'}</span>
|
||||
),
|
||||
},
|
||||
...(activeTab === 'frontend' ? [{
|
||||
title: '默认显示', dataIndex: 'isDefault', width: 80,
|
||||
render: (v: boolean) => v ? <Tag color="green">默认</Tag> : <Typography.Text type="secondary">-</Typography.Text>,
|
||||
}] : []),
|
||||
{
|
||||
title: '操作', key: 'action', width: 150,
|
||||
render: (_: any, r: any) => (
|
||||
<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>
|
||||
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>菜单配置</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加菜单
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'frontend', label: '前台菜单' },
|
||||
{ key: 'admin', label: '后台菜单' },
|
||||
]}
|
||||
/>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={displayMenus}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><MenuOutlined />{modal.menu?.id ? '编辑菜单' : '添加菜单'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, menu: null }); form.resetFields(); }}
|
||||
okText="确认" cancelText="取消" width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="label" label="菜单名称" rules={[{ required: true, message: '请输入菜单名称' }]}>
|
||||
<Input placeholder="例如:我的项目" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label="路由路径" tooltip="分组类型可留空">
|
||||
<Input placeholder="例如:/projects(分组可留空)" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="menuType" label="菜单类型" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'page', label: '页面' },
|
||||
{ value: 'group', label: '分组' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="menuTarget" label="适用端" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'frontend', label: '前台' },
|
||||
{ value: 'admin', label: '后台' },
|
||||
{ value: 'both', label: '两者' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="icon" label="图标" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<Select size="large" options={ICON_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} max={100} style={{ width: '100%' }} size="large" placeholder="默认0" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="parentId" label="上级菜单">
|
||||
<Select size="large" options={parentOptions} allowClear placeholder="顶级菜单" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.menuTarget !== cur.menuTarget}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('menuTarget') !== 'admin' ? (
|
||||
<Form.Item name="isDefault" label="新用户默认显示" valuePropName="checked" tooltip="开启后,新注册用户默认显示此菜单">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMenuConfig;
|
||||
@@ -0,0 +1,243 @@
|
||||
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);
|
||||
try {
|
||||
const data = await getModelConfigs();
|
||||
setModels(data);
|
||||
} catch { /* auth error handled by client */ }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
model_name: values.modelName,
|
||||
api_base: values.apiBase,
|
||||
api_key: values.apiKey,
|
||||
weight: values.weight,
|
||||
max_tokens: values.maxTokens,
|
||||
temperature: values.temperature,
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
if (modal.model?.id) {
|
||||
await saveModelConfig({ id: modal.model.id, ...payload });
|
||||
} else {
|
||||
await saveModelConfig(payload);
|
||||
}
|
||||
message.success(modal.model?.id ? '模型配置已更新' : '模型配置已添加');
|
||||
setModal({ open: false, model: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteModelConfig(id);
|
||||
message.success('模型配置已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
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,250 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface NotificationRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: string;
|
||||
target: string;
|
||||
targetUserId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ReadUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
readAt: string;
|
||||
}
|
||||
|
||||
const AdminNotificationManager: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [users, setUsers] = useState<UserOption[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [readModal, setReadModal] = useState<{ open: boolean; notifId: string; title: string }>({ open: false, notifId: '', title: '' });
|
||||
const [readUsers, setReadUsers] = useState<ReadUser[]>([]);
|
||||
const [readLoading, setReadLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminNotifications();
|
||||
const userList = await getAdminUsers();
|
||||
const userMap: Record<string, string> = {};
|
||||
userList.forEach((u: any) => { userMap[u.id] = u.username; });
|
||||
const items = (res.items || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
type: n.type,
|
||||
targetUserId: n.userId || null,
|
||||
target: n.userId ? (userMap[n.userId] || n.userId) : '全部用户',
|
||||
createdAt: n.createdAt,
|
||||
}));
|
||||
setNotifications(items);
|
||||
setUsers(userList.map((u: any) => ({ id: u.id, username: u.username })));
|
||||
} catch {
|
||||
message.error('加载通知列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await createAdminNotification({
|
||||
title: values.title,
|
||||
content: values.content,
|
||||
type: values.type || 'system',
|
||||
target_user_id: values.target_user_id || undefined,
|
||||
});
|
||||
message.success('消息已发送');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteAdminNotification(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewRead = async (notifId: string, title: string) => {
|
||||
setReadModal({ open: true, notifId, title });
|
||||
setReadLoading(true);
|
||||
try {
|
||||
const res = await getNotificationReadUsers(notifId);
|
||||
setReadUsers(res.items || []);
|
||||
} catch {
|
||||
message.error('加载已读列表失败');
|
||||
} finally {
|
||||
setReadLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
render: (v: string) => formatDate(v),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 180,
|
||||
render: (_: any, r: NotificationRecord) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewRead(r.id, r.title)}>
|
||||
已读
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该消息?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" danger size="small" 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>
|
||||
<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"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 条消息` }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</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={users.map(u => ({ value: u.id, label: u.username }))} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Read Users Modal */}
|
||||
<Modal
|
||||
title={<Space><TeamOutlined />已读用户 — {readModal.title}</Space>}
|
||||
open={readModal.open}
|
||||
onCancel={() => { setReadModal({ open: false, notifId: '', title: '' }); setReadUsers([]); }}
|
||||
footer={null} width={480}
|
||||
>
|
||||
{readLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : readUsers.length === 0 ? (
|
||||
<Empty description="暂无用户已读" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, color: '#64748b', fontSize: 13 }}>
|
||||
共 {readUsers.length} 人已读
|
||||
</div>
|
||||
<Table
|
||||
dataSource={readUsers}
|
||||
rowKey="userId"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '用户名', dataIndex: 'username', render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||
{ title: '已读时间', dataIndex: 'readAt', width: 180, render: (v: string) => formatDate(v) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNotificationManager;
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getOperationLogs } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface OperationLog {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
detail?: string;
|
||||
ip?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = { POST: 'green', PUT: 'blue', DELETE: 'red' };
|
||||
|
||||
const AdminOperationLogs: React.FC = () => {
|
||||
const [logs, setLogs] = useState<OperationLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const load = async (p?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOperationLogs(p || page);
|
||||
setLogs(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
message.error('加载操作日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '操作人', dataIndex: 'username', width: 120,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', dataIndex: 'action', width: 160,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '方法', dataIndex: 'method', width: 80,
|
||||
render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '路径', dataIndex: 'path', width: 220, ellipsis: true,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>操作日志</Typography.Text>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()}>刷新</Button>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: 20,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p) => { setPage(p); load(p); },
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminOperationLogs;
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Switch, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getPaymentConfigs, updatePaymentConfig } from '../api';
|
||||
|
||||
interface PaymentConfig {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const AdminPaymentConfig: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [configs, setConfigs] = useState<PaymentConfig[]>([]);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [alipayEnabled, setAlipayEnabled] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await getPaymentConfigs();
|
||||
setConfigs(data);
|
||||
const map: Record<string, string> = {};
|
||||
data.forEach((c: PaymentConfig) => { map[c.key] = c.value; });
|
||||
form.setFieldsValue({
|
||||
wechat_mch_id: map['payment_wechat_mch_id'] || '',
|
||||
wechat_api_key: map['payment_wechat_api_key'] || '',
|
||||
wechat_cert_path: map['payment_wechat_cert_path'] || '',
|
||||
wechat_notify_url: map['payment_wechat_notify_url'] || '',
|
||||
alipay_app_id: map['payment_alipay_app_id'] || '',
|
||||
alipay_private_key: map['payment_alipay_private_key'] || '',
|
||||
alipay_public_key: map['payment_alipay_public_key'] || '',
|
||||
alipay_notify_url: map['payment_alipay_notify_url'] || '',
|
||||
});
|
||||
setWechatEnabled(map['payment_wechat_enabled'] === 'true');
|
||||
setAlipayEnabled(map['payment_alipay_enabled'] === 'true');
|
||||
} catch {
|
||||
message.error('加载支付配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
const updates: [string, string][] = [
|
||||
['payment_wechat_enabled', String(wechatEnabled)],
|
||||
['payment_wechat_mch_id', values.wechat_mch_id || ''],
|
||||
['payment_wechat_api_key', values.wechat_api_key || ''],
|
||||
['payment_wechat_cert_path', values.wechat_cert_path || ''],
|
||||
['payment_wechat_notify_url', values.wechat_notify_url || ''],
|
||||
['payment_alipay_enabled', String(alipayEnabled)],
|
||||
['payment_alipay_app_id', values.alipay_app_id || ''],
|
||||
['payment_alipay_private_key', values.alipay_private_key || ''],
|
||||
['payment_alipay_public_key', values.alipay_public_key || ''],
|
||||
['payment_alipay_notify_url', values.alipay_notify_url || ''],
|
||||
];
|
||||
for (const [key, value] of updates) {
|
||||
const cfg = configs.find(c => c.key === key);
|
||||
if (cfg) {
|
||||
await updatePaymentConfig(cfg.id, value);
|
||||
}
|
||||
}
|
||||
message.success('支付配置已保存');
|
||||
load();
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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,253 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
|
||||
|
||||
interface PackageItem {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
price: number;
|
||||
bonusCredits: number;
|
||||
totalCredits: number;
|
||||
description: string | null;
|
||||
packageType: string;
|
||||
isGift: boolean;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
normal: 'blue',
|
||||
gift: 'green',
|
||||
promo: 'purple',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
normal: '常规',
|
||||
gift: '赠送',
|
||||
promo: '促销',
|
||||
};
|
||||
|
||||
const AdminRechargePackages: React.FC = () => {
|
||||
const [packages, setPackages] = useState<PackageItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRechargePackages();
|
||||
setPackages(data.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
credits: item.credits,
|
||||
price: item.price,
|
||||
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
|
||||
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
|
||||
description: item.description,
|
||||
packageType: item.package_type ?? item.packageType ?? 'normal',
|
||||
isGift: item.is_gift ?? item.isGift ?? false,
|
||||
isActive: item.is_active ?? item.isActive ?? true,
|
||||
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
|
||||
})));
|
||||
} catch {
|
||||
message.error('加载充值套餐失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
name: values.name,
|
||||
credits: values.credits,
|
||||
price: values.price,
|
||||
bonus_credits: values.bonusCredits || 0,
|
||||
description: values.description || null,
|
||||
package_type: values.packageType || 'normal',
|
||||
is_gift: values.isGift || false,
|
||||
is_active: values.isActive ?? true,
|
||||
sort_order: values.sortOrder ?? 0,
|
||||
};
|
||||
if (modal.item?.id) {
|
||||
await saveRechargePackage({ id: modal.item.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveRechargePackage(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRechargePackage(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item?: PackageItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
name: item.name,
|
||||
credits: item.credits,
|
||||
price: item.price,
|
||||
bonusCredits: item.bonusCredits,
|
||||
description: item.description,
|
||||
packageType: item.packageType,
|
||||
isGift: item.isGift,
|
||||
isActive: item.isActive,
|
||||
sortOrder: item.sortOrder,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '套餐名称', key: 'name', width: 160,
|
||||
render: (_: any, r: PackageItem) => (
|
||||
<div>
|
||||
<Typography.Text strong>{r.name}</Typography.Text>
|
||||
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'credits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
|
||||
render: (v: number) => v > 0
|
||||
? <Tag color="green">+{v.toLocaleString()}</Tag>
|
||||
: <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '总积分', key: 'total', width: 100,
|
||||
render: (_: any, r: PackageItem) => (
|
||||
<Typography.Text strong style={{ color: '#6366f1' }}>
|
||||
{(r.credits + r.bonusCredits).toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '价格(元)', dataIndex: 'price', width: 100,
|
||||
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'packageType', width: 80,
|
||||
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
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: PackageItem) => (
|
||||
<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>
|
||||
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>充值套餐管理</Typography.Text>
|
||||
<Tag color="purple">{packages.length} 个套餐</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={packages}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
|
||||
<Input placeholder="例如:进阶包" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'normal', label: '常规' },
|
||||
{ value: 'gift', label: '赠送' },
|
||||
{ value: 'promo', label: '促销' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input placeholder="套餐描述(可选)" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRechargePackages;
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Space, Typography, Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getSystemConfigs, updateSystemConfig, uploadPdf } 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 [uploading, setUploading] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
for (const config of configs) {
|
||||
const newVal = values[config.key];
|
||||
if (newVal !== undefined && newVal !== config.value) {
|
||||
await updateSystemConfig(config.id, newVal ?? '');
|
||||
}
|
||||
}
|
||||
message.success('系统配置已保存');
|
||||
const data = await getSystemConfigs();
|
||||
setConfigs(data);
|
||||
setSaving(false);
|
||||
} catch (e: any) {
|
||||
setSaving(false);
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File, configKey: string) => {
|
||||
setUploading(configKey);
|
||||
try {
|
||||
const res = await uploadPdf(file, configKey);
|
||||
// Update local state
|
||||
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ [configKey]: res.url });
|
||||
message.success('PDF上传成功');
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading('');
|
||||
}
|
||||
return false; // prevent default upload
|
||||
};
|
||||
|
||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||
'协议配置': configs.filter(c => c.key === 'user_agreement_url' || c.key === 'privacy_policy_url'),
|
||||
'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',
|
||||
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件',
|
||||
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件',
|
||||
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" />;
|
||||
};
|
||||
|
||||
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
|
||||
const label = config.key === 'user_agreement_url' ? '用户协议' : '隐私政策';
|
||||
const hasFile = config.value && config.value.startsWith('/uploads/');
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px', borderRadius: 10,
|
||||
border: '1px solid #f0f0f5', background: '#fafbfc',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<FilePdfOutlined style={{ color: '#ef4444', fontSize: 18 }} />
|
||||
<Typography.Text strong>{label}</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{hasFile && (
|
||||
<Button size="small" icon={<EyeOutlined />}
|
||||
onClick={() => window.open(`http://localhost:8000${config.value}`, '_blank')}>
|
||||
预览
|
||||
</Button>
|
||||
)}
|
||||
<Upload
|
||||
accept=".pdf"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => handleUpload(file, config.key)}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === config.key}>
|
||||
{hasFile ? '重新上传' : '上传PDF'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{hasFile ? `已上传: ${config.value}` : '尚未上传,前台将不显示对应链接'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
{group === '协议配置' ? (
|
||||
items.map(config => (
|
||||
<PdfUploadField key={config.id} config={config} />
|
||||
))
|
||||
) : (
|
||||
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,395 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword } from '../api';
|
||||
import type { AdminUser } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
const AdminUsers: React.FC = () => {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<string>('frontend');
|
||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [createType, setCreateType] = useState<string>('frontend');
|
||||
const [menuModal, setMenuModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [allMenus, setAllMenus] = useState<any[]>([]);
|
||||
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
|
||||
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [form] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [resetPwdForm] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getAdminUsers(search || undefined);
|
||||
setUsers(data);
|
||||
} catch { /* auth error handled by client */ }
|
||||
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.description);
|
||||
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 handleCreateUser = async () => {
|
||||
try {
|
||||
const values = await createForm.validateFields();
|
||||
const userType = values.user_type || 'frontend';
|
||||
await createUser({
|
||||
username: userType === 'admin' ? values.username : undefined,
|
||||
password: values.password,
|
||||
email: values.email || undefined,
|
||||
phone: userType === 'frontend' ? values.phone : (values.phone || undefined),
|
||||
credits: values.credits || 0,
|
||||
user_type: userType,
|
||||
});
|
||||
message.success('用户创建成功');
|
||||
setCreateModal(false);
|
||||
createForm.resetFields();
|
||||
setCreateType('frontend');
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const openMenuModal = async (user: AdminUser) => {
|
||||
try {
|
||||
const menus = await getMenuConfigs();
|
||||
const isAdminUser = user.userType === 'admin';
|
||||
setAllMenus(menus.filter((m: any) => {
|
||||
const target = m.menu_target ?? m.menuTarget ?? 'frontend';
|
||||
return isAdminUser ? (target === 'admin' || target === 'both') : (target === 'frontend' || target === 'both');
|
||||
}));
|
||||
setCheckedMenus(user.allowedMenus || []);
|
||||
setMenuModal({ open: true, user });
|
||||
} catch {
|
||||
message.error('加载菜单失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Build structured menu display: groups with children, and top-level pages
|
||||
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
|
||||
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
|
||||
const childMap: Record<string, any[]> = {};
|
||||
menuPages.filter((m: any) => m.parent_id ?? m.parentId).forEach((m: any) => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
const topLevelPages = menuPages.filter((m: any) => !(m.parent_id ?? m.parentId));
|
||||
|
||||
const handleSaveMenus = async () => {
|
||||
const { user } = menuModal;
|
||||
if (!user) return;
|
||||
try {
|
||||
await updateUserMenus(user.id, checkedMenus.length > 0 ? checkedMenus : null);
|
||||
message.success('菜单权限已更新');
|
||||
setMenuModal({ open: false, user: null });
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
try {
|
||||
const values = await resetPwdForm.validateFields();
|
||||
const { user } = resetPwdModal;
|
||||
if (!user) return;
|
||||
await resetUserPassword(user.id, values.newPassword);
|
||||
message.success(`已重置 ${user.username} 的密码`);
|
||||
setResetPwdModal({ open: false, user: null });
|
||||
resetPwdForm.resetFields();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u => u.userType === activeTab);
|
||||
const isAdminTab = activeTab === 'admin';
|
||||
|
||||
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>
|
||||
),
|
||||
},
|
||||
...(!isAdminTab ? [{
|
||||
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 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '最后登录', dataIndex: 'lastLoginAt', width: 140,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 320, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminUser) => (
|
||||
<Space size={4}>
|
||||
{!isAdminTab && (
|
||||
<Button type="link" size="small" icon={<WalletOutlined />}
|
||||
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
|
||||
调整积分
|
||||
</Button>
|
||||
)}
|
||||
<Button type="link" size="small" icon={<MenuOutlined />}
|
||||
onClick={() => openMenuModal(r)}>
|
||||
菜单权限
|
||||
</Button>
|
||||
<Button type="link" size="small" icon={<LockOutlined />}
|
||||
onClick={() => { setResetPwdModal({ open: true, user: r }); resetPwdForm.resetFields(); }}>
|
||||
重置密码
|
||||
</Button>
|
||||
<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', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<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>
|
||||
<Button type="primary" icon={<PlusOutlined />}
|
||||
onClick={() => { setCreateType(activeTab); createForm.setFieldsValue({ user_type: activeTab }); setCreateModal(true); }}
|
||||
style={{ borderRadius: 8 }}>
|
||||
创建用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{ key: 'frontend', label: '前台用户' },
|
||||
{ key: 'admin', label: '后台用户' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredUsers}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 10, showTotal: (t) => `共 ${t} 个用户` }}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</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="description" label="原因"
|
||||
rules={[{ required: true, message: '请输入调整原因' }]}>
|
||||
<Input.TextArea rows={2} placeholder="请输入调整原因" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Create User Modal */}
|
||||
<Modal
|
||||
title={<Space><UserOutlined />创建用户</Space>}
|
||||
open={createModal}
|
||||
onOk={handleCreateUser}
|
||||
onCancel={() => { setCreateModal(false); createForm.resetFields(); setCreateType('frontend'); }}
|
||||
okText="创建" cancelText="取消" width={480}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}
|
||||
onValuesChange={(changed) => { if (changed.user_type) setCreateType(changed.user_type); }}
|
||||
>
|
||||
<Form.Item name="user_type" label="用户类型" initialValue="frontend" rules={[{ required: true }]}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'frontend', label: '前端用户' },
|
||||
{ value: 'admin', label: '后台管理员' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
{createType === 'frontend' ? (
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||
<Input placeholder="请输入手机号" maxLength={11} size="large" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder="请输入用户名" size="large" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
{createType === 'frontend' && (
|
||||
<Form.Item name="credits" label="初始积分" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input placeholder="选填" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Menu Permission Modal */}
|
||||
<Modal
|
||||
title={<Space><MenuOutlined />菜单权限 - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
|
||||
open={menuModal.open}
|
||||
onOk={handleSaveMenus}
|
||||
onCancel={() => { setMenuModal({ open: false, user: null }); }}
|
||||
okText="保存" cancelText="取消" width={520}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
勾选该用户可访问的菜单,不勾选则显示全部菜单
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
|
||||
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{/* Top-level pages */}
|
||||
{topLevelPages.map((m: any) => (
|
||||
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
|
||||
{m.label}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
{/* Groups with their children */}
|
||||
{menuGroups.map((g: any) => {
|
||||
const children = childMap[g.id] || [];
|
||||
if (children.length === 0) return null;
|
||||
return (
|
||||
<div key={g.id}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, color: '#6366f1', marginBottom: 4, marginTop: 4 }}>
|
||||
{g.label}
|
||||
</div>
|
||||
<Space direction="vertical" size={4} style={{ paddingLeft: 12, width: '100%' }}>
|
||||
{children.map((c: any) => (
|
||||
<Checkbox key={c.path} value={c.path} style={{ width: '100%' }}>
|
||||
{c.label}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{c.path}</Typography.Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
<Modal
|
||||
title={<Space><LockOutlined />重置密码 - {resetPwdModal.user?.username}</Space>}
|
||||
open={resetPwdModal.open}
|
||||
onOk={handleResetPassword}
|
||||
onCancel={() => { setResetPwdModal({ open: false, user: null }); resetPwdForm.resetFields(); }}
|
||||
okText="确认重置" cancelText="取消" width={420}
|
||||
>
|
||||
<Form form={resetPwdForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getVideoEngines, saveVideoEngine, deleteVideoEngine } from '../api';
|
||||
|
||||
interface VideoEngine {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
apiBase: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
supportedRatios: string[];
|
||||
supportedResolutions: string[];
|
||||
supportedDurations: number[];
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function parseJsonArray(val: unknown): any[] {
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === 'string') {
|
||||
try { return JSON.parse(val); } catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const AdminVideoEngines: React.FC = () => {
|
||||
const [engines, setEngines] = useState<VideoEngine[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getVideoEngines();
|
||||
setEngines(data.map((e: any) => ({
|
||||
...e,
|
||||
supportedRatios: parseJsonArray(e.supportedRatios),
|
||||
supportedResolutions: parseJsonArray(e.supportedResolutions),
|
||||
supportedDurations: parseJsonArray(e.supportedDurations),
|
||||
})));
|
||||
} catch {
|
||||
message.error('加载视频引擎失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
name: values.name,
|
||||
provider: values.provider,
|
||||
api_base: values.apiBase,
|
||||
api_key: values.apiKey,
|
||||
model_name: values.modelName,
|
||||
supported_ratios: JSON.stringify(values.supportedRatios || []),
|
||||
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
|
||||
supported_durations: JSON.stringify(values.supportedDurations || []),
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
};
|
||||
if (modal.engine) {
|
||||
await saveVideoEngine({ id: modal.engine.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveVideoEngine(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, engine: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteVideoEngine(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (engine?: VideoEngine) => {
|
||||
setModal({ open: true, engine: engine || null });
|
||||
if (engine) {
|
||||
form.setFieldsValue(engine);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
supportedResolutions: ['480p', '720p', '1080p'],
|
||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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: '支持比例', dataIndex: 'supportedRatios', width: 200,
|
||||
render: (ratios: string[]) => <Space size={2} wrap>{ratios.map(r => <Tag key={r}>{r}</Tag>)}</Space>,
|
||||
},
|
||||
{
|
||||
title: '支持分辨率', dataIndex: 'supportedResolutions', width: 150,
|
||||
render: (res: string[]) => <Space size={2} wrap>{res.map(r => <Tag key={r} color="blue">{r}</Tag>)}</Space>,
|
||||
},
|
||||
{
|
||||
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
|
||||
render: (d: number[]) => <Tag color="orange">{d?.length ? `${Math.min(...d)}-${Math.max(...d)}s` : '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
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"
|
||||
loading={loading}
|
||||
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={620}
|
||||
>
|
||||
<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: 'ark', label: '火山引擎 (Ark)' },
|
||||
]} />
|
||||
</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="doubao-seedance-2-0-260128" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedRatios" label="支持比例">
|
||||
<Select mode="multiple" size="large" options={[
|
||||
{ value: '16:9', label: '16:9 (横屏)' },
|
||||
{ value: '4:3', label: '4:3 (标准)' },
|
||||
{ value: '1:1', label: '1:1 (方形)' },
|
||||
{ value: '3:4', label: '3:4 (竖版)' },
|
||||
{ value: '9:16', label: '9:16 (竖屏)' },
|
||||
{ value: '21:9', label: '21:9 (超宽)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="supportedResolutions" label="支持分辨率" style={{ flex: 1 }}>
|
||||
<Select mode="multiple" size="large" options={[
|
||||
{ value: '480p' }, { value: '720p' }, { value: '1080p' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
|
||||
<Select mode="multiple" size="large" options={
|
||||
Array.from({ length: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
||||
} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 2, label: '2' },
|
||||
{ value: 3, label: '3' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</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,40 @@
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import * as api from '../api';
|
||||
|
||||
interface AdminState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAdminStore = create<AdminState>((set) => ({
|
||||
user: null,
|
||||
loading: true,
|
||||
|
||||
login: async (username, password, rememberMe?) => {
|
||||
const user = await api.login(username, password, undefined, rememberMe);
|
||||
set({ user });
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await api.logout();
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) { set({ user: null, loading: false }); return; }
|
||||
const user = await api.getUser();
|
||||
set({ user, loading: false });
|
||||
} catch (error: any) {
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
localStorage.removeItem('auth_token');
|
||||
}
|
||||
set({ user: null, loading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,171 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
avatar?: string;
|
||||
credits: number;
|
||||
isAdmin: boolean;
|
||||
userType: string;
|
||||
allowedMenus?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreditRecord {
|
||||
id: string;
|
||||
type: 'consume' | 'recharge';
|
||||
amount: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type Industry =
|
||||
| 'ecommerce'
|
||||
| 'education'
|
||||
| 'gaming'
|
||||
| 'medical'
|
||||
| 'finance'
|
||||
| 'realestate'
|
||||
| 'food'
|
||||
| 'travel'
|
||||
| 'tech'
|
||||
| 'other';
|
||||
|
||||
export const INDUSTRY_LABELS: Record<Industry, string> = {
|
||||
ecommerce: '电商',
|
||||
education: '教育',
|
||||
gaming: '游戏',
|
||||
medical: '医疗',
|
||||
finance: '金融',
|
||||
realestate: '房产',
|
||||
food: '餐饮',
|
||||
travel: '旅游',
|
||||
tech: '科技',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
industry: Industry;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type AspectRatio = '16:9' | '9:16' | '1:1' | '4:3';
|
||||
export type Resolution = '720p' | '1080p' | '4K';
|
||||
|
||||
export type GenerationStatus =
|
||||
| 'optimizing'
|
||||
| 'prompt_optimized'
|
||||
| 'generating'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export interface GenerationRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
originalPrompt: string;
|
||||
optimizedPrompt: string;
|
||||
duration: number;
|
||||
aspectRatio: AspectRatio;
|
||||
resolution: Resolution;
|
||||
status: GenerationStatus;
|
||||
videoUrl?: string;
|
||||
creditsCost: number;
|
||||
createdAt: string;
|
||||
generatedAt?: string;
|
||||
}
|
||||
|
||||
export interface GenerationParams {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
aspectRatio: AspectRatio;
|
||||
resolution: Resolution;
|
||||
}
|
||||
|
||||
export interface OptimizeResult {
|
||||
optimizedPrompt: string;
|
||||
creditsCost: number;
|
||||
}
|
||||
|
||||
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;
|
||||
allowedMenus?: string[] | null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AdminGenerationRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
originalPrompt: string;
|
||||
optimizedPrompt: string;
|
||||
duration?: number;
|
||||
aspectRatio?: string;
|
||||
resolution?: string;
|
||||
status: 'prompt_optimized' | 'generating' | 'completed' | 'failed';
|
||||
videoUrl?: string;
|
||||
references?: { url: string; type: string; name: string }[];
|
||||
creditsCost: number;
|
||||
textCreditsCost: number;
|
||||
textTokensUsed: number;
|
||||
videoTokensUsed: number;
|
||||
errorMessage?: string;
|
||||
createdAt: string;
|
||||
generatedAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '-';
|
||||
let s = iso.trim();
|
||||
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