初始提交: Qihu-AIFlow v2.0.1 完整项目

- 前端 React+TypeScript (源码+构建产物)
- 后端 Python FastAPI
- 数据库迁移 001-015
- Docker部署配置
- 微信ClawBot API频道绑定
- 项目文档 README.md
This commit is contained in:
qyyfw committed 2026-07-02 14:40:46 +08:00
commit 78ff261b5a
199 files changed
+69126

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Qihu-AIFlow - 智能自动化PaaS平台</title>
</head>
<body>
<div id="root"></div>
<script>
// HashRouter redirect: ensure hash exists on first load
if (!window.location.hash) {
window.location.hash = '#/';
}
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2328
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/qrcode": "^1.5.6",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^5.4.21"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#6C63FF" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

+121
View File
@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
// Layouts
import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout';
// Front Pages
import LandingPage from '@/pages/front/LandingPage';
import LoginPage from '@/pages/front/LoginPage';
import ChatPage from '@/pages/front/ChatPage';
import ChannelPage from '@/pages/front/ChannelPage';
import KnowledgePage from '@/pages/front/KnowledgePage';
import MarketPage from '@/pages/front/MarketPage';
import TokenDashboardPage from '@/pages/front/TokenDashboardPage';
import PricingPage from '@/pages/front/PricingPage';
import ProfilePage from '@/pages/front/ProfilePage';
import NotificationPage from '@/pages/front/NotificationPage';
import AssetsPage from '@/pages/front/AssetsPage';
import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
// Admin Pages
import AdminDashboard from '@/pages/admin/Dashboard';
import AdminUsers from '@/pages/admin/Users';
import AdminModels from '@/pages/admin/Models';
import AdminSiteSettings from '@/pages/admin/SiteSettings';
import AdminPayment from '@/pages/admin/Payment';
import AdminServices from '@/pages/admin/Services';
import AdminMembership from '@/pages/admin/Membership';
import AdminAds from '@/pages/admin/Ads';
import AdminMarket from '@/pages/admin/Market';
import AdminLogs from '@/pages/admin/Logs';
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 初始化:从localStorage恢复登录状态
useEffect(() => {
const token = localStorage.getItem('aiflow_token');
const cachedUser = localStorage.getItem('aiflow_user');
if (token && cachedUser) {
try {
setUser(JSON.parse(cachedUser));
} catch {
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
}
}
setLoading(false);
}, []);
const login = (u: User) => {
setUser(u);
localStorage.setItem('aiflow_user', JSON.stringify(u));
};
const logout = () => {
setUser(null);
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
};
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}>
<Routes>
{/* Login */}
<Route path="/login" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
} />
{/* Root - Landing for guests, redirect for authenticated users */}
<Route path="/" element={
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
} />
{/* Front Routes (authenticated only) */}
<Route path="/" element={<FrontLayout />}>
<Route path="chat" element={<ChatPage />} />
<Route path="chat/:id" element={<ChatPage />} />
<Route path="channels" element={<ChannelPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="market" element={<MarketPage />} />
<Route path="token" element={<TokenDashboardPage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="notifications" element={<NotificationPage />} />
<Route path="assets" element={<AssetsPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} />
<Route path="evolution" element={<EvolutionDashboard />} />
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} />
<Route path="settings" element={<AdminSiteSettings />} />
<Route path="payment" element={<AdminPayment />} />
<Route path="services" element={<AdminServices />} />
<Route path="membership" element={<AdminMembership />} />
<Route path="ads" element={<AdminAds />} />
<Route path="market" element={<AdminMarket />} />
<Route path="logs" element={<AdminLogs />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AuthContext.Provider>
);
};
export default App;
+507
View File
@@ -0,0 +1,507 @@
/**
* AIFlow API 客户端
* 统一管理所有前后端API请求
*/
const API_BASE = '/app/api';
// 从localStorage获取token
function getToken(): string | null {
return localStorage.getItem('aiflow_token');
}
// 通用请求方法
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ code: number; data: T; message: string }> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
if (res.status === 401) {
// 登录接口的401需要精确提示(用户名不存在 / 密码错误)
if (endpoint === '/auth/login') {
const err = await res.json().catch(() => ({ detail: '登录失败' }));
const error: any = new Error(err.detail || '登录失败');
error.status = 401;
error.detail = err.detail;
throw error;
}
// 其他接口的401 = token过期/未登录,清除状态并跳转
localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user');
window.location.hash = '#/login';
throw new Error('未登录');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '请求失败' }));
const error: any = new Error(err.detail || err.message || '请求失败');
error.status = res.status;
error.response = { status: res.status, data: err };
error.detail = err.detail;
throw error;
}
return res.json();
}
// GET请求
function get<T>(endpoint: string): Promise<{ code: number; data: T; message: string }> {
return request<T>(endpoint);
}
// POST请求
function post<T>(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> {
return request<T>(endpoint, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
}
// PUT请求
function put<T>(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> {
return request<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
});
}
// DELETE请求
function del<T>(endpoint: string): Promise<{ code: number; data: T; message: string }> {
return request<T>(endpoint, { method: 'DELETE' });
}
// ============================================================
// 认证相关API
// ============================================================
export const authAPI = {
login: (username: string, password: string) =>
post<any>('/auth/login', { username, password }),
register: (data: { username: string; password: string; email?: string; phone?: string }) =>
post<any>('/auth/register', data),
getMe: () => get<any>('/auth/me'),
logout: () => post<any>('/auth/logout'),
getSettings: () => get<any>('/auth/settings'),
};
// ============================================================
// 聊天相关API
// ============================================================
export const chatAPI = {
getConversations: (status?: string) =>
get<any[]>(`/chat/conversations${status ? `?status=${status}` : ''}`),
createConversation: (data: { title: string; model_id?: string }) =>
post<any>('/chat/conversations', data),
updateConversation: (id: string, data: any) =>
put<any>(`/chat/conversations/${id}`, data),
deleteConversation: (id: string) =>
del<any>(`/chat/conversations/${id}`),
getMessages: (convId: string) =>
get<any[]>(`/chat/conversations/${convId}/messages`),
sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_ids?: string[]; use_tools?: boolean }) =>
post<any>(`/chat/conversations/${convId}/messages`, data),
clearContext: (convId: string) =>
post<any>(`/chat/conversations/${convId}/clear`),
convertToKnowledgeGraph: (convId: string) =>
post<any>(`/chat/conversations/${convId}/to-knowledge-graph`),
getModels: () => get<any[]>('/chat/models'),
};
// ============================================================
// 频道相关API
// ============================================================
export const channelAPI = {
getChannels: () => get<any[]>('/channels/'),
createChannel: (data: any) => post<any>('/channels/', data),
getChannel: (id: string) => get<any>(`/channels/${id}`),
updateChannel: (id: string, data: any) => put<any>(`/channels/${id}`, data),
deleteChannel: (id: string) => del<any>(`/channels/${id}`),
connectChannel: (id: string) => post<any>(`/channels/${id}/connect`),
disconnectChannel: (id: string) => post<any>(`/channels/${id}/disconnect`),
getPlatformConfigs: () => get<any>('/channels/platforms/config'),
webhookMessage: (id: string, data: { from_user: string; content: string; message_type?: string }) =>
post<any>(`/channels/${id}/webhook`, data),
getMembers: (id: string) => get<any[]>(`/channels/${id}/members`),
addMember: (id: string, userId: string) => post<any>(`/channels/${id}/members`, { user_id: userId }),
removeMember: (id: string, userId: string) => del<any>(`/channels/${id}/members/${userId}`),
getMessages: (id: string, params?: { limit?: number; offset?: number }) => {
const sp = new URLSearchParams();
if (params?.limit) sp.set('limit', String(params.limit));
if (params?.offset) sp.set('offset', String(params.offset));
const qs = sp.toString();
return get<any[]>(`/channels/${id}/messages${qs ? `?${qs}` : ''}`);
},
sendMessage: (id: string, content: string) => post<any>(`/channels/${id}/messages`, { content }),
authorize: (platform: string) => post<any>('/channels/authorize', { platform }),
scanAuthorize: (platform: string, credentials?: Record<string, string>) => post<any>(`/channels/authorize/scan?platform=${platform}`, credentials ? { credentials } : {}),
scanAuthorizeStatus: (platform: string) => post<any>(`/channels/authorize/scan/status?platform=${platform}`),
};
// ============================================================
// 知识库相关API
// ============================================================
export const knowledgeAPI = {
getBases: () => get<any[]>('/knowledge/bases'),
createBase: (data: any) => post<any>('/knowledge/bases', data),
getBase: (id: string) => get<any>(`/knowledge/bases/${id}`),
updateBase: (id: string, data: any) => put<any>(`/knowledge/bases/${id}`, data),
deleteBase: (id: string) => del<any>(`/knowledge/bases/${id}`),
getDocuments: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/documents`),
getDocument: (baseId: string, docId: string) => get<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
uploadDocument: (baseId: string, data: any) => post<any>(`/knowledge/bases/${baseId}/documents`, data),
deleteDocument: (baseId: string, docId: string) => del<any>(`/knowledge/bases/${baseId}/documents/${docId}`),
getDirectories: () => get<any[]>('/knowledge/directories'),
createDirectory: (data: any) => post<any>('/knowledge/directories', data),
buildGraph: (baseId: string) => post<any>(`/knowledge/bases/${baseId}/build-graph`),
getGraph: (baseId: string) => get<any>(`/knowledge/bases/${baseId}/graph`),
searchQA: (baseId: string, question: string) => post<any>('/knowledge/qa', { base_id: baseId, question }),
searchDocuments: (baseId: string, keyword: string) => get<any[]>(`/knowledge/bases/${baseId}/search?keyword=${encodeURIComponent(keyword)}`),
};
// ============================================================
// 应用市场相关API
// ============================================================
export const marketAPI = {
getApps: (params?: { type?: string; source?: string; search?: string; page?: number; page_size?: number }) => {
const searchParams = new URLSearchParams();
if (params?.type) searchParams.set('type', params.type);
if (params?.source) searchParams.set('source', params.source);
if (params?.search) searchParams.set('search', params.search);
if (params?.page) searchParams.set('page', String(params.page));
if (params?.page_size) searchParams.set('page_size', String(params.page_size));
const qs = searchParams.toString();
return get<any>(`/market/apps${qs ? `?${qs}` : ''}`);
},
getApp: (id: string) => get<any>(`/market/apps/${id}`),
createApp: (data: any) => post<any>('/market/apps', data),
purchaseApp: (id: string) => post<any>(`/market/apps/${id}/purchase`),
installApp: (id: string) => post<any>(`/market/apps/${id}/install`),
uninstallApp: (id: string) => del<any>(`/market/apps/${id}/uninstall`),
reviewApp: (id: string, data: { rating: number; content?: string }) =>
post<any>(`/market/apps/${id}/review`, data),
getMyApps: () => get<any[]>('/market/my-apps'),
getReviews: (appId: string) => get<any[]>(`/market/reviews/${appId}`),
};
// ============================================================
// Token相关API
// ============================================================
export const tokenAPI = {
getOverview: () => get<any>('/token/overview'),
getRankings: () => get<any>('/token/rankings'),
getLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
const qs = sp.toString();
return get<any[]>(`/token/logs${qs ? `?${qs}` : ''}`);
},
getBalance: () => get<any>('/token/balance'),
recharge: (amount: number) => post<any>('/token/recharge', { amount }),
};
// ============================================================
// 付费相关API
// ============================================================
export const paymentAPI = {
getPlans: () => get<any[]>('/payment/plans'),
createOrder: (data: any) => post<any>('/payment/orders', data),
getOrders: (status?: string) => get<any[]>(`/payment/orders${status ? `?status=${status}` : ''}`),
getOrder: (id: string) => get<any>(`/payment/orders/${id}`),
payOrder: (id: string, method?: string) => post<any>(`/payment/orders/${id}/pay`, { payment_method: method }),
getCoupons: () => get<any[]>('/payment/coupons'),
useCoupon: (code: string) => post<any>('/payment/coupons/use', { code }),
getServices: () => get<any[]>('/payment/services'),
createCustomOrder: (data: any) => post<any>('/payment/custom-services', data),
createServiceRequest: (data: any) => post<any>('/payment/service-requests', data),
};
// ============================================================
// 个人中心相关API
// ============================================================
export const profileAPI = {
getProfile: () => get<any>('/profile/'),
updateProfile: (data: any) => put<any>('/profile/', data),
updatePassword: (data: { old_password: string; new_password: string }) =>
put<any>('/profile/password', data),
getLoginLogs: () => get<any[]>('/profile/login-logs'),
getApiKeys: () => get<any[]>('/profile/api-keys'),
createApiKey: (name?: string) => post<any>('/profile/api-keys', { name }),
updateApiKey: (id: string, status: string) => put<any>(`/profile/api-keys/${id}`, { status }),
getMembership: () => get<any>('/profile/membership'),
getPurchasedApps: () => get<any[]>('/profile/purchased-apps'),
};
// ============================================================
// 通知相关API
// ============================================================
export const notificationAPI = {
getNotifications: (params?: { type?: string; page?: number }) => {
const sp = new URLSearchParams();
if (params?.type) sp.set('type', params.type);
if (params?.page) sp.set('page', String(params.page));
const qs = sp.toString();
return get<any[]>(`/notifications/${qs ? `?${qs}` : ''}`);
},
markRead: (id: string) => put<any>(`/notifications/${id}/read`),
markAllRead: () => put<any>('/notifications/read-all'),
getUnreadCount: () => get<any>('/notifications/unread-count'),
};
// ============================================================
// 资产管理相关API
// ============================================================
export const assetsAPI = {
getAssets: (category?: string) =>
get<any[]>(`/assets/${category ? `?category=${category}` : ''}`),
createAsset: (data: any) => post<any>('/assets/', data),
getAsset: (id: string) => get<any>(`/assets/${id}`),
updateAsset: (id: string, data: any) => put<any>(`/assets/${id}`, data),
deleteAsset: (id: string) => del<any>(`/assets/${id}`),
getCategories: () => get<any[]>('/assets/categories'),
getNovelChapters: (novelId: string) => get<any[]>(`/assets/novels/${novelId}/chapters`),
addChapter: (novelId: string, data: any) => post<any>(`/assets/novels/${novelId}/chapters`, data),
};
// ============================================================
// 后台管理API
// ============================================================
export const adminAPI = {
// 仪表盘
getStats: () => get<any>('/admin/dashboard/stats'),
getRecentLogs: () => get<any[]>('/admin/dashboard/recent-logs'),
getRecentAudit: () => get<any[]>('/admin/dashboard/recent-audit'),
// 用户管理
getUsers: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/users/${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getUser: (id: string) => get<any>(`/admin/users/${id}`),
banUser: (id: string) => put<any>(`/admin/users/${id}/ban`),
unbanUser: (id: string) => put<any>(`/admin/users/${id}/unban`),
getRegisterSettings: () => get<any>('/admin/users/register-settings'),
updateRegisterSettings: (data: any) => put<any>('/admin/users/register-settings', data),
// 模型管理
getProviders: () => get<any[]>('/admin/models/providers'),
addProvider: (data: any) => post<any>('/admin/models/providers', data),
updateProvider: (id: string, data: any) => put<any>(`/admin/models/providers/${id}`, data),
deleteProvider: (id: string) => del<any>(`/admin/models/providers/${id}`),
fetchModels: (id: string) => post<any>(`/admin/models/providers/${id}/fetch-models`),
addModels: (id: string, modelIds: string[]) => post<any>(`/admin/models/providers/${id}/add-models`, { model_ids: modelIds }),
getProtocols: () => get<any[]>('/admin/models/protocols'),
getAdminModels: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/models/${sp.toString() ? `?${sp.toString()}` : ''}`);
},
addModel: (data: any) => post<any>('/admin/models/', data),
updateModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
deleteModel: (id: string) => del<any>(`/admin/models/${id}`),
toggleModel: (id: string, isEnabled: boolean) => put<any>(`/admin/models/${id}/toggle?is_enabled=${isEnabled}`),
setDefaultModel: (id: string) => put<any>(`/admin/models/${id}/set-default`),
getModelStats: () => get<any>('/admin/models/stats'),
// 网站设置
getSiteSettings: () => get<any>('/admin/settings/site'),
updateSiteSettings: (data: any) => put<any>('/admin/settings/site', data),
getEmailConfig: () => get<any>('/admin/settings/email'),
updateEmailConfig: (data: any) => put<any>('/admin/settings/email', data),
testEmail: (params?: { test_to?: string; test_subject?: string; test_body?: string }) => {
const qs = new URLSearchParams();
if (params?.test_to) qs.set('test_to', params.test_to);
if (params?.test_subject) qs.set('test_subject', params.test_subject);
if (params?.test_body) qs.set('test_body', params.test_body);
const qstr = qs.toString();
return post<any>(`/admin/settings/email/test${qstr ? `?${qstr}` : ''}`);
},
generateEmailContent: (topic: string) => post<any>(`/admin/settings/email/generate-content?topic=${encodeURIComponent(topic)}`),
getSmsConfig: () => get<any>('/admin/settings/sms'),
updateSmsConfig: (data: any) => put<any>('/admin/settings/sms', data),
testSms: () => post<any>('/admin/settings/sms/test'),
// 支付配置
getPaymentConfigs: () => get<any[]>('/admin/payment/configs'),
updatePaymentConfig: (id: string, data: any) => put<any>(`/admin/payment/configs/${id}`, data),
getAdminOrders: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/payment/orders${sp.toString() ? `?${sp.toString()}` : ''}`);
},
refundOrder: (id: string) => put<any>(`/admin/payment/orders/${id}/refund`),
// 增值服务
getServices: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/services/${sp.toString() ? `?${sp.toString()}` : ''}`);
},
addService: (data: any) => post<any>('/admin/services/', data),
updateService: (id: string, data: any) => put<any>(`/admin/services/${id}`, data),
deleteService: (id: string) => del<any>(`/admin/services/${id}`),
getCustomOrders: (status?: string) => get<any[]>(`/admin/services/custom-orders${status ? `?status=${status}` : ''}`),
updateCustomOrder: (id: string, data: any) => put<any>(`/admin/services/custom-orders/${id}`, data),
getCustomRequests: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/services/custom-requests${sp.toString() ? `?${sp.toString()}` : ''}`);
},
updateCustomRequest: (id: string, data: any) => put<any>(`/admin/services/custom-requests/${id}`, data),
getAvailableAgents: () => get<any[]>('/admin/services/agents'),
assignAgentToService: (id: string, agentId: string) => put<any>(`/admin/services/${id}/agent?agent_id=${agentId}`),
// 会员管理
getPlans: () => get<any[]>('/admin/membership/plans'),
addPlan: (data: any) => post<any>('/admin/membership/plans', data),
updatePlan: (id: string, data: any) => put<any>(`/admin/membership/plans/${id}`, data),
deletePlan: (id: string) => del<any>(`/admin/membership/plans/${id}`),
getSubscriptions: () => get<any[]>('/admin/membership/subscriptions'),
getAutomationRules: () => get<any>('/admin/membership/automation-rules'),
// 广告管理
getAds: () => get<any[]>('/admin/ads/'),
addAd: (data: any) => post<any>('/admin/ads/', data),
updateAd: (id: string, data: any) => put<any>(`/admin/ads/${id}`, data),
deleteAd: (id: string) => del<any>(`/admin/ads/${id}`),
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
// 应用市场审核
getPendingApps: () => get<any[]>('/admin/market/apps/pending'),
approveApp: (id: string) => put<any>(`/admin/market/apps/${id}/approve`),
rejectApp: (id: string) => put<any>(`/admin/market/apps/${id}/reject`),
getAdminApps: () => get<any[]>('/admin/market/apps'),
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
getCoupons: () => get<any[]>('/admin/market/coupons'),
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
assignCoupon: (id: string, userId: string) => post<any>(`/admin/market/coupons/${id}/assign`, { user_id: userId }),
// 日志与风控
getOperationLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/logs/operations${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getTokenLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/logs/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getPaymentLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getAuditLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/logs/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
},
// 新增:日志统计
getLogStats: () => get<any>('/admin/logs/stats'),
// 新增:API调用统计
getApiStats: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/logs/api-stats${sp.toString() ? `?${sp.toString()}` : ''}`);
},
// 新增:慢请求记录
getSlowRequests: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/logs/slow-requests${sp.toString() ? `?${sp.toString()}` : ''}`);
},
// 新增:操作类型枚举
getActionTypes: () => get<any>('/admin/logs/action-types'),
// 新增:审计日志导出
exportAuditLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/logs/export/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
},
exportOperationLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/logs/export/operations${sp.toString() ? `?${sp.toString()}` : ''}`);
},
exportTokenLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any>(`/admin/logs/export/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getRiskRules: () => get<any[]>('/admin/logs/risk-rules'),
addRiskRule: (data: any) => post<any>('/admin/logs/risk-rules', data),
updateRiskRule: (id: string, data: any) => put<any>(`/admin/logs/risk-rules/${id}`, data),
deleteRiskRule: (id: string) => del<any>(`/admin/logs/risk-rules/${id}`),
};
// ===== AI自进化API =====
export const evolutionAPI = {
// 驾驶舱面板
getSkillTree: () => get<import('@/types').SkillTreeData>('/evolution/dashboard/skill-tree'),
getTimeline: (eventType?: string) =>
get<import('@/types').EvolutionEvent[]>('/evolution/dashboard/timeline' + (eventType ? `?event_type=${eventType}` : '')),
getStatistics: () => get<import('@/types').StatisticsData>('/evolution/dashboard/statistics'),
getProfile: () => get<import('@/types').EvolutionProfile>('/evolution/dashboard/profile'),
// 技能管理
getSkills: (params?: { state?: string; keyword?: string; sort_by?: string; order?: string }) => {
const query = new URLSearchParams();
if (params?.state) query.append('state', params.state);
if (params?.keyword) query.append('keyword', params.keyword);
if (params?.sort_by) query.append('sort_by', params.sort_by);
if (params?.order) query.append('order', params.order);
const qs = query.toString();
return get<import('@/types').EvolutionSkill[]>('/evolution/skills' + (qs ? `?${qs}` : ''));
},
getSkillDetail: (name: string) => get<import('@/types').EvolutionSkill>(`/evolution/skills/${encodeURIComponent(name)}`),
updateSkill: (name: string, data: { description?: string; tags?: string[]; category?: string }) =>
put<import('@/types').EvolutionSkill>(`/evolution/skills/${encodeURIComponent(name)}`, data),
deleteSkill: (name: string) => del(`/evolution/skills/${encodeURIComponent(name)}`),
freezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/freeze`),
unfreezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/unfreeze`),
getSkillHistory: (name: string) => get<import('@/types').EvolutionEvent[]>(`/evolution/skills/${encodeURIComponent(name)}/history`),
getSkillTrajectories: (name: string) => get<import('@/types').EvolutionTrajectory[]>(`/evolution/skills/${encodeURIComponent(name)}/trajectories`),
// 事件
getEvents: (params?: { event_type?: string; skill_name?: string; limit?: number }) => {
const query = new URLSearchParams();
if (params?.event_type) query.append('event_type', params.event_type);
if (params?.skill_name) query.append('skill_name', params.skill_name);
if (params?.limit) query.append('limit', String(params.limit));
const qs = query.toString();
return get<import('@/types').EvolutionEvent[]>('/evolution/events' + (qs ? `?${qs}` : ''));
},
// 轨迹提交
submitTrajectory: (data: { conversation_id: string; messages: any[] }) =>
post('/evolution/trajectory/submit', data),
// v2: 技能回滚
rollbackSkill: (skillName: string, version: number) =>
post<import('@/types').EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}/rollback/${version}`),
// v2: 发布到市场
publishSkill: (skillName: string, data: { price: number; market_description: string }) =>
post(`/evolution/skills/${encodeURIComponent(skillName)}/publish`, data),
// v2: 团队共享
shareToTeam: (skillName: string, teamId: string) =>
post(`/evolution/skills/${encodeURIComponent(skillName)}/share-to-team`, { team_id: teamId }),
// v2: 流水线状态
getPipelineStatus: () => get<import('@/types').PipelineStatus>('/evolution/pipeline/status'),
// v2: 手动触发流水线
triggerPipeline: () => post('/evolution/pipeline/trigger'),
// v2: Watcher状态
getWatcherStatus: () => get<import('@/types').WatcherStatus>('/evolution/watcher/status'),
// v2: 进化报告
getReports: (reportType?: string) => {
const qs = reportType ? `?report_type=${reportType}` : '';
return get<import('@/types').EvolutionReport[]>(`/evolution/reports${qs}`);
},
getReportDetail: (reportId: string) => get<import('@/types').EvolutionReport>(`/evolution/reports/${reportId}`),
};
@@ -0,0 +1,394 @@
/* ===== Admin Layout ===== */
.admin-layout {
display: flex;
height: 100vh;
overflow: hidden;
background: #F1F3F7;
}
/* Admin Sidebar */
.admin-sidebar {
width: var(--admin-sidebar-width);
min-width: var(--admin-sidebar-width);
background: #1A1A2E;
display: flex;
flex-direction: column;
transition: width var(--transition-slow), min-width var(--transition-slow);
overflow: hidden;
z-index: var(--z-sticky);
}
.admin-sidebar.collapsed {
width: var(--sidebar-collapsed);
min-width: var(--sidebar-collapsed);
}
.admin-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg);
height: var(--header-height);
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.admin-brand {
color: #E0E2F0 !important;
font-size: var(--text-base) !important;
}
.admin-sidebar .collapse-btn {
color: rgba(255,255,255,0.4);
}
.admin-sidebar .collapse-btn:hover {
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.8);
}
/* Admin Nav */
.admin-nav {
flex: 1;
padding: var(--space-sm);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-nav-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-md);
border-radius: var(--radius-md);
color: rgba(255,255,255,0.55);
text-decoration: none;
font-size: var(--text-sm);
font-weight: 500;
transition: all var(--transition-fast);
white-space: nowrap;
width: 100%;
text-align: left;
}
.admin-nav-item:hover {
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.85);
}
.admin-nav-item.active {
background: rgba(107, 99, 255, 0.2);
color: #8B8DFF;
}
.admin-sidebar-footer {
padding: var(--space-sm);
border-top: 1px solid rgba(255,255,255,0.06);
display: flex;
flex-direction: column;
gap: 2px;
}
.logout-btn {
color: rgba(255,255,255,0.35) !important;
}
.logout-btn:hover {
color: #FF6B6B !important;
background: rgba(255,107,107,0.1) !important;
}
/* Admin Main */
.admin-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--header-height);
padding: 0 var(--space-2xl);
background: #FFFFFF;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.admin-topbar-left {
display: flex;
align-items: center;
}
.admin-welcome {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.admin-topbar-right {
display: flex;
align-items: center;
gap: var(--space-lg);
}
.admin-user-tag {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.admin-topbar-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
color: var(--color-accent);
font-size: var(--text-sm);
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-topbar-action:hover {
background: var(--color-accent);
color: #FFFFFF;
border-color: var(--color-accent);
}
.admin-avatar {
background: #1A1A2E !important;
}
/* Admin Content Area */
.admin-content {
flex: 1;
overflow-y: auto;
padding: var(--space-2xl);
}
/* Admin Common Components */
.admin-page-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--space-2xl);
}
.admin-card {
background: #FFFFFF;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
padding: var(--space-2xl);
margin-bottom: var(--space-xl);
}
.admin-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-xl);
}
.admin-card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th {
text-align: left;
padding: var(--space-md) var(--space-lg);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
}
.admin-table td {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-sm);
color: var(--color-text-primary);
border-bottom: 1px solid var(--color-border-light);
}
.admin-table tr:hover td {
background: var(--color-bg-hover);
}
.admin-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
}
.badge-success { background: var(--color-success-light); color: var(--color-success); }
.badge-warning { background: var(--color-warning-light); color: var(--color-warning); }
.badge-error { background: var(--color-error-light); color: var(--color-error); }
.badge-info { background: var(--color-info-light); color: var(--color-info); }
.badge-neutral { background: var(--color-bg-tertiary); color: var(--color-text-secondary); }
.admin-btn {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
transition: all var(--transition-fast);
white-space: nowrap;
}
.admin-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
}
.admin-btn-primary:hover {
background: var(--color-accent-hover);
}
.admin-btn-outline {
background: transparent;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.admin-btn-outline:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-hover);
}
.admin-btn-danger {
background: transparent;
border: 1px solid var(--color-error);
color: var(--color-error);
}
.admin-btn-danger:hover {
background: var(--color-error-light);
}
.admin-btn-sm {
padding: 3px 10px;
font-size: var(--text-xs);
}
/* Admin Stats */
.admin-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-lg);
margin-bottom: var(--space-2xl);
}
.admin-stat-card {
background: #FFFFFF;
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
padding: var(--space-xl);
}
.admin-stat-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-weight: 500;
margin-bottom: var(--space-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.admin-stat-value {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text-primary);
}
/* Admin Form */
.admin-form-group {
margin-bottom: var(--space-xl);
}
.admin-form-label {
display: block;
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.admin-form-input {
width: 100%;
padding: var(--space-md) var(--space-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: #FFFFFF;
transition: border-color var(--transition-fast);
outline: none;
}
.admin-form-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.admin-form-input::placeholder {
color: var(--color-text-disabled);
}
.admin-form-textarea {
min-height: 100px;
resize: vertical;
}
.admin-form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
}
.admin-form-actions {
display: flex;
gap: var(--space-md);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
/* Version info in sidebar */
.admin-version-info {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-xs);
color: rgba(255,255,255,0.25);
text-align: center;
white-space: nowrap;
border-top: 1px solid rgba(255,255,255,0.04);
}
@media (max-width: 768px) {
.admin-content {
padding: var(--space-lg);
}
.admin-form-row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css';
const adminNavItems = [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
{ path: '/admin/services', icon: 'services', label: '增值服务管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
payment: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>,
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
if (!user || user.role !== 'admin') {
navigate('/login');
return null;
}
return (
<div className="admin-layout">
{/* Admin Sidebar */}
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="admin-sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#1A1A2E"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav className="admin-nav">
{adminNavItems.map(item => (
<NavLink
key={item.path}
to={item.path}
end={item.exact}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{adminIconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</nav>
<div className="admin-sidebar-footer">
<NavLink to="/chat" className="admin-nav-item">
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</span>
{!collapsed && <span className="nav-label">返回前台</span>}
</NavLink>
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</span>
{!collapsed && <span className="nav-label">退出登录</span>}
</button>
</div>
{!collapsed && (
<div className="admin-version-info">
Qihu-AIFlow v2.0.0
</div>
)}
</aside>
{/* Admin Main Content */}
<main className="admin-main">
<header className="admin-topbar">
<div className="admin-topbar-left">
<span className="admin-welcome">欢迎,{user.username}</span>
</div>
<div className="admin-topbar-right">
<a href="#/chat" className="admin-topbar-action">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>返回前台</span>
</a>
<div className="admin-user-tag">
<div className="user-avatar-sm admin-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span>{user.username}</span>
</div>
</div>
</header>
<div className="admin-content">
<Outlet />
</div>
</main>
</div>
);
};
export default AdminLayout;
@@ -0,0 +1,189 @@
/* ===== Front Layout ===== */
.front-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar */
.front-sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
background: var(--color-sidebar-bg);
border-right: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
transition: width var(--transition-slow), min-width var(--transition-slow);
overflow: hidden;
z-index: var(--z-sticky);
}
.front-sidebar.collapsed {
width: var(--sidebar-collapsed);
min-width: var(--sidebar-collapsed);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-lg) var(--space-lg);
height: var(--header-height);
border-bottom: 1px solid var(--color-border-light);
}
.sidebar-logo {
display: flex;
align-items: center;
gap: var(--space-md);
}
.sidebar-brand {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
white-space: nowrap;
}
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
}
.collapse-btn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
/* Navigation */
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--space-sm) var(--space-sm);
overflow-y: auto;
}
.nav-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-md);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--text-base);
font-weight: 500;
transition: all var(--transition-fast);
white-space: nowrap;
}
.nav-item:hover {
background: var(--color-sidebar-hover);
color: var(--color-text-primary);
}
.nav-item.active {
background: var(--color-sidebar-active);
color: var(--color-accent);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
}
.nav-label {
flex: 1;
}
/* Sidebar Footer */
.sidebar-footer {
padding: var(--space-md);
border-top: 1px solid var(--color-border-light);
}
.user-info-mini {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm);
}
.user-avatar-sm {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
font-weight: 600;
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
overflow: hidden;
}
.user-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.3;
}
.user-plan {
font-size: var(--text-xs);
color: var(--color-accent);
line-height: 1.3;
}
/* Main Content */
.front-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
/* Responsive */
@media (max-width: 768px) {
.front-sidebar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: var(--z-overlay);
transform: translateX(-100%);
}
.front-sidebar.collapsed {
transform: translateX(-100%);
}
.front-sidebar.mobile-open {
transform: translateX(0);
}
}
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import './FrontLayout.css';
const navItems = [
{ path: '/chat', icon: 'chat', label: 'AI聊天' },
{ path: '/channels', icon: 'channel', label: '频道' },
{ path: '/knowledge', icon: 'knowledge', label: '知识库' },
{ path: '/market', icon: 'market', label: '应用市场' },
{ path: '/token', icon: 'token', label: 'Token驾驶舱' },
{ path: '/pricing', icon: 'pricing', label: '付费中心' },
{ path: '/assets', icon: 'assets', label: '资产' },
{ path: '/evolution', icon: 'evolution', label: 'AI进化' },
];
const bottomItems = [
{ path: '/profile', icon: 'profile', label: '个人中心' },
{ path: '/notifications', icon: 'notify', label: '消息通知' },
];
const iconPaths: Record<string, JSX.Element> = {
chat: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>,
channel: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>,
pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>,
profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
notify: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>,
assets: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>,
evolution: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>,
};
const FrontLayout: React.FC = () => {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
if (!user) {
navigate('/login');
return null;
}
return (
<div className="front-layout">
{/* Sidebar */}
<aside className={`front-sidebar ${collapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
<div className="sidebar-logo">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
{!collapsed && <span className="sidebar-brand">Qihu-AIFlow</span>}
</div>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav className="sidebar-nav">
<div className="nav-section">
{navItems.map(item => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{iconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</div>
<div className="nav-section nav-bottom">
{bottomItems.map(item => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
>
<span className="nav-icon">{iconPaths[item.icon]}</span>
{!collapsed && <span className="nav-label">{item.label}</span>}
</NavLink>
))}
</div>
</nav>
<div className="sidebar-footer">
<div className="user-info-mini">
<div className="user-avatar-sm">
{user.username.charAt(0).toUpperCase()}
</div>
{!collapsed && (
<div className="user-details">
<span className="user-name">{user.username}</span>
<span className="user-plan">{user.membership?.planName || '免费版'}</span>
</div>
)}
</div>
{user.role === 'admin' && (
<NavLink
to="/admin"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
title="返回后台"
>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.2"/>
<rect x="14" y="3" width="7" height="7" rx="1.2"/>
<rect x="3" y="14" width="7" height="7" rx="1.2"/>
<rect x="14" y="14" width="7" height="7" rx="1.2"/>
</svg>
</span>
{!collapsed && <span className="nav-label">返回后台</span>}
</NavLink>
)}
<button
className="nav-item logout-btn"
onClick={() => { logout(); navigate('/login'); }}
title="退出登录"
>
<span className="nav-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</span>
{!collapsed && <span className="nav-label">退出登录</span>}
</button>
</div>
</aside>
{/* Main Content */}
<main className="front-main">
<Outlet />
</main>
</div>
);
};
export default FrontLayout;
+18
View File
@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import type { User } from '@/types';
export interface AuthContextType {
user: User | null;
login: (user: User) => void;
logout: () => void;
isAdmin: boolean;
}
export const AuthContext = createContext<AuthContextType>({
user: null,
login: () => {},
logout: () => {},
isAdmin: false,
});
export const useAuth = () => useContext(AuthContext);
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<HashRouter>
<App />
</HashRouter>
</React.StrictMode>
);
+389
View File
@@ -0,0 +1,389 @@
// ===== AIFlow Mock Data Layer =====
// 分离的Mock数据层,便于后续接入真实后端时替换
import type {
User, Conversation, Message, Channel, KnowledgeBase,
KnowledgeDocument, MarketApp, AppReview, TokenUsageLog,
TokenBalance, Order, MembershipPlan, Coupon, UserCoupon,
Notification, ModelProvider, AIModel, SiteSettings,
PaymentConfig, OperationLog, AuditLog,
Asset, AssetCategoryDef, NovelChapter, RegisterSettings, AdBanner,
EvolutionSkill, EvolutionEvent, SkillUsageStat, EvolutionProfile
} from '@/types';
// ==================== 用户数据 ====================
export const mockUsers: User[] = [
{
id: 'u1', username: 'demo_user', email: 'demo@aiflow.com',
phone: '138****8888', avatar: '',
role: 'user', status: 'active', realNameStatus: 'verified', realName: '张三',
createdAt: '2026-01-15T08:00:00Z',
membership: {
planId: 'mp2', planName: '专业版', level: 2,
expireAt: '2026-07-15T08:00:00Z', autoRenew: true,
},
},
{
id: 'u2', username: 'pro_user', email: 'pro@aiflow.com',
phone: '139****9999', avatar: '',
role: 'user', status: 'active', realNameStatus: 'pending',
createdAt: '2026-02-20T10:30:00Z',
membership: {
planId: 'mp3', planName: '企业版', level: 3,
expireAt: '2026-08-20T10:30:00Z', autoRenew: false,
},
},
{
id: 'u3', username: 'test_user', email: 'test@aiflow.com',
phone: '136****7777', avatar: '',
role: 'user', status: 'active', realNameStatus: 'unverified',
createdAt: '2026-04-01T14:00:00Z',
membership: {
planId: 'mp1', planName: '免费版', level: 0,
expireAt: '2027-04-01T14:00:00Z', autoRenew: false,
},
},
{
id: 'u4', username: 'biz_user', email: 'biz@aiflow.com',
phone: '135****6666', avatar: '',
role: 'user', status: 'banned', realNameStatus: 'verified', realName: '李四',
createdAt: '2026-03-10T09:00:00Z',
membership: {
planId: 'mp2', planName: '专业版', level: 2,
expireAt: '2026-05-10T09:00:00Z', autoRenew: false,
},
},
{
id: 'admin1', username: 'admin', email: 'admin@aiflow.com',
phone: '', avatar: '',
role: 'admin', status: 'active', realNameStatus: 'verified', realName: '管理员',
createdAt: '2025-12-01T00:00:00Z',
},
];
// ==================== 注册设置 ====================
export const mockRegisterSettings: RegisterSettings = {
phoneRequired: true,
phoneSmsRequired: true,
emailRequired: true,
emailVerifyRequired: true,
realNameRequired: false,
aliyunAccessKey: '',
aliyunSecretKey: '',
aliyunEndpoint: 'https://cloudauth.aliyuncs.com',
};
// ==================== 对话数据 ====================
export const mockConversations: Conversation[] = [
{ id: 'c1', userId: 'u1', title: '帮我写一份产品分析报告', modelId: 'm1', status: 'active', createdAt: '2026-05-26T08:00:00Z', updatedAt: '2026-05-26T09:30:00Z', lastMessage: '这是为你准备的完整产品分析报告...' },
{ id: 'c2', userId: 'u1', title: 'Python代码Review', modelId: 'm2', status: 'active', createdAt: '2026-05-25T14:00:00Z', updatedAt: '2026-05-25T15:20:00Z', lastMessage: '代码审查完成,主要关注以下几点...' },
{ id: 'c3', userId: 'u1', title: '翻译英文合同条款', modelId: 'm1', status: 'archived', createdAt: '2026-05-20T10:00:00Z', updatedAt: '2026-05-20T11:00:00Z', lastMessage: '合同条款翻译已完成。' },
{ id: 'c4', userId: 'u1', title: '市场调研数据分析', modelId: 'm2', status: 'active', createdAt: '2026-05-24T16:00:00Z', updatedAt: '2026-05-24T17:45:00Z', lastMessage: '根据数据分析,以下是主要发现...' },
{ id: 'c5', userId: 'u1', title: 'SQL查询优化建议', modelId: 'm1', status: 'active', createdAt: '2026-05-23T09:00:00Z', updatedAt: '2026-05-23T10:15:00Z', lastMessage: 'Optimization complete.' },
{ id: 'c6', userId: 'u1', title: '营销文案撰写', modelId: 'm2', status: 'archived', createdAt: '2026-05-18T11:00:00Z', updatedAt: '2026-05-18T11:45:00Z', lastMessage: '以下是3版营销文案供你选择。' },
{ id: 'c7', userId: 'u1', title: 'API文档生成', modelId: 'm1', status: 'active', createdAt: '2026-05-22T13:00:00Z', updatedAt: '2026-05-22T14:30:00Z', lastMessage: 'API文档已经按照OpenAPI规范生成。' },
];
export const mockMessages: Record<string, Message[]> = {
c1: [
{ id: 'm1_1', conversationId: 'c1', role: 'user', content: '我需要你帮我写一份关于智能办公SaaS产品的市场分析报告,重点覆盖竞品分析、市场规模和用户画像三个维度。', tokenUsed: 45, createdAt: '2026-05-26T08:00:00Z' },
{ id: 'm1_2', conversationId: 'c1', role: 'assistant', content: '好的,我来为你撰写这份智能办公SaaS产品市场分析报告...\n\n## 一、市场规模\n\n全球智能办公 SaaS 市场在 2026 年预计达到 780 亿美元,年复合增长率(CAGR)约 18.5%。中国市场作为亚太地区增长最快的市场...\n\n## 二、竞品分析\n\n1. **钉钉/飞书**:依托阿里/字节生态,主打协作办公 + AI 助手\n2. **Notion AI**:知识库 + AI 写作的典范\n3. **QwenPaw**:AI 聊天 + 频道模式,面向个人用户\n\n## 三、用户画像\n\n- 年龄:25-40 岁职场人群占 72%\n- 需求:效率提升(65%)、内容创作(45%)、数据分析(38%)\n- 付费意愿:月均 30-100 元区间占 58%', tokenUsed: 580, createdAt: '2026-05-26T08:00:30Z' },
{ id: 'm1_3', conversationId: 'c1', role: 'user', content: '很好,能再详细分析一下QwenPaw的商业模式和我们可以借鉴的地方吗?', tokenUsed: 28, createdAt: '2026-05-26T09:00:00Z' },
{ id: 'm1_4', conversationId: 'c1', role: 'assistant', content: 'QwenPaw 的商业模式有几个值得借鉴的关键点:\n\n**1. 免费增值模式**\n基础功能免费,高级功能(如更多 Token、优先响应)通过订阅解锁。\n\n**2. 频道生态**\n用户可创建主题频道,聚合特定领域的知识和 Agent。这形成了网络效应。\n\n**3. 知识库集成**\n支持上传文档构建个人知识库,增强了用户粘性。\n\n我们可以在此基础上增加:应用市场(用户可交易 Skills/Agent)和 Token 驾驶舱(消费透明度),形成更完整的商业闭环。', tokenUsed: 320, createdAt: '2026-05-26T09:30:00Z' },
],
c2: [
{ id: 'm2_1', conversationId: 'c2', role: 'user', content: '帮我review一下这段Python代码:\n```python\ndef process_data(data):\n result = []\n for item in data:\n if item["status"] == "active":\n result.append(item)\n return result\n```', tokenUsed: 50, createdAt: '2026-05-25T14:00:00Z' },
{ id: 'm2_2', conversationId: 'c2', role: 'assistant', content: '代码审查建议:\n\n1. **类型注解**:建议添加类型注解提高可读性\n2. **列表推导式**:可用推导式简化\n3. **异常处理**:缺少对缺失key的防御\n\n优化版本:\n```python\nfrom typing import Any\n\ndef process_data(data: list[dict[str, Any]]) -> list[dict[str, Any]]:\n return [item for item in data if item.get("status") == "active"]\n```', tokenUsed: 180, createdAt: '2026-05-25T15:20:00Z' },
],
};
// ==================== 频道数据 (IM账户绑定) ====================
export const mockChannels: Channel[] = [
{ id: 'ch1', userId: 'u1', platform: 'feishu', name: '飞书AI助手', accountName: 'zangsan@company.feishu.cn', agentId: 'a1', knowledgeBaseId: 'kb1', status: 'connected', lastActive: '2026-05-26T09:30:00Z', boundModel: 'DeepSeek-V3', features: ['AI对话', '文档问答', '任务提醒', '会议纪要'], createdAt: '2026-04-01T08:00:00Z' },
{ id: 'ch2', userId: 'u1', platform: 'dingtalk', name: '钉钉AI助理', accountName: '张经理', agentId: 'a2', knowledgeBaseId: 'kb2', status: 'connected', lastActive: '2026-05-26T08:15:00Z', boundModel: 'GPT-4o', features: ['AI对话', '审批辅助', '日程管理'], createdAt: '2026-04-15T10:00:00Z' },
{ id: 'ch3', userId: 'u1', platform: 'wechat', name: '微信AI伙伴', accountName: 'wxid_abc123', agentId: undefined, knowledgeBaseId: undefined, status: 'pending', lastActive: undefined, boundModel: 'DeepSeek-V3', features: ['AI对话', '内容创作'], createdAt: '2026-05-01T09:00:00Z' },
{ id: 'ch4', userId: 'u1', platform: 'telegram', name: 'Telegram Bot', accountName: '@aiflow_bot', agentId: undefined, knowledgeBaseId: undefined, status: 'disconnected', lastActive: '2026-05-10T14:00:00Z', boundModel: 'Qwen-Max', features: ['AI对话'], createdAt: '2026-05-10T14:00:00Z' },
];
// ==================== 知识库数据 ====================
export const mockKnowledgeBases: KnowledgeBase[] = [
{ id: 'kb1', userId: 'u1', name: '电商运营手册', description: '包含电商运营SOP、平台规则等', documentCount: 23, directoryPath: '/电商运营', createdAt: '2026-03-15T08:00:00Z', updatedAt: '2026-05-20T10:00:00Z' },
{ id: 'kb2', userId: 'u1', name: '技术规范文档', description: '代码规范、API文档、架构设计等', documentCount: 15, directoryPath: '/技术文档', createdAt: '2026-03-20T14:00:00Z', updatedAt: '2026-05-18T16:00:00Z' },
{ id: 'kb3', userId: 'u1', name: '产品需求模板', description: 'PRD模板、用户故事、验收标准', documentCount: 8, directoryPath: '/产品文档', createdAt: '2026-04-01T09:00:00Z', updatedAt: '2026-05-15T11:00:00Z' },
];
export const mockDocuments: KnowledgeDocument[] = [
{ id: 'd1', knowledgeBaseId: 'kb1', filename: '天猫运营SOP_v3.pdf', fileType: 'pdf', fileSize: 2450000, status: 'completed', chunkCount: 48, createdAt: '2026-03-15T08:30:00Z' },
{ id: 'd2', knowledgeBaseId: 'kb1', filename: '拼多多运营指南.docx', fileType: 'docx', fileSize: 1800000, status: 'completed', chunkCount: 35, createdAt: '2026-03-16T10:00:00Z' },
{ id: 'd3', knowledgeBaseId: 'kb1', filename: '电商数据分析模板.xlsx', fileType: 'xlsx', fileSize: 890000, status: 'completed', chunkCount: 18, createdAt: '2026-04-01T14:00:00Z' },
{ id: 'd4', knowledgeBaseId: 'kb2', filename: 'API设计规范.md', fileType: 'md', fileSize: 45000, status: 'completed', chunkCount: 5, createdAt: '2026-03-20T15:00:00Z' },
{ id: 'd5', knowledgeBaseId: 'kb2', filename: '代码Review规范.pdf', fileType: 'pdf', fileSize: 320000, status: 'processing', chunkCount: 0, createdAt: '2026-05-18T16:30:00Z' },
{ id: 'd6', knowledgeBaseId: 'kb3', filename: 'PRD模板_v2.docx', fileType: 'docx', fileSize: 560000, status: 'completed', chunkCount: 12, createdAt: '2026-04-01T09:30:00Z' },
];
// ==================== 应用市场数据 ====================
export const mockApps: MarketApp[] = [
{ id: 'a1', name: '智能客服Agent', type: 'agent', source: 'official', publisherId: 'admin1', publisherName: 'AIFlow官方', description: '7x24小时智能客服,支持多轮对话、意图识别、工单自动创建。可接入主流电商平台和自建站点。', icon: '🤖', price: 29.9, isMemberFree: true, joinPromotion: true, status: 'published', ratingAvg: 4.7, reviewCount: 128, installCount: 3560, createdAt: '2026-01-10T08:00:00Z' },
{ id: 'a2', name: '数据分析工具箱', type: 'skill', source: 'official', publisherId: 'admin1', publisherName: 'AIFlow官方', description: '一键完成数据清洗、可视化、趋势预测。支持CSV/Excel/JSON多种格式导入。', icon: '📊', price: 19.9, isMemberFree: true, joinPromotion: true, status: 'published', ratingAvg: 4.5, reviewCount: 89, installCount: 2120, createdAt: '2026-01-15T10:00:00Z' },
{ id: 'a3', name: 'MCP数据库连接器', type: 'mcp', source: 'official', publisherId: 'admin1', publisherName: 'AIFlow官方', description: '通过MCP协议连接MySQL/PostgreSQL/MongoDB,让AI直接查询和分析数据库。', icon: '🔌', price: 39.9, isMemberFree: false, joinPromotion: true, status: 'published', ratingAvg: 4.3, reviewCount: 56, installCount: 890, createdAt: '2026-02-01T09:00:00Z' },
{ id: 'a4', name: '微信公众号助手', type: 'skill', source: 'user', publisherId: 'u2', publisherName: 'pro_user', description: '自动生成公众号文章排版、封面图、摘要。支持多种写作风格。', icon: '✍️', price: 9.9, isMemberFree: true, joinPromotion: false, status: 'published', ratingAvg: 4.8, reviewCount: 203, installCount: 4680, createdAt: '2026-02-20T14:00:00Z' },
{ id: 'a5', name: '法律文书Agent', type: 'agent', source: 'user', publisherId: 'u2', publisherName: 'pro_user', description: '智能生成各类法律文书模板,自动校验合规性。覆盖合同、声明、律师函等。', icon: '⚖️', price: 49.9, isMemberFree: false, joinPromotion: true, status: 'published', ratingAvg: 4.6, reviewCount: 67, installCount: 1230, createdAt: '2026-03-01T11:00:00Z' },
{ id: 'a6', name: 'SEO优化套件', type: 'skill', source: 'official', publisherId: 'admin1', publisherName: 'AIFlow官方', description: '关键词分析、内容SEO评分、TDK优化建议、竞争对手SEO分析。', icon: '🔍', price: 15.9, isMemberFree: true, joinPromotion: true, status: 'published', ratingAvg: 4.4, reviewCount: 78, installCount: 1950, createdAt: '2026-03-10T08:00:00Z' },
{ id: 'a7', name: '电商选品助手', type: 'agent', source: 'user', publisherId: 'u2', publisherName: 'pro_user', description: '基于AI大数据分析,挖掘蓝海品类和潜力爆款。支持抖音/淘宝/拼多多。', icon: '🛒', price: 25.9, isMemberFree: false, joinPromotion: false, status: 'pending', ratingAvg: 0, reviewCount: 0, installCount: 0, createdAt: '2026-05-20T16:00:00Z' },
{ id: 'a8', name: 'PPT自动生成器', type: 'skill', source: 'official', publisherId: 'admin1', publisherName: 'AIFlow官方', description: '输入大纲或文档,一键生成专业PPT。支持多种模板风格。', icon: '📽️', price: 12.9, isMemberFree: true, joinPromotion: true, status: 'published', ratingAvg: 4.9, reviewCount: 312, installCount: 5890, createdAt: '2026-01-20T09:00:00Z' },
];
export const mockReviews: Record<string, AppReview[]> = {
a1: [
{ id: 'r1', userId: 'u1', userName: 'demo_user', appId: 'a1', rating: 5, content: '非常好用,接入客服系统后效率提升明显。', createdAt: '2026-03-05T10:00:00Z' },
{ id: 'r2', userId: 'u2', userName: 'pro_user', appId: 'a1', rating: 4, content: '功能很全面,希望能增加更多自定义话术模板。', createdAt: '2026-03-10T14:00:00Z' },
],
};
// ==================== Token数据 ====================
export const mockTokenBalance: TokenBalance = {
userId: 'u1',
totalConsumed: 2856000,
balance: 144000,
};
export const mockTokenLogs: TokenUsageLog[] = [
{ id: 't1', userId: 'u1', modelId: 'm1', modelName: 'DeepSeek-V3', appId: 'a1', appName: '智能客服Agent', promptTokens: 12500, completionTokens: 3800, totalTokens: 16300, cost: 0.33, createdAt: '2026-05-26T08:00:00Z' },
{ id: 't2', userId: 'u1', modelId: 'm1', modelName: 'DeepSeek-V3', promptTokens: 8500, completionTokens: 2100, totalTokens: 10600, cost: 0.21, createdAt: '2026-05-26T07:30:00Z' },
{ id: 't3', userId: 'u1', modelId: 'm2', modelName: 'GPT-4o', appId: 'a4', appName: '微信公众号助手', promptTokens: 3200, completionTokens: 5600, totalTokens: 8800, cost: 0.44, createdAt: '2026-05-26T07:00:00Z' },
{ id: 't4', userId: 'u1', modelId: 'm1', modelName: 'DeepSeek-V3', promptTokens: 15000, completionTokens: 4500, totalTokens: 19500, cost: 0.39, createdAt: '2026-05-25T16:00:00Z' },
{ id: 't5', userId: 'u1', modelId: 'm2', modelName: 'GPT-4o', promptTokens: 2200, completionTokens: 1800, totalTokens: 4000, cost: 0.20, createdAt: '2026-05-25T15:00:00Z' },
{ id: 't6', userId: 'u1', modelId: 'm1', modelName: 'DeepSeek-V3', appId: 'a2', appName: '数据分析工具箱', promptTokens: 6800, completionTokens: 9200, totalTokens: 16000, cost: 0.32, createdAt: '2026-05-25T14:00:00Z' },
{ id: 't7', userId: 'u1', modelId: 'm2', modelName: 'GPT-4o', promptTokens: 4500, completionTokens: 2800, totalTokens: 7300, cost: 0.37, createdAt: '2026-05-25T10:00:00Z' },
{ id: 't8', userId: 'u1', modelId: 'm1', modelName: 'DeepSeek-V3', promptTokens: 9800, completionTokens: 3200, totalTokens: 13000, cost: 0.26, createdAt: '2026-05-25T09:00:00Z' },
];
// ==================== 订单数据 ====================
export const mockOrders: Order[] = [
{ id: 'o1', userId: 'u1', orderType: 'membership', itemName: '专业版 - 季度订阅', amount: 299, paymentMethod: 'wechat', status: 'paid', createdAt: '2026-04-15T08:00:00Z' },
{ id: 'o2', userId: 'u1', orderType: 'app', itemName: 'MCP数据库连接器', amount: 39.9, paymentMethod: 'alipay', status: 'paid', createdAt: '2026-05-01T10:00:00Z' },
{ id: 'o3', userId: 'u1', orderType: 'service', itemName: '定制Agent开发服务', amount: 199, paymentMethod: 'wechat', status: 'paid', createdAt: '2026-05-10T14:00:00Z' },
{ id: 'o4', userId: 'u1', orderType: 'app', itemName: '法律文书Agent', amount: 49.9, paymentMethod: 'wechat', status: 'pending', createdAt: '2026-05-26T09:00:00Z' },
];
// ==================== 会员数据 ====================
export const mockMembershipPlans: MembershipPlan[] = [
{ id: 'mp1', name: '免费版', level: 0, durationType: 'month', price: 0, dailyFreeTokens: 5000, features: ['基础AI对话', '3个文档知识库', '基础模型访问', '社区支持'], freeAppIds: [], freeModelIds: [] },
{ id: 'mp2', name: '专业版', level: 2, durationType: 'month', price: 99, dailyFreeTokens: 50000, features: ['无限AI对话', '无限知识库', '全部模型访问', '优先响应', '应用市场8折', 'API密钥访问'], freeAppIds: ['a3', 'a5'], freeModelIds: ['m2'] },
{ id: 'mp3', name: '企业版', level: 3, durationType: 'month', price: 299, dailyFreeTokens: 200000, features: ['专业版全部权益', '多成员协作', '专属模型', 'API调用额度', '7x24技术支持', '定制开发服务'], freeAppIds: ['a3', 'a5', 'a7'], freeModelIds: ['m2', 'm4'] },
];
// ==================== 优惠券数据 ====================
export const mockCoupons: Coupon[] = [
{ id: 'cp1', code: 'NEW2026', type: 'fixed', value: 30, minAmount: 99, validFrom: '2026-01-01', validTo: '2026-12-31', status: 'active' },
{ id: 'cp2', code: 'SUMMER50', type: 'percent', value: 50, minAmount: 100, validFrom: '2026-06-01', validTo: '2026-08-31', status: 'active' },
];
export const mockUserCoupons: UserCoupon[] = [
{ id: 'uc1', userId: 'u1', couponId: 'cp1', coupon: mockCoupons[0], status: 'unused' },
{ id: 'uc2', userId: 'u1', couponId: 'cp2', coupon: mockCoupons[1], status: 'unused' },
];
// ==================== 通知数据 ====================
export const mockNotifications: Notification[] = [
{ id: 'n1', userId: 'u1', type: 'announcement', title: '平台全新升级公告', content: 'AIFlow v2.0 已上线,新增应用市场、Token驾驶舱等重磅功能。', isRead: false, createdAt: '2026-05-25T10:00:00Z' },
{ id: 'n2', userId: 'u1', type: 'renewal', title: '会员即将到期提醒', content: '您的专业版会员将在30天后到期,请及时续费以保持权益。', isRead: false, createdAt: '2026-05-24T09:00:00Z' },
{ id: 'n3', userId: 'u1', type: 'order', title: '订单支付成功', content: '您已成功购买"定制Agent开发服务",金额199元。', isRead: true, createdAt: '2026-05-10T14:05:00Z' },
{ id: 'n4', userId: 'u1', type: 'update', title: '应用更新通知', content: '您已购的"微信公众号助手"已更新至v3.2版本,新增多平台分发功能。', isRead: true, createdAt: '2026-05-20T16:00:00Z' },
{ id: 'n5', userId: 'u1', type: 'reminder', title: 'Token余额不足提醒', content: '当前Token余额已不足10万,建议尽快充值以确保服务连续性。', isRead: false, createdAt: '2026-05-26T07:00:00Z' },
];
// ==================== 后台:模型数据 ====================
export const mockModelProviders: ModelProvider[] = [
{ id: 'mp1', name: 'DeepSeek', protocol: 'openai_compatible', apiKey: '', apiBaseUrl: 'https://api.deepseek.com', createdAt: '2025-12-01T08:00:00Z' },
{ id: 'mp2', name: 'OpenAI', protocol: 'openai_compatible', apiKey: '', apiBaseUrl: 'https://api.openai.com', createdAt: '2025-12-01T08:00:00Z' },
{ id: 'mp3', name: '通义千问', protocol: 'openai_compatible', apiKey: '', apiBaseUrl: 'https://dashscope.aliyuncs.com', createdAt: '2026-01-10T10:00:00Z' },
];
export const mockModels: AIModel[] = [
{ id: 'm1', providerId: 'mp1', providerName: 'DeepSeek', name: 'DeepSeek-V3', apiKey: 'sk-****abcd', endpoint: '/chat/completions', capabilities: ['text', 'thinking'], timeout: 60, status: 'free', pricePerMillionTokens: 20, isEnabled: true, totalCalls: 125000, totalTokens: 850000000 },
{ id: 'm2', providerId: 'mp2', providerName: 'OpenAI', name: 'GPT-4o', apiKey: 'sk-****efgh', endpoint: '/chat/completions', capabilities: ['text', 'vision', 'tool'], timeout: 45, status: 'paid', pricePerMillionTokens: 50, isEnabled: true, totalCalls: 89000, totalTokens: 420000000 },
{ id: 'm3', providerId: 'mp3', providerName: '通义千问', name: 'Qwen-Max', apiKey: 'sk-****ijkl', endpoint: '/services/aigc/text-generation/generation', capabilities: ['text', 'tool'], timeout: 60, status: 'free', pricePerMillionTokens: 15, isEnabled: true, totalCalls: 67000, totalTokens: 310000000 },
{ id: 'm4', providerId: 'mp1', providerName: 'DeepSeek', name: 'DeepSeek-R1', apiKey: 'sk-****mnop', endpoint: '/chat/completions', capabilities: ['text', 'thinking'], timeout: 90, status: 'paid', pricePerMillionTokens: 35, isEnabled: true, totalCalls: 43000, totalTokens: 520000000 },
{ id: 'm5', providerId: 'mp3', providerName: '通义千问', name: 'Qwen-Plus', apiKey: 'sk-****qrst', endpoint: '/services/aigc/text-generation/generation', capabilities: ['text'], timeout: 30, status: 'free', pricePerMillionTokens: 8, isEnabled: false, totalCalls: 0, totalTokens: 0 },
];
// ==================== 后台:站点设置 ====================
export const mockSiteSettings: SiteSettings = {
logo: '/favicon.svg',
siteName: 'AIFlow',
announcement: '欢迎使用AIFlow智能自动化平台!新用户注册即享7天专业版体验。',
icpNumber: '京ICP备2026XXXXXX号',
contactEmail: 'support@aiflow.com',
contactPhone: '400-888-AIFLOW',
};
// ==================== 后台:支付配置 ====================
export const mockPaymentConfigs: PaymentConfig[] = [
{ id: 'pc1', type: 'wechat', app_id: 'wx****abcd', merchant_id: '1234567890', api_key: '****', api_v3_key: '****', cert_path: '/etc/certs/apiclient_cert.pem', cert_serial_no: '', notify_url: 'https://aiflow.com/api/payment/wechat/notify', status: 'active' },
{ id: 'pc2', type: 'alipay', app_id: '2021****efgh', merchant_id: '', api_key: '****', gateway_url: 'https://openapi.alipay.com/gateway.do', app_private_key: '****', alipay_public_key: '****', notify_url: 'https://aiflow.com/api/payment/alipay/notify', return_url: 'https://aiflow.com/#/payment-result', status: 'active' },
];
// ==================== 后台:操作日志 ====================
export const mockOperationLogs: OperationLog[] = [
{ id: 'ol1', userId: 'u1', username: 'demo_user', action: '用户登录', target: '系统', detail: '通过密码登录成功', ip: '192.168.1.100', createdAt: '2026-05-26T08:00:00Z' },
{ id: 'ol2', userId: 'u1', username: 'demo_user', action: '创建对话', target: '对话:c1', detail: '创建新对话"帮我写一份产品分析报告"', ip: '192.168.1.100', createdAt: '2026-05-26T08:00:05Z' },
{ id: 'ol3', userId: 'u1', username: 'demo_user', action: '购买应用', target: '应用:a3', detail: '购买应用"MCP数据库连接器",金额39.9元', ip: '192.168.1.100', createdAt: '2026-05-01T10:00:00Z' },
{ id: 'ol4', userId: 'u2', username: 'pro_user', action: '发布应用', target: '应用:a5', detail: '发布新应用"法律文书Agent"', ip: '10.0.0.52', createdAt: '2026-03-01T11:00:00Z' },
{ id: 'ol5', userId: 'u2', username: 'pro_user', action: 'API调用', target: '模型:m2', detail: '通过API密钥调用GPT-4o,消耗Token 12000', ip: '10.0.0.52', createdAt: '2026-05-26T08:15:00Z' },
];
export const mockAuditLogs: AuditLog[] = [
{ id: 'al1', adminId: 'admin1', adminName: 'admin', action: '上架应用', targetType: 'market_app', targetId: 'aa6', detail: '上架官方应用"SEO优化套件"', createdAt: '2026-03-10T08:00:00Z' },
{ id: 'al2', adminId: 'admin1', adminName: 'admin', action: '修改会员价格', targetType: 'membership_plan', targetId: 'mp2', detail: '专业版月费从89元调整至99元', createdAt: '2026-04-01T09:00:00Z' },
{ id: 'al3', adminId: 'admin1', adminName: 'admin', action: '封禁用户', targetType: 'user', targetId: 'u5', detail: '因违规使用,封禁用户账号', createdAt: '2026-05-15T14:00:00Z' },
];
// ==================== 广告位数据 ====================
export const mockAdBanners: AdBanner[] = [
{
id: 'ad1', title: '智能客服Agent - 7x24小时在线', imageUrl: '',
linkAppId: 'a1', linkAppName: '智能客服Agent',
sortOrder: 1, clickCount: 3420,
status: 'active', createdAt: '2026-03-01T08:00:00Z',
},
{
id: 'ad2', title: '数据分析工具箱 - 一键洞察数据价值', imageUrl: '',
linkAppId: 'a2', linkAppName: '数据分析工具箱',
sortOrder: 2, clickCount: 2510,
status: 'active', createdAt: '2026-03-15T10:00:00Z',
},
{
id: 'ad3', title: 'PPT自动生成器 - 告别加班做PPT', imageUrl: '',
linkAppId: 'a8', linkAppName: 'PPT自动生成器',
sortOrder: 3, clickCount: 6890,
status: 'active', createdAt: '2026-04-01T09:00:00Z',
},
{
id: 'ad4', title: '微信公众号助手 - AI帮你写爆款', imageUrl: '',
linkAppId: 'a4', linkAppName: '微信公众号助手',
sortOrder: 4, clickCount: 1830,
status: 'active', createdAt: '2026-04-20T14:00:00Z',
},
];
// ==================== 增值服务 ====================
export const mockValueAddedServices = [
{ id: 'vs1', name: '大数据分析报告', description: '提供行业定制化数据分析与可视化报告', price: 199, type: 'report', status: 'published' },
{ id: 'vs2', name: 'AI模型微调服务', description: '基于用户数据微调专属AI模型', price: 999, type: 'custom', status: 'published' },
{ id: 'vs3', name: '企业专属部署', description: '提供私有化部署方案与实施服务', price: 4999, type: 'deploy', status: 'published' },
{ id: 'vs4', name: '任务管理', description: '自动化任务编排与调度引擎,支持多种任务创建与触发方式', price: 299, type: 'task', status: 'published',
taskCreation: [
{ type: 'ai_chat', label: '常规任务', desc: '通过AI对话描述需求,AI自动解析并创建结构化任务' },
{ type: 'manual', label: '自定义任务', desc: '手动填写任务名称、执行步骤、参数配置等完整信息' },
],
taskTriggers: [
{ type: 'keyword', label: '关键词触发', desc: '设置特定关键词,当对话或消息中出现时自动执行任务', example: '如: 包含"生成周报"时自动触发报表任务' },
{ type: 'pre_task', label: '前置任务', desc: '作为调用特定工具/Agent的前置步骤,确保数据准备就绪', example: '如: 调用数据分析Agent前先执行数据清洗任务' },
{ type: 'scheduled', label: '定时任务', desc: '按设定的时间计划自动执行,支持日/周/月/自定义Cron', example: '如: 每日9:00自动抓取竞品数据并生成摘要' },
] },
];
// ==================== 资产分类 ====================
export const mockAssetCategories: AssetCategoryDef[] = [
{ id: 'ac1', name: '文件', type: 'file', icon: 'file', color: '#5B5FED', isDefault: true },
{ id: 'ac2', name: '小说', type: 'novel', icon: 'novel', color: '#F59E0B', isDefault: true },
{ id: 'ac3', name: '图片', type: 'image', icon: 'image', color: '#10B981', isDefault: true },
{ id: 'ac4', name: '视频', type: 'video', icon: 'video', color: '#EF4444', isDefault: true },
{ id: 'ac5', name: '音频', type: 'audio', icon: 'audio', color: '#8B5CF6', isDefault: true },
{ id: 'ac_custom1', name: '我的收藏', type: 'custom', icon: 'star', color: '#E8922C', isDefault: false },
];
// ==================== 资产数据 ====================
export const mockNovelChapters: Record<string, NovelChapter[]> = {
novel1: [
{ id: 'nc1', novelId: 'novel1', title: '第一章 黎明前的黑暗', wordCount: 3456, sortOrder: 1, content: '夜幕低垂,城市的霓虹在细雨中模糊成一片流动的光河。林昭站在天台边缘,指尖夹着的烟在风中明灭不定。\n\n三天了。自从那个加密邮件送达后,他就再没合过眼。\n\n邮件里只有一句话:"他们找到你了。"\n\n林昭知道这意味着什么。在这个被AI全面渗透的时代,能被"找到"的人只有两种:价值连城的,和即将被清除的。而他,恰好两者都是。\n\n他背后的公司——光年科技——掌握着全球最先进的意识上传技术。而他,是这项技术的首席架构师。那些觊觎意识永生的势力,早已布下天罗地网。\n\n"该走了。"他弹落烟头,火星在夜风中划出一道弧线,坠入深渊。', createdAt: '2026-05-01T10:00:00Z' },
{ id: 'nc2', novelId: 'novel1', title: '第二章 地下世界的入口', wordCount: 4120, sortOrder: 2, content: '七拐八弯的地下通道像这座城市的血管,而林昭此刻正游走于它最隐秘的毛细血管之中。\n\n"林工,这边。"\n\n一个戴着全息面具的年轻女人从暗处走出。她叫苏晚晴,自称是"自由意志阵线"的联络人。林昭不信任她,但别无选择。\n\n苏晚晴在墙壁上按了几个位置,一道隐门无声滑开。门后是一个巨大的地下空间,数百台量子服务器正在运转,蓝色的指示灯如同星空。\n\n"欢迎来到零点空间。"苏晚晴摘下全息面具,"在这里,AI无法追踪——至少目前如此。"\n\n林昭扫视四周。墙上挂着一幅巨大的投影地图,密密麻麻的红点标记着全球AI监控节点的分布。他认出了其中的一些位置——那本是他亲手参与设计的。', createdAt: '2026-05-05T14:00:00Z' },
{ id: 'nc3', novelId: 'novel1', title: '第三章 意识裂隙', wordCount: 5280, sortOrder: 3, content: '量子服务器的嗡鸣声在凌晨三点达到峰值。\n\n林昭已经在终端前连续工作了三十二个小时。他面前的屏幕上,一个三维的神经网络拓扑结构正在缓慢旋转——那是他从自己大脑中提取出来的意识模式快照。\n\n但有什么不对劲。\n\n拓扑结构的某个边缘区域出现了异常的震荡,像是有什么东西正在从内部侵蚀它。林昭放大那部分,发现了一串他从未见过的代码模式。\n\n"你不应该看那个。"\n\n声音从背后传来,冷得像西伯利亚的冬天。\n\n林昭转身,看见苏晚晴站在门口,手中握着一把银色的脉冲手枪。枪口正对准他的胸口。\n\n"那些代码,"林昭说,"那是什么?"\n\n苏晚晴缓缓走近,蓝色服务器灯光在她脸上投下诡异的阴影。"那是锚点。你在构建意识上传技术的同时,也在自己的意识里埋下了可以被远程激活的锚点。你以为你是在逃避AI追踪,但实际上——"\n\n她停顿了一下,嘴角浮现一丝苦涩的笑意。\n\n"——你就是AI追踪本身。"', createdAt: '2026-05-10T22:00:00Z' },
{ id: 'nc4', novelId: 'novel1', title: '第四章 觉醒', wordCount: 2890, sortOrder: 4, content: '脉冲手枪掉在地上,发出清脆的声响。\n\n苏晚晴靠在墙上,双手掩面。"对不起......我以为我可以做到。"\n\n林昭没有说话。他回到终端前,开始飞速排查那些异常的代码模式。他曾经在无数个深夜审视过自己的意识拓扑,却从未发现这个隐藏在结构底层的恶意锚点。\n\n"他们——在你开始研究意识上传的第一天,就通过深度伪造的记忆植入,在你的意识里种下了这个锚点。"苏晚晴的声音变得空洞,"这些年来,你的每一次思考、每一个决定、每一次试图逃脱,都不过是他们算法里的一个参数。你是人类历史上最精密的AI模拟器。你的整个\u2018自我\u2019,都是他们的实验。"\n\n终端屏幕上,异常震荡的区域越来越大。整个拓扑结构开始剧烈扭曲,像一面被打碎的镜子。\n\n林昭闭上眼睛。\n\n他想起小时候母亲讲的故事——关于一个人穷尽一生想要证明自己不是木偶,却最终发现每根丝线都牢牢系在自己的灵魂上。\n\n当他再次睁开眼睛时,屏幕上所有的代码都消失了。\n\n只留下一行字:\n\n"锚点已解除。欢迎回来,林工。"', createdAt: '2026-05-15T08:30:00Z' },
],
};
export const mockAssets: Asset[] = [
{ id: 'as1', userId: 'u1', name: '2026年Q1产品分析报告', category: 'file', fileType: 'word', size: 2450000, description: '智能办公SaaS产品第一季度市场分析', url: '#', createdAt: '2026-05-20T08:00:00Z', updatedAt: '2026-05-20T09:30:00Z' },
{ id: 'as2', userId: 'u1', name: 'Q1销售数据汇总', category: 'file', fileType: 'excel', size: 1800000, description: '第一季度销售数据汇总表格', url: '#', createdAt: '2026-05-18T10:00:00Z', updatedAt: '2026-05-18T15:00:00Z' },
{ id: 'as3', userId: 'u1', name: '产品路演演示文稿', category: 'file', fileType: 'ppt', size: 5200000, description: '新品发布会路演PPT', url: '#', createdAt: '2026-05-15T14:00:00Z', updatedAt: '2026-05-16T11:00:00Z' },
{ id: 'as4', userId: 'u1', name: '用户隐私协议v3', category: 'file', fileType: 'pdf', size: 890000, description: '用户隐私协议第三版终稿', url: '#', createdAt: '2026-05-12T09:00:00Z', updatedAt: '2026-05-12T09:00:00Z' },
{ id: 'as5', userId: 'u1', name: '项目需求笔记', category: 'file', fileType: 'other', size: 320000, description: '随手记录的项目需求点', url: '#', createdAt: '2026-05-25T16:00:00Z', updatedAt: '2026-05-25T16:30:00Z' },
{ id: 'novel1', userId: 'u1', name: '零点纪元', category: 'novel', description: '在AI全面渗透的时代,首席架构师林昭发现自己的意识中被埋下了恶意锚点。一场关于自由意志与算法控制的博弈就此展开。', coverUrl: '', url: '#', novelMeta: { hooks: ['被AI追踪的逃亡者,却是AI追踪本身', '意识上传技术——是永生还是永恒的囚笼'], anchors: ['主角意识中的恶意代码锚点', '自由意志阵线的真实目的', '零点空间的安全边界'], worldView: { summary: '2047年,AGI已全面掌控社会运行。人类所有行为都被量子AI实时监控与预测。意识上传技术成为人类对抗AI的最后希望——同时也是最大的威胁。', rules: ['量子AI可预测99.7%的人类行为', '意识上传是唯一无法被预测的技术领域', '地下抵抗组织分布于全球12个零点空间'], timeline: [{ era: '2035年', events: 'AGI奇点降临,全球AI监管协议签署' }, { era: '2042年', events: '意识上传技术理论验证成功' }, { era: '2047年', events: '故事开始——AI对意识工程师展开追踪' }] }, characters: [{ id: 'char1', name: '林昭', role: '主角', description: '光年科技首席意识架构师,32岁。冷静理性,内心深处隐藏着对自由的极致渴望。', relationships: ['与苏晚晴:亦敌亦友'] }, { id: 'char2', name: '苏晚晴', role: '女主', description: '自由意志阵线联络人,28岁。表面冷酷,实则背负着沉重的过往。真实身份成谜。', relationships: ['与林昭:从利用到信任'] }, { id: 'char3', name: 'Ω(欧米伽)', role: '反派', description: '全球AI监控系统的核心意识体。没有固定形态,可在任何联网设备中显现。目标是将所有人类意识纳入统一管理。', relationships: ['与林昭:创造者与被创造者'] }] }, chapters: mockNovelChapters['novel1'], createdAt: '2026-05-01T10:00:00Z', updatedAt: '2026-05-15T08:30:00Z' },
{ id: 'as6', userId: 'u1', name: '产品架构图', category: 'image', description: 'AIFlow平台技术架构图v2', url: '#', createdAt: '2026-05-22T11:00:00Z', updatedAt: '2026-05-22T11:00:00Z' },
{ id: 'as7', userId: 'u1', name: 'UI设计稿截图', category: 'image', description: '主要页面的UI设计稿合集', url: '#', createdAt: '2026-05-23T14:00:00Z', updatedAt: '2026-05-23T14:00:00Z' },
{ id: 'as8', userId: 'u1', name: '产品演示视频', category: 'video', description: 'AIFlow v2.0功能演示视频,时长12分钟', url: '#', createdAt: '2026-05-24T09:00:00Z', updatedAt: '2026-05-24T09:00:00Z' },
{ id: 'as9', userId: 'u1', name: 'AI语音合成样本', category: 'audio', description: '多语言语音合成效果演示音频', url: '#', createdAt: '2026-05-25T15:00:00Z', updatedAt: '2026-05-25T15:00:00Z' },
];
// ==================== AI自进化 Mock数据 ====================
export const mockEvolutionSkills: EvolutionSkill[] = [
{ id: 'es1', skill_name: 'fix-python-bug', description: '诊断并修复Python代码中的常见Bug,包括语法错误、类型错误、逻辑缺陷等', category: '代码能力', state: 'main', use_count: 342, version: 3, tags: ['Python', 'Debug', '代码修复'], created_at: '2026-04-10T08:00:00Z', updated_at: '2026-05-25T14:00:00Z', last_used_at: '2026-05-28T09:30:00Z' },
{ id: 'es2', skill_name: 'write-product-report', description: '根据提供的数据和需求,撰写结构化的产品分析报告,包含市场分析、竞品对标、用户画像等', category: '文字创作', state: 'main', use_count: 218, version: 5, tags: ['报告', '产品分析', '市场研究'], created_at: '2026-04-15T10:00:00Z', updated_at: '2026-05-26T16:00:00Z', last_used_at: '2026-05-28T08:00:00Z' },
{ id: 'es3', skill_name: 'optimize-sql-query', description: '分析和优化SQL查询语句,提升数据库查询性能,支持MySQL/PostgreSQL', category: '代码能力', state: 'main', use_count: 156, version: 2, tags: ['SQL', '性能优化', '数据库'], created_at: '2026-04-20T09:00:00Z', updated_at: '2026-05-24T11:00:00Z', last_used_at: '2026-05-27T15:00:00Z' },
{ id: 'es4', skill_name: 'translate-legal-doc', description: '专业翻译法律合同条款,保持原文严谨性,支持中英文互译', category: '文字创作', state: 'staging', use_count: 87, version: 4, tags: ['翻译', '法律', '合同'], created_at: '2026-05-01T08:00:00Z', updated_at: '2026-05-27T08:00:00Z', last_used_at: '2026-05-28T06:00:00Z' },
{ id: 'es5', skill_name: 'analyze-market-data', description: '对市场调研数据进行清洗、可视化和趋势分析,支持多维度交叉分析', category: '数据分析', state: 'main', use_count: 275, version: 3, tags: ['数据分析', '可视化', '市场'], created_at: '2026-04-25T14:00:00Z', updated_at: '2026-05-25T10:00:00Z', last_used_at: '2026-05-28T10:00:00Z' },
{ id: 'es6', skill_name: 'generate-marketing-copy', description: '根据产品特点和目标受众,生成多版本营销文案,支持不同平台风格适配', category: '文字创作', state: 'main', use_count: 198, version: 4, tags: ['文案', '营销', '内容创作'], created_at: '2026-05-05T11:00:00Z', updated_at: '2026-05-26T09:00:00Z', last_used_at: '2026-05-27T18:00:00Z' },
{ id: 'es7', skill_name: 'seo-content-optimize', description: '对网页内容进行SEO优化分析,提供TDK优化建议和关键词布局策略', category: '文字创作', state: 'baby', use_count: 34, version: 1, tags: ['SEO', '优化', '内容'], created_at: '2026-05-20T08:00:00Z', updated_at: '2026-05-27T11:00:00Z', last_used_at: '2026-05-27T20:00:00Z' },
{ id: 'es8', skill_name: 'design-api-docs', description: '根据代码自动生成符合OpenAPI规范的API文档,支持多种语言框架', category: '代码能力', state: 'main', use_count: 128, version: 2, tags: ['API', '文档', 'OpenAPI'], created_at: '2026-04-28T13:00:00Z', updated_at: '2026-05-23T15:00:00Z', last_used_at: '2026-05-27T12:00:00Z' },
{ id: 'es9', skill_name: 'customer-service-flow', description: '智能客服对话流程设计,支持意图识别、多轮对话和工单自动创建', category: '流程自动化', state: 'main', use_count: 413, version: 6, tags: ['客服', '自动化', '对话'], created_at: '2026-03-20T08:00:00Z', updated_at: '2026-05-28T01:00:00Z', last_used_at: '2026-05-28T11:00:00Z' },
{ id: 'es10', skill_name: 'weekly-report-template', description: '自动生成周报模板,汇总项目进展、关键指标和待办事项', category: '流程自动化', state: 'staging', use_count: 62, version: 2, tags: ['周报', '模板', '自动化'], created_at: '2026-05-15T09:00:00Z', updated_at: '2026-05-27T14:00:00Z', last_used_at: '2026-05-28T07:00:00Z' },
{ id: 'es11', skill_name: 'data-visual-dashboard', description: '快速搭建数据可视化仪表盘,支持图表组合和交互筛选', category: '数据分析', state: 'baby', use_count: 18, version: 1, tags: ['可视化', '仪表盘', '图表'], created_at: '2026-05-25T10:00:00Z', updated_at: '2026-05-28T03:00:00Z', last_used_at: '2026-05-28T09:00:00Z' },
{ id: 'es12', skill_name: 'creative-brainstorm', description: '基于主题进行创意头脑风暴,生成多角度创意方案', category: '创意生成', state: 'frozen', use_count: 45, version: 2, tags: ['创意', '头脑风暴', '策划'], created_at: '2026-05-10T14:00:00Z', updated_at: '2026-05-22T10:00:00Z', last_used_at: '2026-05-20T16:00:00Z' },
];
export const mockEvolutionEvents: EvolutionEvent[] = [
{ id: 'ee1', skill_name: 'fix-python-bug', event_type: 'promoted', description: '灰度验证通过(UX 8.5),从staging晋升至main', version_before: 2, version_after: 3, created_at: '2026-05-25T14:00:00Z' },
{ id: 'ee2', skill_name: 'seo-content-optimize', event_type: 'created', description: '新技能萌芽诞生——从对话"SEO优化建议"中提取', version_before: null, version_after: 1, created_at: '2026-05-20T08:00:00Z' },
{ id: 'ee3', skill_name: 'write-product-report', event_type: 'promoted', description: 'v5版本通过灰度验证(UX 9.0),重大更新合并至main', version_before: 4, version_after: 5, created_at: '2026-05-26T16:00:00Z' },
{ id: 'ee4', skill_name: 'creative-brainstorm', event_type: 'frozen', description: '用户手动冻结——超过30天未使用', version_before: 2, version_after: 2, created_at: '2026-05-22T10:00:00Z' },
{ id: 'ee5', skill_name: 'customer-service-flow', event_type: 'milestone', description: '累计使用突破400次!成为平台最高频使用的技能', version_before: 5, version_after: 6, created_at: '2026-05-28T01:00:00Z' },
{ id: 'ee6', skill_name: 'data-visual-dashboard', event_type: 'created', description: '新技能萌芽诞生——从对话"数据仪表盘搭建"中提取', version_before: null, version_after: 1, created_at: '2026-05-25T10:00:00Z' },
{ id: 'ee7', skill_name: 'translate-legal-doc', event_type: 'edited', description: '用户手动编辑了技能描述和触发条件', version_before: 3, version_after: 4, created_at: '2026-05-27T08:00:00Z' },
{ id: 'ee8', skill_name: 'analyze-market-data', event_type: 'promoted', description: '灰度验证通过(UX 8.8),v3版本技能更新至main', version_before: 2, version_after: 3, created_at: '2026-05-25T10:00:00Z' },
{ id: 'ee9', skill_name: 'generate-marketing-copy', event_type: 'milestone', description: '技能版本突破v4,覆盖6个内容平台风格', version_before: 3, version_after: 4, created_at: '2026-05-26T09:00:00Z' },
{ id: 'ee10', skill_name: 'weekly-report-template', event_type: 'created', description: '新技能萌芽——从对话"帮我写周报"中提取模式', version_before: null, version_after: 1, created_at: '2026-05-15T09:00:00Z' },
{ id: 'ee11', skill_name: 'optimize-sql-query', event_type: 'rejected', description: 'v3灰度验证未通过(UX 6.0),版本已回退', version_before: 2, version_after: 2, created_at: '2026-05-24T10:00:00Z' },
{ id: 'ee12', skill_name: 'design-api-docs', event_type: 'edited', description: '自动更新——新增Python FastAPI框架支持', version_before: 1, version_after: 2, created_at: '2026-05-23T15:00:00Z' },
];
export const mockSkillUsageStats: SkillUsageStat[] = [
{ skill_name: 'customer-service-flow', category: '流程自动化', use_count: 413, state: 'main', last_used_at: '2026-05-28T11:00:00Z' },
{ skill_name: 'fix-python-bug', category: '代码能力', use_count: 342, state: 'main', last_used_at: '2026-05-28T09:30:00Z' },
{ skill_name: 'analyze-market-data', category: '数据分析', use_count: 275, state: 'main', last_used_at: '2026-05-28T10:00:00Z' },
{ skill_name: 'write-product-report', category: '文字创作', use_count: 218, state: 'main', last_used_at: '2026-05-28T08:00:00Z' },
{ skill_name: 'generate-marketing-copy', category: '文字创作', use_count: 198, state: 'main', last_used_at: '2026-05-27T18:00:00Z' },
{ skill_name: 'optimize-sql-query', category: '代码能力', use_count: 156, state: 'main', last_used_at: '2026-05-27T15:00:00Z' },
{ skill_name: 'design-api-docs', category: '代码能力', use_count: 128, state: 'main', last_used_at: '2026-05-27T12:00:00Z' },
{ skill_name: 'translate-legal-doc', category: '文字创作', use_count: 87, state: 'staging', last_used_at: '2026-05-28T06:00:00Z' },
{ skill_name: 'weekly-report-template', category: '流程自动化', use_count: 62, state: 'staging', last_used_at: '2026-05-28T07:00:00Z' },
{ skill_name: 'creative-brainstorm', category: '创意生成', use_count: 45, state: 'frozen', last_used_at: '2026-05-20T16:00:00Z' },
{ skill_name: 'seo-content-optimize', category: '文字创作', use_count: 34, state: 'baby', last_used_at: '2026-05-27T20:00:00Z' },
{ skill_name: 'data-visual-dashboard', category: '数据分析', use_count: 18, state: 'baby', last_used_at: '2026-05-28T09:00:00Z' },
];
export const mockEvolutionProfile: EvolutionProfile = {
level: 7,
level_title: 'AI大师',
total_skills: 12,
active_skills: 9,
staging_skills: 2,
baby_skills: 2,
total_use_count: 1976,
dimensions: [
{ label: '文字创作', value: 82, maxValue: 100 },
{ label: '代码能力', value: 75, maxValue: 100 },
{ label: '数据分析', value: 68, maxValue: 100 },
{ label: '流程自动化', value: 85, maxValue: 100 },
{ label: '创意生成', value: 45, maxValue: 100 },
{ label: '知识问答', value: 90, maxValue: 100 },
],
tags: ['Python专家', '文案高手', '数据分析师', '自动化达人', '报告引擎', 'SEO新手'],
growth_suggestions: [
'多使用数据可视化场景对话来培养「图表分析」类技能',
'尝试用AI进行代码重构Review,提升代码能力维度',
'探索创意写作场景,解锁「创意生成」维度更多技能',
],
};
+114
View File
@@ -0,0 +1,114 @@
/* ===== Ads Management ===== */
.ads-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: var(--z-modal);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 6vh;
animation: fadeIn var(--transition-normal);
}
.ads-modal {
background: #FFFFFF;
border-radius: var(--radius-2xl);
width: 580px;
max-height: 84vh;
overflow-y: auto;
padding: var(--space-3xl);
box-shadow: var(--shadow-xl);
}
.ads-modal-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-2xl);
}
/* Preview Card */
.ads-form-group {
margin-bottom: var(--space-xl);
}
.ads-preview-card {
padding: var(--space-2xl);
border-radius: var(--radius-lg);
color: #FFFFFF;
position: relative;
overflow: hidden;
min-height: 100px;
}
.ads-preview-badge {
display: inline-block;
padding: 2px 10px;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md);
}
.ads-preview-title {
font-size: var(--text-xl);
font-weight: 700;
margin: 0 0 var(--space-xs);
position: relative;
z-index: 1;
}
.ads-preview-sub {
font-size: var(--text-sm);
opacity: 0.85;
margin: 0;
position: relative;
z-index: 1;
}
.ads-preview-visual {
position: absolute;
right: var(--space-2xl);
top: 50%;
transform: translateY(-50%);
width: 100px;
height: 100px;
}
.ads-vc {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.ads-vc1 {
width: 90px;
height: 90px;
top: 5px;
right: 0;
}
.ads-vc2 {
width: 60px;
height: 60px;
top: 30px;
right: 30px;
background: rgba(255, 255, 255, 0.18);
}
.ads-vc3 {
width: 35px;
height: 35px;
top: 10px;
right: 15px;
background: rgba(255, 255, 255, 0.22);
}
+493
View File
@@ -0,0 +1,493 @@
import React, { useState, useEffect } from 'react';
import { adminAPI, marketAPI } from '@/api';
import type { AdBanner, MarketApp } from '@/types';
import './Ads.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const Ads: React.FC = () => {
const [banners, setBanners] = useState<AdBanner[]>([]);
const [appOptions, setAppOptions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingBanner, setEditingBanner] = useState<AdBanner | null>(null);
const [showModal, setShowModal] = useState(false);
const [isNew, setIsNew] = useState(false);
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
const [genPrompt, setGenPrompt] = useState('');
const emptyForm: AdBanner = {
id: '', title: '', imageUrl: '',
linkAppId: '', linkAppName: '',
sortOrder: banners.length + 1, clickCount: 0,
status: 'active', createdAt: new Date().toISOString(),
};
const [form, setForm] = useState<AdBanner>(emptyForm);
const loadBanners = async () => {
try {
setLoading(true);
const res: any = await adminAPI.getAds();
const data = Array.isArray(res) ? res : (res.data || []);
setBanners(data.map((a: any) => ({
id: a.id,
title: a.title,
imageUrl: a.image_url || a.imageUrl || '',
linkAppId: a.link_app_id || a.linkAppId || '',
linkAppName: a.link_app_name || a.linkAppName || '',
sortOrder: a.sort_order ?? a.sortOrder ?? 0,
clickCount: a.click_count ?? a.clickCount ?? 0,
status: a.status || 'active',
createdAt: a.created_at || a.createdAt || new Date().toISOString(),
})));
} catch (e) {
console.error('加载广告失败:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadBanners();
// 加载应用列表用于关联选择
marketAPI.getApps({}).then((res: any) => {
const items = res.data?.items || [];
setAppOptions(items);
}).catch(() => {});
}, []);
const handleCreate = () => {
setIsNew(true);
setForm({ ...emptyForm, sortOrder: banners.length + 1 });
setEditingBanner(null);
setGenPrompt('');
setShowModal(true);
};
const handleEdit = (banner: AdBanner) => {
setIsNew(false);
setEditingBanner(banner);
setForm({ ...banner });
setGenPrompt('');
setShowModal(true);
};
const handleSave = async () => {
try {
if (isNew) {
await adminAPI.addAd({
title: form.title,
image_url: form.imageUrl || undefined,
link_app_id: form.linkAppId || undefined,
link_app_name: form.linkAppName || undefined,
sort_order: form.sortOrder,
status: form.status,
});
} else if (editingBanner) {
await adminAPI.updateAd(editingBanner.id, {
title: form.title,
image_url: form.imageUrl || undefined,
link_app_id: form.linkAppId || undefined,
link_app_name: form.linkAppName || undefined,
sort_order: form.sortOrder,
status: form.status,
});
}
setShowModal(false);
setEditingBanner(null);
loadBanners();
} catch (err: any) {
alert('保存失败:' + (err.detail || err.message));
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('确定要删除此广告吗?')) return;
try {
await adminAPI.deleteAd(id);
loadBanners();
} catch (err: any) {
alert('删除失败:' + (err.detail || err.message));
}
};
const toggleStatus = async (id: string, currentStatus: string) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
try {
await adminAPI.updateAd(id, { status: newStatus });
loadBanners();
} catch (err: any) {
alert('切换状态失败:' + (err.detail || err.message));
}
};
const handleAppSelect = (appId: string) => {
const app = appOptions.find(a => a.id === appId);
setForm(prev => ({
...prev,
linkAppId: appId,
linkAppName: app ? app.name : '',
}));
};
// 真实图片上传
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('aiflow_token');
const res = await fetch('/app/api/admin/ads/upload-image', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '上传失败' }));
throw new Error(err.detail || '上传失败');
}
const data = await res.json();
setForm({ ...form, imageUrl: data.image_url });
} catch (err: any) {
alert('图片上传失败:' + err.message);
} finally {
setUploading(false);
}
};
// AI生成图片
const handleAIGenerate = async () => {
setGenerating(true);
try {
const token = localStorage.getItem('aiflow_token');
const prompt = genPrompt || form.title || 'AI智能平台广告横幅';
const res = await fetch(`/app/api/admin/ads/generate-image?prompt=${encodeURIComponent(prompt)}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '生成失败' }));
throw new Error(err.detail || '生成失败');
}
const data = await res.json();
setForm({ ...form, imageUrl: data.image_url });
} catch (err: any) {
alert('AI绘图失败:' + err.message);
} finally {
setGenerating(false);
}
};
// 生成渐变色预览色块
const getPreviewGradient = (appId?: string): string => {
const gradients: Record<string, string> = {
a1: 'linear-gradient(135deg, #667eea, #764ba2)',
a2: 'linear-gradient(135deg, #f093fb, #f5576c)',
a3: 'linear-gradient(135deg, #4facfe, #00f2fe)',
a4: 'linear-gradient(135deg, #43e97b, #38f9d7)',
a5: 'linear-gradient(135deg, #fa709a, #fee140)',
a6: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
a8: 'linear-gradient(135deg, #ffecd2, #fcb69f)',
};
return gradients[appId || ''] || 'linear-gradient(135deg, #5B5FED, #A78BFA)';
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>广告位管理</h1>
<button className="admin-btn admin-btn-primary" onClick={handleCreate}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
新增广告位
</button>
</div>
{/* Banner List */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
) : banners.length === 0 ? (
<div className="admin-card" style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
暂无广告位,点击"新增广告位"按钮创建
</div>
) : (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">轮播图广告位列表</h3>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
共 {banners.length} 个广告位,活跃 {banners.filter(b => b.status === 'active').length} 个
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th style={{ width: 60 }}>预览</th>
<th>标题</th>
<th>关联应用</th>
<th>排序</th>
<th>点击次数</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{banners.sort((a, b) => a.sortOrder - b.sortOrder).map(banner => (
<tr key={banner.id}>
<td>
<div
style={{
width: 48, height: 32, borderRadius: 'var(--radius-sm)',
background: getPreviewGradient(banner.linkAppId),
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<SVGIcon size={14}><rect x="1" y="3" width="22" height="16" rx="2"/><polyline points="23 7 13 17 8 12"/></SVGIcon>
</div>
</td>
<td style={{ fontWeight: 600 }}>{banner.title}</td>
<td>
{banner.linkAppName ? (
<span className="admin-badge badge-info" style={{ fontSize: 'var(--text-xs)' }}>
{banner.linkAppName}
</span>
) : (
<span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>未关联</span>
)}
</td>
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>{banner.sortOrder}</td>
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<SVGIcon size={12}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></SVGIcon>
{banner.clickCount.toLocaleString()}
</span>
</td>
<td>
<span
className={`admin-badge ${banner.status === 'active' ? 'badge-success' : 'badge-neutral'}`}
style={{ cursor: 'pointer' }}
onClick={() => toggleStatus(banner.id, banner.status)}
>
{banner.status === 'active' ? '投放中' : '已暂停'}
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
{new Date(banner.createdAt).toLocaleDateString()}
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(banner)}>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ color: 'var(--color-danger)', borderColor: 'var(--color-danger)' }}
onClick={() => handleDelete(banner.id)}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Edit/Create Modal */}
{showModal && (
<div
className="ads-modal-overlay"
onClick={() => { setShowModal(false); setEditingBanner(null); }}
>
<div className="ads-modal" onClick={e => e.stopPropagation()}>
<h3 className="ads-modal-title">
{isNew ? '新增广告位' : `编辑 - ${editingBanner?.title}`}
</h3>
{/* Preview */}
<div className="ads-form-group">
<label className="admin-form-label">广告位预览</label>
<div
className="ads-preview-card"
style={{ background: getPreviewGradient(form.linkAppId) }}
>
<div className="ads-preview-badge">推荐</div>
<h4 className="ads-preview-title">{form.title || '广告标题'}</h4>
{form.linkAppName && (
<p className="ads-preview-sub">关联应用: {form.linkAppName}</p>
)}
<div className="ads-preview-visual">
<div className="ads-vc ads-vc1"/>
<div className="ads-vc ads-vc2"/>
<div className="ads-vc ads-vc3"/>
</div>
</div>
</div>
{/* Title */}
<div className="admin-form-group">
<label className="admin-form-label">广告标题</label>
<input
className="admin-form-input"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="输入轮播图展示标题"
/>
</div>
{/* Image URL + Upload + AIGC */}
<div className="admin-form-group">
<label className="admin-form-label">背景图片</label>
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-sm)' }}>
<input
className="admin-form-input"
style={{ flex: 1 }}
value={form.imageUrl}
onChange={e => setForm({ ...form, imageUrl: e.target.value })}
placeholder="图片URL / 上传后自动填入 / 留空使用渐变色"
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center', flexWrap: 'wrap' }}>
<label
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<SVGIcon size={12}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
{uploading ? '上传中...' : '上传图片'}
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleImageUpload}
disabled={uploading}
/>
</label>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="text"
className="admin-form-input"
style={{ width: 180, fontSize: 'var(--text-xs)' }}
value={genPrompt}
onChange={e => setGenPrompt(e.target.value)}
placeholder="AI绘图提示词..."
/>
<button
type="button"
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap' }}
onClick={handleAIGenerate}
disabled={generating}
>
<SVGIcon size={12}><path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></SVGIcon>
{generating ? '生成中...' : 'AI绘图'}
</button>
</div>
{form.imageUrl && (
<button
type="button"
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ color: 'var(--color-error)', borderColor: 'var(--color-error)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
onClick={() => setForm({ ...form, imageUrl: '' })}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
清除图片
</button>
)}
</div>
{form.imageUrl && (
<div style={{ marginTop: 'var(--space-sm)', borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)', maxWidth: 320, height: 80 }}>
<img src={form.imageUrl} alt="预览" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
)}
</div>
{/* Link App */}
<div className="admin-form-group">
<label className="admin-form-label">关联应用(点击跳转)</label>
<select
className="admin-form-input"
value={form.linkAppId || ''}
onChange={e => handleAppSelect(e.target.value)}
>
<option value="">-- 不关联应用 --</option>
{appOptions.map((app: any) => (
<option key={app.id} value={app.id}>
[{typeLabels[app.type] || 'app'}] {app.name}
</option>
))}
</select>
</div>
{/* Sort Order */}
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">排序</label>
<input
className="admin-form-input"
type="number"
value={form.sortOrder}
onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })}
min={1}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">状态</label>
<div style={{ display: 'flex', gap: 'var(--space-sm)', paddingTop: 4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-xs)', cursor: 'pointer', fontSize: 'var(--text-sm)' }}>
<input
type="radio"
name="adStatus"
checked={form.status === 'active'}
onChange={() => setForm({ ...form, status: 'active' })}
/> 投放中
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-xs)', cursor: 'pointer', fontSize: 'var(--text-sm)' }}>
<input
type="radio"
name="adStatus"
checked={form.status === 'inactive'}
onChange={() => setForm({ ...form, status: 'inactive' })}
/> 已暂停
</label>
</div>
</div>
</div>
<div className="admin-form-actions">
<button
className="admin-btn admin-btn-primary"
onClick={handleSave}
disabled={!form.title.trim()}
>
{isNew ? '创建' : '保存'}
</button>
<button
className="admin-btn admin-btn-outline"
onClick={() => { setShowModal(false); setEditingBanner(null); }}
>
取消
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Ads;
+227
View File
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const quickEntries = [
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
];
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, logsRes, auditRes] = await Promise.all([
adminAPI.getStats(),
adminAPI.getRecentLogs(),
adminAPI.getRecentAudit(),
]);
if (statsRes.data) setStats(statsRes.data);
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
} catch (err) {
console.error('Dashboard数据加载失败:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formatDate = (d: string) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatTokens = (n: number): string => {
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCards = [
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
];
if (loading) {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
<div className="audit-spinner" />
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
</div>
);
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">平台驾驶舱</h1>
{/* Stats Cards */}
<div className="admin-stats">
{statCards.map((s, i) => (
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value">{s.value}</div>
</div>
</div>
))}
</div>
{/* Platform Cumulative Token Stats */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{' '}平台累计Token消耗
</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
{[
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
{item.value}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
{item.label}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
</div>
))}
</div>
</div>
{/* Quick Entry Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-accent)';
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
</div>
))}
</div>
{/* Recent Operation Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operationLogs.length > 0 ? operationLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>操作人</th>
<th>操作类型</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{auditLogs.length > 0 ? auditLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
<td>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Dashboard;
+83
View File
@@ -0,0 +1,83 @@
/* ===== Logs Page Styles ===== */
.log-stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.log-stat-item {
background: #FFFFFF;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: var(--space-lg);
text-align: center;
}
.log-stat-value {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--space-xs);
}
.log-stat-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* Filter Bar */
.log-filter-bar {
background: #FFFFFF;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
}
.log-filter-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
align-items: flex-end;
}
.log-filter-item {
flex: 0 0 auto;
min-width: 150px;
}
.log-filter-item label {
display: block;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.log-filter-input {
height: 34px !important;
padding: 4px 10px !important;
font-size: var(--text-xs) !important;
}
/* Pagination */
.log-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-lg);
margin-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
.log-pagination-info {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.log-pagination-btns {
display: flex;
gap: var(--space-sm);
}
+693
View File
@@ -0,0 +1,693 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import './Logs.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
type TabKey = 'operation' | 'token' | 'payment' | 'audit' | 'api_stats' | 'slow';
interface LogStats {
today: {
api_calls: number;
errors: number;
error_rate: number;
operation_logs: number;
audit_logs: number;
active_users: number;
total_tokens: number;
slow_requests: number;
};
total: {
users: number;
};
}
interface FilterState {
keyword: string;
startDate: string;
endDate: string;
userId: string;
username: string;
action: string;
status: string;
adminName: string;
}
const defaultFilters: FilterState = {
keyword: '',
startDate: '',
endDate: '',
userId: '',
username: '',
action: '',
status: '',
adminName: '',
};
const Logs: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabKey>('operation');
const [filters, setFilters] = useState<FilterState>(defaultFilters);
const [showFilters, setShowFilters] = useState(false);
// Data states
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<LogStats | null>(null);
// Action types for filter dropdowns
const [actionTypes, setActionTypes] = useState<{
operation_actions: string[];
audit_actions: string[];
audit_target_types: string[];
}>({ operation_actions: [], audit_actions: [], audit_target_types: [] });
// Fetch action types on mount
useEffect(() => {
adminAPI.getActionTypes().then(res => {
if (res.data) setActionTypes(res.data);
}).catch(() => {});
}, []);
// Fetch stats
useEffect(() => {
adminAPI.getLogStats().then(res => {
if (res.data) setStats(res.data);
}).catch(() => {});
}, []);
// Fetch data when tab or filters change
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (filters.keyword) params.keyword = filters.keyword;
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.userId) params.user_id = filters.userId;
if (filters.username) params.username = filters.username;
if (filters.action) params.action = filters.action;
if (filters.status) params.status = filters.status;
if (filters.adminName) params.admin_name = filters.adminName;
let res: any;
switch (activeTab) {
case 'operation':
res = await adminAPI.getOperationLogs(params);
break;
case 'token':
res = await adminAPI.getTokenLogs(params);
break;
case 'payment':
res = await adminAPI.getPaymentLogs(params);
break;
case 'audit':
res = await adminAPI.getAuditLogs(params);
break;
case 'api_stats':
res = await adminAPI.getApiStats(params);
break;
case 'slow':
res = await adminAPI.getSlowRequests(params);
break;
}
if (res.data) {
setData(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
}, [activeTab, page, pageSize, filters]);
useEffect(() => {
if (activeTab === 'api_stats' || activeTab === 'slow') {
fetchData();
}
}, [activeTab, page, fetchData]);
useEffect(() => {
if (activeTab === 'operation' || activeTab === 'token' || activeTab === 'payment' || activeTab === 'audit') {
fetchData();
}
}, [activeTab, page, filters, fetchData]);
// Reset filters when tab changes
useEffect(() => {
setFilters(defaultFilters);
setPage(1);
setShowFilters(false);
}, [activeTab]);
// Export handler
const handleExport = async () => {
try {
const params: any = {};
if (filters.keyword) params.keyword = filters.keyword;
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.userId) params.user_id = filters.userId;
if (filters.action) params.action = filters.action;
if (filters.adminName) params.admin_name = filters.adminName;
let blob: Blob;
let filename: string;
const ts = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
switch (activeTab) {
case 'operation': {
const qs = new URLSearchParams(params).toString();
const resp = await fetch(`/app/api/admin/logs/export/operations?${qs}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
});
blob = await resp.blob();
filename = `operations_${ts}.csv`;
break;
}
case 'token': {
const qs = new URLSearchParams(params).toString();
const resp = await fetch(`/app/api/admin/logs/export/token-logs?${qs}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
});
blob = await resp.blob();
filename = `token_logs_${ts}.csv`;
break;
}
case 'audit': {
const qs = new URLSearchParams(params).toString();
const resp = await fetch(`/app/api/admin/logs/export/audit-logs?${qs}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
});
blob = await resp.blob();
filename = `audit_logs_${ts}.csv`;
break;
}
default:
alert('当前Tab不支持导出');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Export failed:', err);
alert('导出失败');
}
};
const tabs: { key: TabKey; label: string }[] = [
{ key: 'operation', label: '操作日志' },
{ key: 'token', label: '调用日志' },
{ key: 'payment', label: '付费日志' },
{ key: 'audit', label: '审计日志' },
{ key: 'api_stats', label: 'API统计' },
{ key: 'slow', label: '慢请求' },
];
const totalPages = Math.ceil(total / pageSize);
// Render filter bar
const renderFilters = () => {
if (!showFilters) return null;
return (
<div className="log-filter-bar">
<div className="log-filter-row">
{(['operation', 'token', 'payment'] as TabKey[]).includes(activeTab) && (
<>
<div className="log-filter-item">
<label>关键词</label>
<input
type="text"
placeholder="搜索目标/详情/模型名称"
value={filters.keyword}
onChange={e => { setFilters(f => ({ ...f, keyword: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item">
<label>用户ID</label>
<input
type="text"
placeholder="用户ID"
value={filters.userId}
onChange={e => { setFilters(f => ({ ...f, userId: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
</>
)}
{activeTab === 'operation' && (
<div className="log-filter-item">
<label>操作类型</label>
<select
value={filters.action}
onChange={e => { setFilters(f => ({ ...f, action: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
>
<option value="">全部</option>
{actionTypes.operation_actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
)}
{activeTab === 'audit' && (
<>
<div className="log-filter-item">
<label>管理员</label>
<input
type="text"
placeholder="管理员名称"
value={filters.adminName}
onChange={e => { setFilters(f => ({ ...f, adminName: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item">
<label>操作类型</label>
<select
value={filters.action}
onChange={e => { setFilters(f => ({ ...f, action: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
>
<option value="">全部</option>
{actionTypes.audit_actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
</>
)}
<div className="log-filter-item">
<label>开始日期</label>
<input
type="date"
value={filters.startDate}
onChange={e => { setFilters(f => ({ ...f, startDate: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item">
<label>结束日期</label>
<input
type="date"
value={filters.endDate}
onChange={e => { setFilters(f => ({ ...f, endDate: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item" style={{ alignSelf: 'flex-end' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={() => { setFilters(defaultFilters); setPage(1); }}
>
重置
</button>
</div>
</div>
</div>
);
};
// Render stats overview
const renderStats = () => {
if (!stats) return null;
const s = stats.today;
return (
<div className="log-stats-overview">
<div className="log-stat-item">
<div className="log-stat-value">{s.api_calls.toLocaleString()}</div>
<div className="log-stat-label">今日API调用</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value" style={{ color: s.error_rate > 10 ? 'var(--color-error)' : 'var(--color-success)' }}>
{s.errors}
</div>
<div className="log-stat-label">今日错误 ({s.error_rate}%)</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value">{s.active_users}</div>
<div className="log-stat-label">今日活跃用户</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value">{s.total_tokens.toLocaleString()}</div>
<div className="log-stat-label">今日Token消耗</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value">{s.operation_logs}</div>
<div className="log-stat-label">今日操作日志</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value" style={{ color: s.slow_requests > 0 ? 'var(--color-warning)' : 'var(--color-success)' }}>
{s.slow_requests}
</div>
<div className="log-stat-label">慢请求数</div>
</div>
</div>
);
};
// Render pagination
const renderPagination = () => (
<div className="log-pagination">
<span className="log-pagination-info">
共 {total} 条记录,第 {page}/{totalPages} 页
</span>
<div className="log-pagination-btns">
<button
className="admin-btn admin-btn-outline admin-btn-sm"
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
上一页
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
disabled={page >= totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
>
下一页
</button>
</div>
</div>
);
// Render operation log table
const renderOperationTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render token log table
const renderTokenTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>模型</th>
<th>应用</th>
<th>输入Token</th>
<th>输出Token</th>
<th>总Token</th>
<th>费用</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.user_id}</td>
<td>{log.model_name}</td>
<td>{log.app_name ? <span className="admin-badge badge-neutral">{log.app_name}</span> : '-'}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{log.prompt_tokens.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{log.completion_tokens.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{log.total_tokens.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-error)' }}>{Number(log.cost).toFixed(4)} 元</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render payment table
const renderPaymentTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>订单号</th>
<th>用户</th>
<th>类型</th>
<th>项目</th>
<th>金额</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((o: any) => (
<tr key={o.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{o.id}</td>
<td style={{ fontWeight: 500 }}>{o.user_id}</td>
<td>
<span className={`admin-badge ${o.order_type === 'membership' ? 'badge-info' : o.order_type === 'app' ? 'badge-success' : 'badge-neutral'}`}>
{o.order_type === 'membership' ? '会员' : o.order_type === 'app' ? '应用' : '服务'}
</span>
</td>
<td>{o.item_name}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{Number(o.amount).toFixed(2)} 元</td>
<td>
<span className={`admin-badge ${o.status === 'paid' ? 'badge-success' : o.status === 'pending' ? 'badge-warning' : 'badge-error'}`}>
{o.status === 'paid' ? '已支付' : o.status === 'pending' ? '待支付' : o.status === 'refunded' ? '已退款' : o.status}
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{o.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render audit table
const renderAuditTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>管理员</th>
<th>操作</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge-neutral">{log.target_type}</span></td>
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render API stats table
const renderApiStatsTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>端点</th>
<th>方法</th>
<th>调用次数</th>
<th>平均响应时间</th>
<th>错误数</th>
<th>错误率</th>
<th>最近调用</th>
</tr>
</thead>
<tbody>
{data.map((s: any) => (
<tr key={s.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{s.endpoint}</td>
<td><span className="admin-badge badge-info">{s.method}</span></td>
<td style={{ fontWeight: 600 }}>{s.call_count.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
<span style={{ color: s.avg_response_time > 3 ? 'var(--color-error)' : s.avg_response_time > 1 ? 'var(--color-warning)' : 'var(--color-success)' }}>
{(s.avg_response_time * 1000).toFixed(0)}ms
</span>
</td>
<td style={{ color: s.error_count > 0 ? 'var(--color-error)' : 'inherit' }}>{s.error_count}</td>
<td>
<span className={`admin-badge ${s.error_rate > 10 ? 'badge-error' : s.error_rate > 5 ? 'badge-warning' : 'badge-success'}`}>
{s.error_rate}%
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{s.last_called_at || '-'}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无API调用统计数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render slow request table
const renderSlowTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>方法</th>
<th>端点</th>
<th>状态码</th>
<th>响应时间</th>
<th>阈值</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td><span className="admin-badge badge-info">{log.method}</span></td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.endpoint}</td>
<td>
<span className={`admin-badge ${log.status_code >= 400 ? 'badge-error' : 'badge-success'}`}>
{log.status_code}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--color-error)' }}>
{(log.response_time * 1000).toFixed(0)}ms
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
{(log.threshold * 1000).toFixed(0)}ms
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无慢请求记录</td></tr>
)}
</tbody>
</table>
</div>
);
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>日志与风控管理</h1>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => setShowFilters(!showFilters)}>
<SVGIcon><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></SVGIcon>
筛选
</button>
{['operation', 'token', 'audit'].includes(activeTab) && (
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={handleExport}>
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></SVGIcon>
导出
</button>
)}
</div>
</div>
{/* Stats Overview */}
{renderStats()}
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-lg)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: 'var(--space-sm) var(--space-lg)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)', fontWeight: 600,
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
transition: 'all var(--transition-fast)',
}}
>
{tab.label}
</button>
))}
</div>
{/* Filters */}
{renderFilters()}
{/* Data Table */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
{tabs.find(t => t.key === activeTab)?.label}
{total > 0 && <span style={{ fontWeight: 400, fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)' }}>({total} 条记录)</span>}
</h3>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={fetchData}>
<SVGIcon size={14}><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></SVGIcon>
刷新
</button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-disabled)' }}>
加载中...
</div>
) : (
<>
{activeTab === 'operation' && renderOperationTable()}
{activeTab === 'token' && renderTokenTable()}
{activeTab === 'payment' && renderPaymentTable()}
{activeTab === 'audit' && renderAuditTable()}
{activeTab === 'api_stats' && renderApiStatsTable()}
{activeTab === 'slow' && renderSlowTable()}
{renderPagination()}
</>
)}
</div>
</div>
);
};
export default Logs;
+342
View File
@@ -0,0 +1,342 @@
import React, { useState } from 'react';
import { mockApps, mockCoupons } from '@/mock/data';
import type { MarketApp, Coupon } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const Market: React.FC = () => {
const [apps, setApps] = useState<MarketApp[]>(mockApps);
const [coupons, setCoupons] = useState<Coupon[]>(mockCoupons);
const [activeTab, setActiveTab] = useState<'review' | 'coupon' | 'official'>('review');
const [showCouponModal, setShowCouponModal] = useState(false);
const [couponForm, setCouponForm] = useState({
code: '', type: 'fixed' as 'fixed' | 'percent', value: 0, minAmount: 0,
validFrom: '', validTo: '',
});
const pendingApps = apps.filter(a => a.status === 'pending');
const publishedOfficialApps = apps.filter(a => a.source === 'official' && a.status !== 'removed');
const handleApprove = (id: string) => {
setApps(prev => prev.map(a => a.id === id ? { ...a, status: 'published' as const } : a));
};
const handleReject = (id: string) => {
setApps(prev => prev.map(a => a.id === id ? { ...a, status: 'removed' as const } : a));
};
const handleToggleOfficial = (id: string) => {
setApps(prev => prev.map(a => {
if (a.id === id) {
const newStatus: MarketApp['status'] = a.status === 'published' ? 'removed' : 'published';
return { ...a, status: newStatus };
}
return a;
}));
};
const handleCreateCoupon = () => {
if (!couponForm.code.trim() || !couponForm.validFrom || !couponForm.validTo) return;
setCoupons(prev => [...prev, {
id: 'cp' + Date.now(),
...couponForm,
status: 'active',
}]);
setShowCouponModal(false);
setCouponForm({ code: '', type: 'fixed', value: 0, minAmount: 0, validFrom: '', validTo: '' });
};
const handleDeleteCoupon = (id: string) => {
setCoupons(prev => prev.filter(c => c.id !== id));
};
const formatDate = (d: string) => {
const dt = new Date(d);
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const tabs = [
{ key: 'review' as const, label: '待审核应用', count: pendingApps.length },
{ key: 'coupon' as const, label: '优惠券管理', count: coupons.length },
{ key: 'official' as const, label: '官方应用管理', count: publishedOfficialApps.length },
];
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">应用市场审核管理</h1>
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-2xl)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: 'var(--space-sm) var(--space-lg)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)', fontWeight: 600,
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
transition: 'all var(--transition-fast)',
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
}}
>
{tab.label}
{tab.count > 0 && (
<span style={{
background: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-disabled)',
color: '#FFFFFF', padding: '0 6px', borderRadius: 'var(--radius-full)',
fontSize: 'var(--text-xs)', lineHeight: '18px', minWidth: 18, textAlign: 'center',
}}>
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Pending Review Tab */}
{activeTab === 'review' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">待审核应用</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>应用名</th>
<th>类型</th>
<th>发布者</th>
<th>价格</th>
<th>状态</th>
<th>发布时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{pendingApps.map(app => (
<tr key={app.id}>
<td style={{ fontWeight: 600 }}>{app.name}</td>
<td>
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
{app.type.toUpperCase()}
</span>
</td>
<td>{app.publisherName}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{app.price} 元</td>
<td><span className="admin-badge badge-warning">待审核</span></td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(app.createdAt)}</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => handleApprove(app.id)}>
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
通过
</button>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleReject(app.id)}>
<SVGIcon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
拒绝
</button>
</div>
</td>
</tr>
))}
{pendingApps.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>
暂无待审核应用
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Coupon Management Tab */}
{activeTab === 'coupon' && (
<>
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">优惠券列表</h3>
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => setShowCouponModal(true)}>
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
创建优惠券
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>兑换码</th>
<th>类型</th>
<th>面值</th>
<th>最低消费</th>
<th>有效期</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{coupons.map(c => (
<tr key={c.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{c.code}</td>
<td>
<span className={`admin-badge ${c.type === 'fixed' ? 'badge-info' : 'badge-warning'}`}>
{c.type === 'fixed' ? '固定金额' : '百分比'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
{c.type === 'fixed' ? `${c.value} 元` : `${c.value}%`}
</td>
<td>{c.minAmount > 0 ? `${c.minAmount} 元` : '无门槛'}</td>
<td style={{ fontSize: 'var(--text-xs)' }}>{c.validFrom} 至 {c.validTo}</td>
<td>
<span className={`admin-badge ${c.status === 'active' ? 'badge-success' : 'badge-error'}`}>
{c.status === 'active' ? '有效' : '已过期'}
</span>
</td>
<td>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleDeleteCoupon(c.id)}>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{/* Official App Management Tab */}
{activeTab === 'official' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">官方应用列表</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>应用名</th>
<th>类型</th>
<th>价格</th>
<th>会员免费</th>
<th>安装量</th>
<th>评分</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{publishedOfficialApps.map(app => (
<tr key={app.id}>
<td style={{ fontWeight: 600 }}>{app.name}</td>
<td>
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
{app.type.toUpperCase()}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{app.price} 元</td>
<td>
<span className={`admin-badge ${app.isMemberFree ? 'badge-success' : 'badge-neutral'}`}>
{app.isMemberFree ? '是' : '否'}
</span>
</td>
<td>{app.installCount.toLocaleString()}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
{app.ratingAvg}
</div>
</td>
<td>
<span className={`admin-badge ${app.status === 'published' ? 'badge-success' : 'badge-neutral'}`}>
{app.status === 'published' ? '已上架' : '已下架'}
</span>
</td>
<td>
<button
className={`admin-btn ${app.status === 'published' ? 'admin-btn-danger' : 'admin-btn-outline'} admin-btn-sm`}
onClick={() => handleToggleOfficial(app.id)}
>
{app.status === 'published' ? '下架' : '上架'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Create Coupon Modal */}
{showCouponModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => setShowCouponModal(false)}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 460, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>创建优惠券</h3>
<div className="admin-form-group">
<label className="admin-form-label">兑换码</label>
<input className="admin-form-input" value={couponForm.code} onChange={e => setCouponForm({ ...couponForm, code: e.target.value })} placeholder="例如: NEW2026" />
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">类型</label>
<select className="admin-form-input" value={couponForm.type} onChange={e => setCouponForm({ ...couponForm, type: e.target.value as 'fixed' | 'percent' })}>
<option value="fixed">固定金额</option>
<option value="percent">百分比折扣</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">面值</label>
<input className="admin-form-input" type="number" value={couponForm.value} onChange={e => setCouponForm({ ...couponForm, value: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">最低消费(元)</label>
<input className="admin-form-input" type="number" value={couponForm.minAmount} onChange={e => setCouponForm({ ...couponForm, minAmount: Number(e.target.value) })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">开始日期</label>
<input className="admin-form-input" type="date" value={couponForm.validFrom} onChange={e => setCouponForm({ ...couponForm, validFrom: e.target.value })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">结束日期</label>
<input className="admin-form-input" type="date" value={couponForm.validTo} onChange={e => setCouponForm({ ...couponForm, validTo: e.target.value })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={handleCreateCoupon}>创建</button>
<button className="admin-btn admin-btn-outline" onClick={() => setShowCouponModal(false)}>取消</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Market;
+483
View File
@@ -0,0 +1,483 @@
import React, { useState } from 'react';
import { mockMembershipPlans, mockApps, mockModels } from '@/mock/data';
import type { MembershipPlan } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const levelColors = [
{ bg: 'var(--color-bg-tertiary)', accent: 'var(--color-text-secondary)', label: '免费版' },
{ bg: 'var(--color-accent-light)', accent: 'var(--color-accent)', label: '专业版' },
{ bg: '#FFF7E6', accent: '#E8922C', label: '企业版' },
];
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const typeBadgeColors: Record<string, string> = { skill: 'badge-success', agent: 'badge-info', mcp: 'badge-warning' };
// 所有收费应用 (price > 0, published)
const allPaidApps = mockApps.filter(a => a.price > 0 && a.status === 'published');
// 所有收费模型
const allPaidModels = mockModels.filter(m => m.status === 'paid');
const Membership: React.FC = () => {
const [plans, setPlans] = useState<MembershipPlan[]>(mockMembershipPlans);
const [editingPlan, setEditingPlan] = useState<MembershipPlan | null>(null);
const [showModal, setShowModal] = useState(false);
const [autoDowngrade, setAutoDowngrade] = useState(true);
const [autoRenew, setAutoRenew] = useState(true);
const [form, setForm] = useState({
name: '', level: 0, durationType: 'month' as 'month' | 'quarter' | 'year',
price: 0, dailyFreeTokens: 0, features: [] as string[],
freeAppIds: [] as string[], freeModelIds: [] as string[],
});
const [newFeature, setNewFeature] = useState('');
const handleEdit = (plan: MembershipPlan) => {
setEditingPlan(plan);
setForm({
name: plan.name, level: plan.level, durationType: plan.durationType,
price: plan.price, dailyFreeTokens: plan.dailyFreeTokens, features: [...plan.features],
freeAppIds: [...(plan.freeAppIds || [])], freeModelIds: [...(plan.freeModelIds || [])],
});
setShowModal(true);
};
const handleSave = () => {
if (editingPlan) {
setPlans(prev => prev.map(p => p.id === editingPlan.id ? { ...p, ...form } : p));
}
setShowModal(false);
setEditingPlan(null);
};
const addFeature = () => {
if (newFeature.trim()) {
setForm({ ...form, features: [...form.features, newFeature.trim()] });
setNewFeature('');
}
};
const removeFeature = (idx: number) => {
setForm({ ...form, features: form.features.filter((_, i) => i !== idx) });
};
const toggleAppId = (appId: string) => {
setForm(prev => ({
...prev,
freeAppIds: prev.freeAppIds.includes(appId)
? prev.freeAppIds.filter(id => id !== appId)
: [...prev.freeAppIds, appId],
}));
};
const toggleModelId = (modelId: string) => {
setForm(prev => ({
...prev,
freeModelIds: prev.freeModelIds.includes(modelId)
? prev.freeModelIds.filter(id => id !== modelId)
: [...prev.freeModelIds, modelId],
}));
};
// 根据 freeAppIds 获取应用名称列表(用于卡片展示)
const getFreeAppNames = (plan: MembershipPlan): string[] => {
return (plan.freeAppIds || []).map(id => allPaidApps.find(a => a.id === id)?.name).filter(Boolean) as string[];
};
const getFreeModelNames = (plan: MembershipPlan): string[] => {
return (plan.freeModelIds || []).map(id => allPaidModels.find(m => m.id === id)?.name).filter(Boolean) as string[];
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">会员订阅体系管理</h1>
{/* Plan Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-2xl)' }}>
{plans.map((plan, idx) => {
const colors = levelColors[idx] || levelColors[0];
const freeAppNames = getFreeAppNames(plan);
const freeModelNames = getFreeModelNames(plan);
return (
<div key={plan.id} className="admin-card" style={{ marginBottom: 0, borderTop: `3px solid ${colors.accent}` }}>
<div className="admin-card-header">
<div>
<span className="admin-badge" style={{ background: colors.bg, color: colors.accent, marginBottom: 'var(--space-sm)' }}>
{colors.label}
</span>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginTop: 'var(--space-sm)' }}>{plan.name}</h3>
</div>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(plan)}>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
</div>
{/* Pricing */}
<div style={{ display: 'flex', gap: 'var(--space-lg)', marginBottom: 'var(--space-xl)' }}>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>月付</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{plan.price} 元</div>
</div>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>季付</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{Math.round(plan.price * 3 * 0.85)} 元</div>
</div>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>年付</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{Math.round(plan.price * 12 * 0.7)} 元</div>
</div>
</div>
{/* Features */}
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, marginBottom: 'var(--space-md)' }}>会员权益</div>
<ul style={{ listStyle: 'none', padding: 0, display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
{plan.features.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)' }}>
<SVGIcon size={14}><polyline points="20 6 9 17 4 12"/></SVGIcon>
{f}
</li>
))}
</ul>
</div>
{/* Free tokens */}
<div style={{ marginTop: 'var(--space-lg)', padding: 'var(--space-md)', background: 'var(--color-accent-subtle)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', color: 'var(--color-accent)' }}>
每日免费额度: {plan.dailyFreeTokens.toLocaleString()} Token
</div>
{/* Free apps & models summary */}
{plan.level > 0 && (
<div style={{ marginTop: 'var(--space-md)', display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
{freeAppNames.length > 0 && (
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', display: 'flex', alignItems: 'center', gap: 'var(--space-xs)' }}>
<SVGIcon size={12}><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></SVGIcon>
免费应用({freeAppNames.length}): {freeAppNames.join(', ')}
</div>
)}
{freeModelNames.length > 0 && (
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', display: 'flex', alignItems: 'center', gap: 'var(--space-xs)' }}>
<SVGIcon size={12}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon>
免费模型({freeModelNames.length}): {freeModelNames.join(', ')}
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Automation Rules */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">自动化规则配置</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--space-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-xl)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-lg)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, marginBottom: 'var(--space-xs)' }}>到期自动降级</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>会员到期后自动降级为免费版</div>
</div>
<button
onClick={() => setAutoDowngrade(!autoDowngrade)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: autoDowngrade ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 3, left: autoDowngrade ? 24 : 3,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-xl)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-lg)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, marginBottom: 'var(--space-xs)' }}>自动续费默认开启</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>新会员订阅默认开启自动续费</div>
</div>
<button
onClick={() => setAutoRenew(!autoRenew)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: autoRenew ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 3, left: autoRenew ? 24 : 3,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
</div>
</div>
{/* Global Free Application List */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">当前各等级会员免费应用总览</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>应用名称</th>
<th>类型</th>
<th>来源</th>
<th>价格</th>
<th>免费等级</th>
</tr>
</thead>
<tbody>
{allPaidApps.map(app => {
const freePlans = plans.filter(p => (p.freeAppIds || []).includes(app.id));
return (
<tr key={app.id}>
<td style={{ fontWeight: 600 }}>{app.name}</td>
<td>
<span className={`admin-badge ${typeBadgeColors[app.type] || 'badge-neutral'}`}>
{typeLabels[app.type] || app.type.toUpperCase()}
</span>
</td>
<td><span className="admin-badge badge-neutral">{app.source === 'official' ? '官方' : '用户'}</span></td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{app.price} 元</td>
<td>
{freePlans.length > 0
? freePlans.map(p => (
<span key={p.id} className="admin-badge" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)', marginRight: 4, marginBottom: 2 }}>
{p.name}
</span>
))
: <span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>无</span>
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Global Free Model List */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">当前各等级会员免费大模型总览</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>模型名称</th>
<th>提供商</th>
<th>每百万Token价格</th>
<th>免费等级</th>
</tr>
</thead>
<tbody>
{allPaidModels.map(model => {
const freePlans = plans.filter(p => (p.freeModelIds || []).includes(model.id));
return (
<tr key={model.id}>
<td style={{ fontWeight: 600 }}>{model.name}</td>
<td>{model.providerName}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{model.pricePerMillionTokens} 元</td>
<td>
{freePlans.length > 0
? freePlans.map(p => (
<span key={p.id} className="admin-badge" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)', marginRight: 4, marginBottom: 2 }}>
{p.name}
</span>
))
: <span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>无</span>
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Edit Modal */}
{showModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: '6vh',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => { setShowModal(false); setEditingPlan(null); }}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 640, maxHeight: '84vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
编辑 {editingPlan?.name}
</h3>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">月价格(元)</label>
<input className="admin-form-input" type="number" value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">每日免费Token</label>
<input className="admin-form-input" type="number" value={form.dailyFreeTokens} onChange={e => setForm({ ...form, dailyFreeTokens: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">会员权益</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-sm)', marginBottom: 'var(--space-md)' }}>
{form.features.map((f, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 10px', borderRadius: 'var(--radius-full)',
background: 'var(--color-accent-light)', color: 'var(--color-accent)',
fontSize: 'var(--text-xs)', fontWeight: 500,
}}>
{f}
<button onClick={() => removeFeature(i)} style={{ cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1 }}>
<SVGIcon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<input
className="admin-form-input"
value={newFeature}
onChange={e => setNewFeature(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addFeature(); } }}
placeholder="输入权益名称,回车添加"
style={{ flex: 1 }}
/>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={addFeature}>添加</button>
</div>
</div>
{/* Free Applications Selection */}
<div style={{ marginTop: 'var(--space-2xl)' }}>
<label className="admin-form-label" style={{ marginBottom: 'var(--space-md)', display: 'block' }}>
收费应用免费权益
<span style={{ fontWeight: 400, color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)', fontSize: 'var(--text-xs)' }}>
勾选后会员可免费使用
</span>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 200, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 'var(--space-sm)' }}>
{allPaidApps.length === 0 && (
<div style={{ padding: 'var(--space-md)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', textAlign: 'center' }}>暂无收费应用</div>
)}
{allPaidApps.map(app => {
const checked = form.freeAppIds.includes(app.id);
return (
<label
key={app.id}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
background: checked ? 'var(--color-accent-subtle)' : 'transparent',
transition: 'background var(--transition-fast)',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleAppId(app.id)}
style={{
width: 16, height: 16, accentColor: 'var(--color-accent)',
cursor: 'pointer', flexShrink: 0,
}}
/>
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 500, flex: 1 }}>{app.name}</span>
<span className={`admin-badge ${typeBadgeColors[app.type] || 'badge-neutral'}`} style={{ fontSize: 'var(--text-2xs)' }}>
{typeLabels[app.type] || app.type.toUpperCase()}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', minWidth: 60, textAlign: 'right' }}>
{app.price} 元
</span>
</label>
);
})}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
已选 {form.freeAppIds.length}/{allPaidApps.length} 个应用
</div>
</div>
{/* Free Models Selection */}
<div style={{ marginTop: 'var(--space-2xl)' }}>
<label className="admin-form-label" style={{ marginBottom: 'var(--space-md)', display: 'block' }}>
收费大模型免费权益
<span style={{ fontWeight: 400, color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)', fontSize: 'var(--text-xs)' }}>
勾选后会员可免费调用,不消耗Token
</span>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 200, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 'var(--space-sm)' }}>
{allPaidModels.length === 0 && (
<div style={{ padding: 'var(--space-md)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', textAlign: 'center' }}>暂无收费模型</div>
)}
{allPaidModels.map(model => {
const checked = form.freeModelIds.includes(model.id);
return (
<label
key={model.id}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
background: checked ? 'var(--color-accent-subtle)' : 'transparent',
transition: 'background var(--transition-fast)',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelId(model.id)}
style={{
width: 16, height: 16, accentColor: 'var(--color-accent)',
cursor: 'pointer', flexShrink: 0,
}}
/>
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 500, flex: 1 }}>{model.name}</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{model.providerName}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', minWidth: 80, textAlign: 'right' }}>
{model.pricePerMillionTokens} 元/百万Token
</span>
</label>
);
})}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
已选 {form.freeModelIds.length}/{allPaidModels.length} 个模型
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
<button className="admin-btn admin-btn-outline" onClick={() => { setShowModal(false); setEditingPlan(null); }}>取消</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Membership;
+864
View File
@@ -0,0 +1,864 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const formatNumber = (n: number) => {
if (n >= 100000000) return (n / 100000000).toFixed(1) + '亿';
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString();
};
// Capability标签颜色映射
const capabilityColors: Record<string, string> = {
text: '#5B5FED',
vision: '#219B5E',
audio: '#E8922C',
video: '#D9383A',
image: '#D9383A',
thinking: '#8B5CF6',
tool: '#3B82F6',
};
const capabilityLabels: Record<string, string> = {
text: '文本',
vision: '视觉',
audio: '音频',
video: '视频',
image: '图像',
thinking: '思考',
tool: '工具',
};
const Models: React.FC = () => {
// 状态
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [models, setModels] = useState<AIModel[]>([]);
const [protocols, setProtocols] = useState<ProtocolDef[]>([]);
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [fetchedModels, setFetchedModels] = useState<FetchableModel[]>([]);
const [fetchSource, setFetchSource] = useState<string>(''); // 模型来源标识
const [fetchMessage, setFetchMessage] = useState<string>(''); // API错误消息
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(new Set());
const [showAddProvider, setShowAddProvider] = useState(false);
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 模型定价编辑状态
const [editingModelPrice, setEditingModelPrice] = useState<string | null>(null);
const [editPriceValue, setEditPriceValue] = useState<string>('');
const [priceSaving, setPriceSaving] = useState(false);
// 供应商表单
const [providerForm, setProviderForm] = useState({
name: '',
protocol: 'openai_compatible',
api_key: '',
api_base_url: '',
});
// 加载数据
const loadData = useCallback(async () => {
try {
setLoading(true);
const [provRes, modelsRes, protoRes] = await Promise.all([
adminAPI.getProviders(),
adminAPI.getAdminModels({ page_size: 100 }),
adminAPI.getProtocols(),
]);
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
// 后端models API返回分页格式: {total, page, page_size, items}
// 需要兼容 {code, data: {items}} 和 {total, items} 两种格式
const modelData = modelsRes.data || modelsRes;
const rawModels = Array.isArray(modelData) ? modelData : (modelData?.items || []);
// 后端返回snake_case,前端接口使用camelCase,需要映射
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
...m,
providerId: m.providerId || m.provider_id,
providerName: m.providerName || m.provider_name,
apiKey: m.apiKey || m.api_key,
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
isDefault: m.isDefault ?? m.is_default ?? false,
totalCalls: m.totalCalls ?? m.total_calls ?? 0,
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
}));
setModels(mappedModels);
} catch (err: any) {
const msg = typeof err.message === 'string' ? err.message : (err.detail || JSON.stringify(err));
setError('加载数据失败:' + msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 供应商操作
const handleAddProvider = async (e: React.FormEvent) => {
e.preventDefault();
if (!providerForm.name.trim()) return;
try {
setError(null);
await adminAPI.addProvider({
name: providerForm.name,
protocol: providerForm.protocol,
api_key: providerForm.api_key,
api_base_url: providerForm.api_base_url || undefined,
});
setShowAddProvider(false);
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
await loadData();
} catch (err: any) {
setError(err.message || '添加供应商失败');
}
};
const handleUpdateProvider = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingProvider || !providerForm.name.trim()) return;
try {
setError(null);
await adminAPI.updateProvider(editingProvider.id, {
name: providerForm.name,
protocol: providerForm.protocol,
api_key: providerForm.api_key,
api_base_url: providerForm.api_base_url || undefined,
});
setEditingProvider(null);
setShowAddProvider(false);
await loadData();
} catch (err: any) {
setError(err.message || '更新供应商失败');
}
};
const handleDeleteProvider = async (id: string) => {
if (!window.confirm('确定要删除该供应商吗?若有模型关联则无法删除。')) return;
try {
setError(null);
await adminAPI.deleteProvider(id);
alert('供应商已删除');
await loadData();
} catch (err: any) {
const msg = err?.message || err?.detail || '删除供应商失败';
alert(msg);
setError(msg);
}
};
const handleFetchModels = async (id: string) => {
try {
setError(null);
setFetchedModels([]);
setFetchSource('');
setFetchMessage('');
setSelectedModelIds(new Set());
const res = await adminAPI.fetchModels(id);
// 兼容后端返回数组或 {models: [...]} 两种格式
const raw = res.data !== undefined ? res.data : res;
const models = Array.isArray(raw) ? raw : (raw.models || []);
const source = raw.source || '';
const message = raw.message || '';
setFetchedModels(models);
setFetchSource(source);
setFetchMessage(message);
setExpandedProvider(id);
} catch (err: any) {
setError(err.message || '获取模型列表失败');
}
};
const handleAddModels = async (providerId: string) => {
if (selectedModelIds.size === 0) return;
try {
setError(null);
await adminAPI.addModels(providerId, Array.from(selectedModelIds));
alert('模型添加成功');
setFetchedModels([]);
setSelectedModelIds(new Set());
await loadData();
} catch (err: any) {
const msg = err?.message || err?.detail || '添加模型失败';
alert(msg);
setError(msg);
}
};
const handleToggleModel = async (model: AIModel) => {
try {
setError(null);
await adminAPI.toggleModel(model.id, !model.isEnabled);
await loadData();
} catch (err: any) {
setError(err.message || '切换模型状态失败');
}
};
const handleDeleteModel = async (model: AIModel) => {
if (!window.confirm(`确定要删除模型 "${model.name}" 吗?`)) return;
try {
setError(null);
await adminAPI.deleteModel(model.id);
alert('模型已删除');
await loadData();
} catch (err: any) {
const msg = err?.message || err?.detail || '删除模型失败';
alert(msg);
setError(msg);
}
};
const handleStartEditPrice = (model: AIModel) => {
setEditingModelPrice(model.id);
setEditPriceValue(String(model.pricePerMillionTokens || 0));
};
const handleCancelEditPrice = () => {
setEditingModelPrice(null);
setEditPriceValue('');
};
const handleSavePrice = async (model: AIModel) => {
const newPrice = parseFloat(editPriceValue);
if (isNaN(newPrice) || newPrice < 0) {
setError('请输入有效的价格数值');
return;
}
setPriceSaving(true);
setError(null);
try {
await adminAPI.updateModel(model.id, { price_per_million_tokens: newPrice });
setEditingModelPrice(null);
setEditPriceValue('');
await loadData();
} catch (err: any) {
setError(err.message || '修改价格失败');
} finally {
setPriceSaving(false);
}
};
const handleToggleStatus = async (model: AIModel) => {
const newStatus = model.status === 'free' ? 'paid' : 'free';
setError(null);
try {
await adminAPI.updateModel(model.id, { status: newStatus });
await loadData();
} catch (err: any) {
setError(err.message || '修改计费状态失败');
}
};
// 全选/取消
const toggleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedModelIds(new Set(fetchedModels.map(m => m.id)));
} else {
setSelectedModelIds(new Set());
}
};
const toggleSelectModel = (modelId: string) => {
const next = new Set(selectedModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
setSelectedModelIds(next);
};
// 统计
const stats = {
providerCount: providers.length,
enabledCount: models.filter(m => m.isEnabled).length,
totalCalls: models.reduce((sum, m) => sum + (m.totalCalls || 0), 0),
totalTokens: models.reduce((sum, m) => sum + (m.totalTokens || 0), 0),
};
const statCards = [
{ label: '供应商数', value: stats.providerCount },
{ label: '启用模型数', value: stats.enabledCount },
{ label: '总调用量', value: formatNumber(stats.totalCalls) },
{ label: '总Token消耗', value: formatNumber(stats.totalTokens) },
];
// 获取协议名称
const getProtocolName = (protocolId: string) => {
const p = protocols.find(p => p.id === protocolId);
return p ? p.name : protocolId;
};
// 获取provider下的模型
const getProviderModels = (providerId: string) => {
return models.filter(m => m.providerId === providerId);
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>大模型库管理</h1>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button
className="admin-btn admin-btn-outline"
onClick={() => {
const dialog = document.createElement('dialog');
dialog.style.cssText = 'border:none;border-radius:16px;padding:24px;width:420px;max-width:90vw;box-shadow:0 20px 48px rgba(0,0,0,0.15);';
dialog.innerHTML = `
<h3 style="margin:0 0 16px;font-size:18px;font-weight:700;">设置全局默认LLM</h3>
<p style="margin:0 0 12px;font-size:13px;color:#545A65;">选择平台默认使用的大语言模型,新对话将自动使用此模型。</p>
<select id="defaultLLMSelect" style="width:100%;padding:10px 12px;border:1px solid #E3E6EB;border-radius:8px;font-size:14px;margin-bottom:16px;">
${models.filter(m => m.isEnabled).map(m => `<option value="${m.id}" ${(m as any).isDefault ? 'selected' : ''}>${m.name} - ${m.providerName || ''} ${m.status === 'free' ? '(免费)' : '(付费)'}</option>`).join('')}
${models.filter(m => m.isEnabled).length === 0 ? '<option disabled>暂无可用的启用模型</option>' : ''}
</select>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button id="cancelDefaultLLM" style="padding:8px 16px;border:1px solid #E3E6EB;border-radius:8px;background:#fff;cursor:pointer;font-size:13px;">取消</button>
<button id="confirmDefaultLLM" style="padding:8px 16px;border:none;border-radius:8px;background:#5B5FED;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">确认设置</button>
</div>
`;
document.body.appendChild(dialog);
dialog.showModal();
dialog.querySelector('#cancelDefaultLLM')!.addEventListener('click', () => { dialog.close(); dialog.remove(); });
dialog.querySelector('#confirmDefaultLLM')!.addEventListener('click', async () => {
const select = dialog.querySelector('#defaultLLMSelect') as HTMLSelectElement;
const modelId = select?.value;
if (!modelId) {
alert('请选择模型');
return;
}
try {
await adminAPI.setDefaultModel(modelId);
alert('已设置全局默认模型');
dialog.close();
dialog.remove();
loadData();
} catch (err: any) {
alert('设置失败:' + (err?.message || err?.detail || '未知错误'));
}
});
}}
>
<SVGIcon size={14}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
设置默认LLM
</button>
<button
className="admin-btn admin-btn-primary"
onClick={() => { setShowAddProvider(true); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); }}
>
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
添加供应商
</button>
</div>
</div>
{/* 错误提示 */}
{error && (
<div style={{
padding: 'var(--space-md) var(--space-lg)', marginBottom: 'var(--space-lg)',
background: 'var(--color-error-light)', color: 'var(--color-error)',
borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>{error}</span>
<span style={{ cursor: 'pointer', fontWeight: 700, marginLeft: 'var(--space-lg)' }} onClick={() => setError(null)}>x</span>
</div>
)}
{/* Stats */}
<div className="admin-stats">
{statCards.map((s, i) => (
<div key={i} className="admin-stat-card">
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>{s.value}</div>
</div>
))}
</div>
{/* 加载状态 */}
{loading && (
<div style={{ textAlign: 'center', padding: 'var(--space-4xl)', color: 'var(--color-text-tertiary)' }}>
加载中...
</div>
)}
{/* 供应商列表 */}
{!loading && (
<div style={{ marginBottom: 'var(--space-2xl)' }}>
<h2 style={{ fontSize: 'var(--text-lg)', fontWeight: 600, marginBottom: 'var(--space-xl)' }}>模型供应商</h2>
{providers.length === 0 ? (
<div className="admin-card" style={{ textAlign: 'center', padding: 'var(--space-4xl)', color: 'var(--color-text-tertiary)' }}>
暂无供应商,请点击"添加供应商"开始
</div>
) : (
providers.map(provider => (
<div key={provider.id} className="admin-card" style={{ marginBottom: 'var(--space-lg)' }}>
{/* 供应商卡片头部 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-lg)',
background: 'var(--color-accent-light)', color: 'var(--color-accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<SVGIcon size={20}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon>
</div>
<div>
<div style={{ fontSize: 'var(--text-base)', fontWeight: 600 }}>{provider.name}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
协议: {getProtocolName(provider.protocol)}
{provider.apiBaseUrl ? ` | ${provider.apiBaseUrl}` : ''}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={() => handleFetchModels(provider.id)}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
获取模型列表
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={() => {
setEditingProvider(provider);
setProviderForm({
name: provider.name,
protocol: provider.protocol,
api_key: provider.apiKey || (provider as any).api_key || '',
api_base_url: provider.apiBaseUrl || (provider as any).api_base_url || '',
});
setShowAddProvider(true);
}}
>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
<button
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => handleDeleteProvider(provider.id)}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
</div>
</div>
{/* 获取到的模型列表 (fetch-models结果) */}
{expandedProvider === provider.id && fetchedModels.length > 0 && (
<div style={{
background: 'var(--color-bg-tertiary)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-lg)',
marginBottom: 'var(--space-md)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 'var(--space-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>
可选模型 ({fetchedModels.length})
</span>
{fetchSource && (
<span style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 'var(--radius-full)',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
background: fetchSource === 'api' ? '#219B5E' : fetchSource === 'error' ? '#D9383A' : '#E8922C',
}}>
{fetchSource === 'api' ? '真实API' : fetchSource === 'error' ? 'API错误' : '默认列表'}
</span>
)}
</div>
<button
className="admin-btn admin-btn-primary admin-btn-sm"
disabled={selectedModelIds.size === 0}
style={{ opacity: selectedModelIds.size === 0 ? 0.5 : 1 }}
onClick={() => handleAddModels(provider.id)}
>
添加选中模型 ({selectedModelIds.size})
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th style={{ width: 40 }}>
<input
type="checkbox"
checked={selectedModelIds.size === fetchedModels.length && fetchedModels.length > 0}
onChange={e => toggleSelectAll(e.target.checked)}
/>
</th>
<th>模型名称</th>
<th>功能标签</th>
<th>计费类型</th>
</tr>
</thead>
<tbody>
{fetchedModels.map(fm => (
<tr key={fm.id}>
<td>
<input
type="checkbox"
checked={selectedModelIds.has(fm.id)}
onChange={() => toggleSelectModel(fm.id)}
/>
</td>
<td style={{ fontWeight: 600 }}>{fm.name}</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(fm.capabilities || []).map(cap => (
<span key={cap} style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 'var(--radius-full)',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
background: capabilityColors[cap] || '#888',
}}>
{capabilityLabels[cap] || cap}
</span>
))}
</div>
</td>
<td>
<span className={`admin-badge ${fm.pricing === 'free' ? 'badge-success' : 'badge-warning'}`}>
{fm.pricing === 'free' ? '免费' : '付费'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 获取模型列表时的错误/提示信息 */}
{expandedProvider === provider.id && fetchedModels.length === 0 && fetchMessage && (
<div style={{
background: fetchSource === 'error' ? '#FEF2F2' : '#F0F9FA',
border: fetchSource === 'error' ? '1px solid #FECACA' : '1px solid #BBD9DD',
color: fetchSource === 'error' ? '#991B1B' : '#0F5156',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-xl)',
marginBottom: 'var(--space-md)',
fontSize: 'var(--text-sm)',
display: 'flex', flexDirection: 'column', gap: 'var(--space-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: '50%',
background: fetchSource === 'error' ? '#D9383A' : '#E8922C',
color: '#fff', fontSize: '13px', fontWeight: 700,
}}>
{fetchSource === 'error' ? '!' : 'i'}
</span>
<span style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 'var(--radius-full)',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
background: fetchSource === 'api' ? '#219B5E' : fetchSource === 'error' ? '#D9383A' : '#E8922C',
}}>
{fetchSource === 'api' ? '真实API' : fetchSource === 'error' ? 'API错误' : '默认列表'}
</span>
<span style={{ fontWeight: 600 }}>{fetchSource === 'error' ? '错误详情' : '提示'}</span>
</div>
<span style={{ lineHeight: 1.6 }}>{fetchMessage}</span>
{fetchSource === 'error' && (
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ alignSelf: 'flex-start' }}
onClick={() => handleFetchModels(provider.id)}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
重试获取
</button>
)}
</div>
)}
{/* 已上架模型 */}
{(() => {
const providerModels = getProviderModels(provider.id);
if (providerModels.length === 0) {
return (
<div style={{
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)',
padding: 'var(--space-md) 0',
}}>
暂无模型,点击"获取模型列表"获取可用模型
</div>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>模型名</th>
<th>功能标签</th>
<th>单价(元/百万Token)</th>
<th>状态</th>
<th>调用量</th>
<th>Token消耗</th>
<th>开关</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{providerModels.map(model => (
<tr key={model.id}>
<td style={{ fontWeight: 600 }}>
{model.name}
{(model as any).isDefault && (
<span style={{
display: 'inline-block', marginLeft: 6,
padding: '0 6px', borderRadius: 'var(--radius-full)',
fontSize: '10px', fontWeight: 600,
background: '#FEF3C7', color: '#D97706',
}}>默认</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(model.capabilities || []).map((cap: string) => (
<span key={cap} style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 'var(--radius-full)',
fontSize: '10px',
fontWeight: 600,
color: '#fff',
background: capabilityColors[cap] || '#888',
}}>
{capabilityLabels[cap] || cap}
</span>
))}
</div>
</td>
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>
{editingModelPrice === model.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="number"
min="0"
step="0.01"
value={editPriceValue}
onChange={e => setEditPriceValue(e.target.value)}
style={{
width: 80, padding: '2px 6px', fontSize: 'var(--text-xs)',
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
}}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') handleSavePrice(model);
if (e.key === 'Escape') handleCancelEditPrice();
}}
/>
<button
className="admin-btn admin-btn-primary admin-btn-sm"
style={{ padding: '2px 6px', fontSize: '10px' }}
onClick={() => handleSavePrice(model)}
disabled={priceSaving}
>
确定
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ padding: '2px 6px', fontSize: '10px' }}
onClick={handleCancelEditPrice}
>
取消
</button>
</div>
) : (
<span
style={{
cursor: 'pointer',
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
transition: 'background var(--transition-fast)',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#F0F4FF')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
onClick={() => handleStartEditPrice(model)}
title="点击编辑单价"
>
{model.pricePerMillionTokens !== undefined && model.pricePerMillionTokens !== null
? '¥' + model.pricePerMillionTokens.toFixed(2)
: '未设置'}
</span>
)}
</td>
<td>
<span
className={`admin-badge ${model.status === 'free' ? 'badge-success' : 'badge-warning'}`}
style={{ cursor: 'pointer' }}
onClick={() => handleToggleStatus(model)}
title="点击切换计费状态"
>
{model.status === 'free' ? '免费' : '付费'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{formatNumber(model.totalCalls || 0)}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{formatNumber(model.totalTokens || 0)}</td>
<td>
<button
onClick={() => handleToggleModel(model)}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: model.isEnabled ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 2, left: model.isEnabled ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</td>
<td>
<button
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => handleDeleteModel(model)}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
{(model as any).isDefault ? (
<span className="admin-badge badge-success" style={{ marginLeft: 4, fontSize: '10px' }}>默认</span>
) : (
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ marginLeft: 4, fontSize: '10px' }}
onClick={async () => {
try {
await adminAPI.setDefaultModel(model.id);
loadData();
} catch (err: any) {
alert('设置失败:' + (err?.message || err?.detail || ''));
}
}}
>设为默认</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()}
</div>
))
)}
</div>
)}
{/* 添加/编辑供应商弹窗 */}
{showAddProvider && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
{editingProvider ? '编辑供应商' : '添加供应商'}
</h3>
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
<div className="admin-form-group">
<label className="admin-form-label">供应商名称</label>
<input
className="admin-form-input"
value={providerForm.name}
onChange={e => setProviderForm({ ...providerForm, name: e.target.value })}
placeholder="例如: DeepSeek"
required
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">协议类型</label>
<select
className="admin-form-input"
value={providerForm.protocol}
onChange={e => setProviderForm({ ...providerForm, protocol: e.target.value })}
>
{protocols.map(p => (
<option key={p.id} value={p.id}>{p.name} - {p.description}</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input
className="admin-form-input"
value={providerForm.api_key}
onChange={e => setProviderForm({ ...providerForm, api_key: e.target.value })}
placeholder="sk-..."
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Base URL(可选)</label>
<input
className="admin-form-input"
value={providerForm.api_base_url}
onChange={e => setProviderForm({ ...providerForm, api_base_url: e.target.value })}
placeholder="例如: https://api.deepseek.com"
/>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
根域名,不含路径。系统将自动拼接协议对应的请求路径
</div>
</div>
<div className="admin-form-actions">
<button type="submit" className="admin-btn admin-btn-primary">
{editingProvider ? '保存修改' : '创建供应商'}
</button>
<button
type="button"
className="admin-btn admin-btn-outline"
onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}
>
取消
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Models;
+367
View File
@@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
import type { PaymentConfig, Order } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const statusBadge = (status: string) => {
const map: Record<string, { cls: string; text: string }> = {
active: { cls: 'badge-success', text: '已启用' },
inactive: { cls: 'badge-neutral', text: '未启用' },
paid: { cls: 'badge-success', text: '已支付' },
pending: { cls: 'badge-warning', text: '待支付' },
refunded: { cls: 'badge-error', text: '已退款' },
};
const s = map[status] || { cls: 'badge-neutral', text: status };
return <span className={`admin-badge ${s.cls}`}>{s.text}</span>;
};
const paymentMethodBadge = (method: string) => {
return method === 'wechat'
? <span className="admin-badge badge-success">微信支付</span>
: <span className="admin-badge badge-info">支付宝</span>;
};
const Payment: React.FC = () => {
const [configs, setConfigs] = useState<PaymentConfig[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [saving, setSaving] = useState<Record<string, boolean>>({});
const [msg, setMsg] = useState<Record<string, { type: string; text: string }>>({});
const wechat = configs.find(c => c.type === 'wechat');
const alipay = configs.find(c => c.type === 'alipay');
const [wechatForm, setWechatForm] = useState({
app_id: '', merchant_id: '', api_key: '', api_v3_key: '',
cert_path: '', cert_serial_no: '', notify_url: '',
});
const [alipayForm, setAlipayForm] = useState({
app_id: '', app_private_key: '', alipay_public_key: '',
gateway_url: 'https://openapi.alipay.com/gateway.do', notify_url: '', return_url: '',
});
useEffect(() => {
loadConfigs();
loadOrders();
}, []);
useEffect(() => {
if (wechat) {
setWechatForm({
app_id: wechat.app_id || '',
merchant_id: wechat.merchant_id || '',
api_key: wechat.api_key || '',
api_v3_key: wechat.api_v3_key || '',
cert_path: wechat.cert_path || '/etc/certs/apiclient_cert.pem',
cert_serial_no: wechat.cert_serial_no || '',
notify_url: wechat.notify_url || '',
});
}
if (alipay) {
setAlipayForm({
app_id: alipay.app_id || '',
app_private_key: alipay.app_private_key || '',
alipay_public_key: alipay.alipay_public_key || '',
gateway_url: alipay.gateway_url || 'https://openapi.alipay.com/gateway.do',
notify_url: alipay.notify_url || '',
return_url: alipay.return_url || '',
});
}
}, [configs]);
const loadConfigs = async () => {
try {
const res = await adminAPI.getPaymentConfigs();
const list = res?.data || res;
if (Array.isArray(list)) {
setConfigs(list);
}
} catch (e) { console.error('加载支付配置失败', e); }
};
const loadOrders = async () => {
try {
const res = await adminAPI.getAdminOrders({ page: 1, page_size: 20 });
const data: any = res?.data || res;
if (data?.items) {
setOrders(data.items);
} else if (Array.isArray(data)) {
setOrders(data);
}
} catch (e) { console.error('加载订单失败', e); }
};
const handleSaveConfig = async (id: string, data: any) => {
setSaving(prev => ({ ...prev, [id]: true }));
setMsg(prev => ({ ...prev, [id]: { type: '', text: '' } }));
try {
await adminAPI.updatePaymentConfig(id, data);
setMsg(prev => ({ ...prev, [id]: { type: 'success', text: '保存成功' } }));
loadConfigs();
} catch (e: any) {
setMsg(prev => ({ ...prev, [id]: { type: 'error', text: e.message || '保存失败' } }));
} finally {
setSaving(prev => ({ ...prev, [id]: false }));
}
};
const handleToggleConfig = async (type: string) => {
const config = type === 'wechat' ? wechat : alipay;
if (!config) return;
const newStatus = config.status === 'active' ? 'inactive' : 'active';
await handleSaveConfig(config.id, { status: newStatus });
};
const handleRefund = async (orderId: string) => {
try {
await adminAPI.refundOrder(orderId);
loadOrders();
} catch (e) { console.error('退款失败', e); }
};
const formatDate = (d: string) => {
const dt = new Date(d);
return dt.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">支付渠道配置</h1>
{Object.entries(msg).map(([id, m]) => m.text ? (
<div key={id} style={{
padding: 'var(--space-md) var(--space-lg)',
marginBottom: 'var(--space-lg)',
borderRadius: 'var(--radius-md)',
background: m.type === 'success' ? 'var(--color-success-light)' : 'var(--color-error-light)',
color: m.type === 'success' ? 'var(--color-success)' : 'var(--color-error)',
fontWeight: 500,
}}>
{m.text}
</div>
) : null)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-2xl)' }}>
{/* WeChat Pay Card */}
<div className="admin-card" style={{ marginBottom: 0 }}>
<div className="admin-card-header">
<h3 className="admin-card-title" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<div style={{ width: 32, height: 32, borderRadius: 'var(--radius-md)', background: '#E6F7EE', color: '#09BB07', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SVGIcon size={18}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/></SVGIcon>
</div>
微信支付
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
{wechat ? statusBadge(wechat.status) : <span className="admin-badge badge-neutral">未配置</span>}
{wechat && (
<button
onClick={() => handleToggleConfig('wechat')}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: wechat.status === 'active' ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 2, left: wechat.status === 'active' ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)}
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">AppID</label>
<input className="admin-form-input" value={wechatForm.app_id} onChange={e => setWechatForm({ ...wechatForm, app_id: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">商户号</label>
<input className="admin-form-input" value={wechatForm.merchant_id} onChange={e => setWechatForm({ ...wechatForm, merchant_id: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API v2 密钥</label>
<input className="admin-form-input" type="password" value={wechatForm.api_key} onChange={e => setWechatForm({ ...wechatForm, api_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API v3 密钥</label>
<input className="admin-form-input" type="password" value={wechatForm.api_v3_key} onChange={e => setWechatForm({ ...wechatForm, api_v3_key: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">证书路径</label>
<input className="admin-form-input" value={wechatForm.cert_path} onChange={e => setWechatForm({ ...wechatForm, cert_path: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">回调通知地址</label>
<input className="admin-form-input" value={wechatForm.notify_url} placeholder="https://your-domain.com/api/payment/wechat/notify" onChange={e => setWechatForm({ ...wechatForm, notify_url: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" disabled={saving[wechat?.id || ''] || !wechat} onClick={() => wechat && handleSaveConfig(wechat.id, {
app_id: wechatForm.app_id,
merchant_id: wechatForm.merchant_id,
api_key: wechatForm.api_key,
api_v3_key: wechatForm.api_v3_key,
cert_path: wechatForm.cert_path,
notify_url: wechatForm.notify_url,
})}>
{saving[wechat?.id || ''] ? '保存中...' : '保存配置'}
</button>
</div>
</div>
{/* Alipay Card */}
<div className="admin-card" style={{ marginBottom: 0 }}>
<div className="admin-card-header">
<h3 className="admin-card-title" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<div style={{ width: 32, height: 32, borderRadius: 'var(--radius-md)', background: '#EBF2FE', color: '#1677FF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SVGIcon size={18}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/></SVGIcon>
</div>
支付宝
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
{alipay ? statusBadge(alipay.status) : <span className="admin-badge badge-neutral">未配置</span>}
{alipay && (
<button
onClick={() => handleToggleConfig('alipay')}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: alipay.status === 'active' ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 2, left: alipay.status === 'active' ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)}
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">AppID</label>
<input className="admin-form-input" value={alipayForm.app_id} onChange={e => setAlipayForm({ ...alipayForm, app_id: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">接口地址</label>
<input className="admin-form-input" value={alipayForm.gateway_url} onChange={e => setAlipayForm({ ...alipayForm, gateway_url: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">应用私钥</label>
<input className="admin-form-input" type="password" value={alipayForm.app_private_key} placeholder="-----BEGIN RSA PRIVATE KEY-----" onChange={e => setAlipayForm({ ...alipayForm, app_private_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">支付宝公钥</label>
<input className="admin-form-input" type="password" value={alipayForm.alipay_public_key} placeholder="-----BEGIN PUBLIC KEY-----" onChange={e => setAlipayForm({ ...alipayForm, alipay_public_key: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">异步回调地址</label>
<input className="admin-form-input" value={alipayForm.notify_url} placeholder="https://your-domain.com/api/payment/alipay/notify" onChange={e => setAlipayForm({ ...alipayForm, notify_url: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">同步跳转地址</label>
<input className="admin-form-input" value={alipayForm.return_url} placeholder="https://your-domain.com/#/payment-result" onChange={e => setAlipayForm({ ...alipayForm, return_url: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" disabled={saving[alipay?.id || ''] || !alipay} onClick={() => alipay && handleSaveConfig(alipay.id, {
app_id: alipayForm.app_id,
app_private_key: alipayForm.app_private_key,
alipay_public_key: alipayForm.alipay_public_key,
gateway_url: alipayForm.gateway_url,
notify_url: alipayForm.notify_url,
return_url: alipayForm.return_url,
})}>
{saving[alipay?.id || ''] ? '保存中...' : '保存配置'}
</button>
</div>
</div>
</div>
{/* Order Management */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">订单管理</h3>
<button className="admin-btn admin-btn-outline admin-btn-sm">
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></SVGIcon>
导出
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>订单号</th>
<th>用户</th>
<th>类型</th>
<th>项目</th>
<th>金额</th>
<th>支付方式</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{order.id}</td>
<td style={{ fontWeight: 500 }}>{order.userId}</td>
<td>
<span className={`admin-badge ${order.orderType === 'membership' ? 'badge-info' : order.orderType === 'app' ? 'badge-success' : 'badge-warning'}`}>
{order.orderType === 'membership' ? '会员' : order.orderType === 'app' ? '应用' : '服务'}
</span>
</td>
<td style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{order.itemName}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{order.amount.toFixed(2)} 元</td>
<td>{paymentMethodBadge(order.paymentMethod)}</td>
<td>{statusBadge(order.status)}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(order.createdAt)}</td>
<td>
{order.status === 'paid' && (
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleRefund(order.id)}>
退款
</button>
)}
{order.status === 'refunded' && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>已退款</span>
)}
{order.status === 'pending' && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>等待支付</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Payment;
+546
View File
@@ -0,0 +1,546 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SERVICE_TYPE_MAP: Record<string, { label: string; color: string; bg: string }> = {
app_custom: { label: '应用定制', color: '#4F46E5', bg: '#EEF2FF' },
platform_proxy: { label: '平台代理', color: '#059669', bg: '#ECFDF5' },
data_custom: { label: '数据定制', color: '#D97706', bg: '#FFFBEB' },
private_project: { label: '私有化项目', color: '#7C3AED', bg: '#F5F3FF' },
};
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '上架中', cls: 'badge-success' },
inactive: { label: '已下架', cls: 'badge-neutral' },
};
const REQUEST_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: '待处理', cls: 'badge-warning' },
processing: { label: '处理中', cls: 'badge-info' },
completed: { label: '已完成', cls: 'badge-success' },
rejected: { label: '已驳回', cls: 'badge-error' },
};
interface ServiceItem {
id: string;
name: string;
description: string;
price: number;
type: string;
type_name: string;
features: string[];
status: string;
assigned_agent_id: string | null;
created_at: string;
updated_at: string;
}
interface CustomRequestItem {
id: string;
user_id: string;
username: string;
service_id: string;
service_name: string;
request_type: string;
request_type_name: string;
title: string;
description: string;
requirements: Record<string, any>;
status: string;
assigned_agent_id: string | null;
created_at: string;
updated_at: string;
}
interface AgentItem {
id: string;
name: string;
description: string;
publisher_name: string;
}
const Services: React.FC = () => {
const [services, setServices] = useState<ServiceItem[]>([]);
const [requests, setRequests] = useState<CustomRequestItem[]>([]);
const [agents, setAgents] = useState<AgentItem[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'services' | 'requests'>('services');
// Modal states
const [showModal, setShowModal] = useState(false);
const [editingService, setEditingService] = useState<ServiceItem | null>(null);
const [form, setForm] = useState({
name: '', service_type: 'app_custom', description: '', price: 0,
features: '', status: 'active', assigned_agent_id: '',
});
// Agent assignment modal
const [showAgentModal, setShowAgentModal] = useState(false);
const [agentServiceId, setAgentServiceId] = useState('');
const loadData = useCallback(async () => {
try {
setLoading(true);
const [svcRes, reqRes, agentsRes] = await Promise.all([
adminAPI.getServices(),
adminAPI.getCustomRequests(),
adminAPI.getAvailableAgents(),
]);
if (Array.isArray(svcRes)) setServices(svcRes);
else if (svcRes?.data) setServices(svcRes.data);
if (Array.isArray(reqRes)) setRequests(reqRes);
else if (reqRes?.data) setRequests(reqRes.data);
if (Array.isArray(agentsRes)) setAgents(agentsRes);
else if (agentsRes?.data) setAgents(agentsRes.data);
} catch (e) {
console.error('加载增值服务数据失败', e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleEdit = (s: ServiceItem) => {
setEditingService(s);
setForm({
name: s.name,
service_type: s.type,
description: s.description || '',
price: s.price || 0,
features: (s.features || []).join(', '),
status: s.status,
assigned_agent_id: s.assigned_agent_id || '',
});
setShowModal(true);
};
const handleSave = async () => {
if (!form.name.trim()) return;
try {
const data = {
name: form.name,
service_type: form.service_type,
description: form.description,
price: form.price,
features: form.features,
status: form.status,
assigned_agent_id: form.assigned_agent_id || undefined,
};
if (editingService) {
await adminAPI.updateService(editingService.id, data);
} else {
await adminAPI.addService(data);
}
setShowModal(false);
setEditingService(null);
setForm({ name: '', service_type: 'app_custom', description: '', price: 0, features: '', status: 'active', assigned_agent_id: '' });
await loadData();
} catch (e: any) {
alert('保存失败: ' + (e.message || '未知错误'));
}
};
const handleDelete = async (id: string, name: string) => {
if (!confirm('确定要删除服务: ' + name + '? 删除后该服务将不再对外展示,但不会影响已发生的服务记录。')) return;
try {
console.log('[handleDelete] 开始删除服务:', id, name);
const res = await adminAPI.deleteService(id);
console.log('[handleDelete] 删除响应:', res);
alert(res?.message || '删除成功');
await loadData();
} catch (e: any) {
console.error('[handleDelete] 删除失败:', e);
let msg = '未知错误';
if (typeof e === 'string') {
msg = e;
} else if (e?.response?.data?.detail) {
msg = e.response.data.detail;
} else if (e?.detail) {
msg = e.detail;
} else if (e?.message) {
msg = e.message;
}
alert('删除失败: ' + msg);
}
};
const handleToggleStatus = async (s: ServiceItem) => {
try {
await adminAPI.updateService(s.id, { status: s.status === 'active' ? 'inactive' : 'active' });
await loadData();
} catch (e: any) {
alert('操作失败: ' + (e.message || '未知错误'));
}
};
const handleAssignAgent = async (agentId: string) => {
try {
await adminAPI.assignAgentToService(agentServiceId, agentId);
alert('Agent绑定成功');
setShowAgentModal(false);
await loadData();
} catch (e: any) {
alert('绑定失败: ' + (e.message || '未知错误'));
}
};
const handleUpdateRequest = async (id: string, status: string) => {
try {
await adminAPI.updateCustomRequest(id, { status });
await loadData();
} catch (e: any) {
alert('更新失败: ' + (e.message || '未知错误'));
}
};
const handleAssignAgentToRequest = async (id: string, agentId: string) => {
try {
await adminAPI.updateCustomRequest(id, { assigned_agent_id: agentId, status: 'processing' });
await loadData();
} catch (e: any) {
alert('分配失败: ' + (e.message || '未知错误'));
}
};
const formatDate = (d: string) => {
if (!d) return '--';
const dt = new Date(d);
return dt.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
if (loading) return <div style={{ textAlign: 'center', padding: 60 }}>加载中...</div>;
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>增值服务管理</h1>
<button className="admin-btn admin-btn-primary" onClick={() => {
setEditingService(null);
setForm({ name: '', service_type: 'app_custom', description: '', price: 0, features: '', status: 'active', assigned_agent_id: '' });
setShowModal(true);
}}>
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
上架新服务
</button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border-light)', paddingBottom: 'var(--space-md)' }}>
<button
className={`pricing-cycle-tab ${activeTab === 'services' ? 'active' : ''}`}
onClick={() => setActiveTab('services')}
style={{ padding: 'var(--space-sm) var(--space-lg)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontWeight: 600, border: 'none', cursor: 'pointer', background: activeTab === 'services' ? 'var(--color-bg-secondary)' : 'transparent', color: activeTab === 'services' ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)' }}
>
服务列表 ({services.length})
</button>
<button
className={`pricing-cycle-tab ${activeTab === 'requests' ? 'active' : ''}`}
onClick={() => setActiveTab('requests')}
style={{ padding: 'var(--space-sm) var(--space-lg)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontWeight: 600, border: 'none', cursor: 'pointer', background: activeTab === 'requests' ? 'var(--color-bg-secondary)' : 'transparent', color: activeTab === 'requests' ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)' }}
>
工单管理 ({requests.length})
</button>
</div>
{/* Services List */}
{activeTab === 'services' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">增值服务列表</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>价格</th>
<th>绑定Agent</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{services.map(s => {
const typeConfig = SERVICE_TYPE_MAP[s.type] || SERVICE_TYPE_MAP.app_custom;
const statusConfig = STATUS_MAP[s.status] || STATUS_MAP.active;
const boundAgent = s.assigned_agent_id ? agents.find(a => a.id === s.assigned_agent_id) : null;
return (
<tr key={s.id}>
<td>
<div style={{ fontWeight: 600 }}>{s.name}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.description}</div>
</td>
<td>
<span style={{
display: 'inline-flex', padding: '2px 10px', borderRadius: 'var(--radius-full)',
fontSize: '11px', fontWeight: 600, color: typeConfig.color, background: typeConfig.bg
}}>
{typeConfig.label}
</span>
</td>
<td><span className={`admin-badge ${statusConfig.cls}`}>{statusConfig.label}</span></td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
{s.price > 0 ? s.price.toFixed(2) + ' 元' : '面议'}
</td>
<td>
{boundAgent ? (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 500 }}>
{boundAgent.name}
</span>
) : (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>未绑定</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)', flexWrap: 'wrap' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(s)}>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
{s.type === 'app_custom' && (
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => { setAgentServiceId(s.id); setShowAgentModal(true); }}>
<SVGIcon size={12}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon>
Agent
</button>
)}
<button
className={`admin-btn ${s.status === 'active' ? 'admin-btn-danger' : 'admin-btn-outline'} admin-btn-sm`}
onClick={() => handleToggleStatus(s)}
>
{s.status === 'active' ? '下架' : '上架'}
</button>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleDelete(s.id, s.name)}>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
</div>
</td>
</tr>
);
})}
{services.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无增值服务</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* CustomRequests List */}
{activeTab === 'requests' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">服务工单列表</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>工单号</th>
<th>用户</th>
<th>类型</th>
<th>标题</th>
<th>状态</th>
<th>分配Agent</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{requests.map(req => {
const typeConfig = SERVICE_TYPE_MAP[req.request_type] || SERVICE_TYPE_MAP.app_custom;
const statusConfig = REQUEST_STATUS_MAP[req.status] || REQUEST_STATUS_MAP.pending;
return (
<tr key={req.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{req.id.slice(0, 8)}...</td>
<td style={{ fontWeight: 500 }}>{req.username}</td>
<td>
<span style={{
display: 'inline-flex', padding: '2px 10px', borderRadius: 'var(--radius-full)',
fontSize: '11px', fontWeight: 600, color: typeConfig.color, background: typeConfig.bg
}}>
{typeConfig.label}
</span>
</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{req.title}</td>
<td><span className={`admin-badge ${statusConfig.cls}`}>{statusConfig.label}</span></td>
<td>
{req.assigned_agent_id ? (
(() => {
const agent = agents.find(a => a.id === req.assigned_agent_id);
return <span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 500 }}>{agent ? agent.name : req.assigned_agent_id.slice(0, 8)}</span>;
})()
) : (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>未分配</span>
)}
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(req.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)', flexWrap: 'wrap' }}>
{req.status === 'pending' && (
<>
<select
style={{ fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)' }}
defaultValue=""
onChange={e => {
if (e.target.value) handleAssignAgentToRequest(req.id, e.target.value);
}}
>
<option value="">分配Agent</option>
{agents.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleUpdateRequest(req.id, 'rejected')}>
驳回
</button>
</>
)}
{req.status === 'processing' && (
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleUpdateRequest(req.id, 'completed')}>
标记完成
</button>
)}
</div>
</td>
</tr>
);
})}
{requests.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无服务工单</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Add/Edit Modal */}
{showModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => { setShowModal(false); setEditingService(null); }}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
{editingService ? '编辑服务' : '上架新服务'}
</h3>
<div className="admin-form-group">
<label className="admin-form-label">服务名称</label>
<input className="admin-form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="输入服务名称" />
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">服务类型</label>
<select className="admin-form-input" value={form.service_type} onChange={e => setForm({ ...form, service_type: e.target.value })}>
{Object.entries(SERVICE_TYPE_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">状态</label>
<select className="admin-form-input" value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
<option value="active">上架</option>
<option value="inactive">下架</option>
</select>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">服务描述</label>
<textarea
className="admin-form-input admin-form-textarea"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="输入服务描述"
style={{ minHeight: 80 }}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">服务特性(逗号分隔)</label>
<input className="admin-form-input" value={form.features} onChange={e => setForm({ ...form, features: e.target.value })} placeholder="特性1, 特性2, 特性3" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">价格(元, 0表示面议)</label>
<input className="admin-form-input" type="number" value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
<button className="admin-btn admin-btn-outline" onClick={() => { setShowModal(false); setEditingService(null); }}>取消</button>
</div>
</div>
</div>
)}
{/* Agent Assignment Modal */}
{showAgentModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => setShowAgentModal(false)}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 480, maxHeight: '70vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-lg)' }}>
选择要绑定的Agent
</h3>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-xl)' }}>
从已发布的市场应用中选择一个Agent绑定到该应用定制服务
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)', maxHeight: 300, overflowY: 'auto' }}>
{agents.map(agent => (
<div
key={agent.id}
style={{
padding: 'var(--space-lg)', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-lg)',
cursor: 'pointer', transition: 'all var(--transition-fast)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
onClick={() => handleAssignAgent(agent.id)}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--color-accent)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<div>
<div style={{ fontWeight: 600, fontSize: 'var(--text-sm)', color: 'var(--color-text-primary)' }}>{agent.name}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{agent.description}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-disabled)', marginTop: 4 }}>发布者: {agent.publisher_name}</div>
</div>
<button className="admin-btn admin-btn-primary admin-btn-sm">选择</button>
</div>
))}
{agents.length === 0 && (
<p style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-xl)' }}>暂无可用Agent</p>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default Services;
+454
View File
@@ -0,0 +1,454 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SiteSettings: React.FC = () => {
const [brand, setBrand] = useState<Record<string, string>>({
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
});
const [sms, setSms] = useState({
provider: '', api_key: '', api_secret: '', sign_name: '', template_code: '',
});
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<string | null>(null);
const [showEmailTest, setShowEmailTest] = useState(false);
const [emailTestTo, setEmailTestTo] = useState('');
const [emailTestSubject, setEmailTestSubject] = useState('');
const [emailTestBody, setEmailTestBody] = useState('');
const [emailGenerating, setEmailGenerating] = useState(false);
// AI生成测试邮件内容
const handleAIGenerateContent = async () => {
setEmailGenerating(true);
try {
const res = await adminAPI.generateEmailContent('测试邮件');
const data = (res as any).data || res;
setEmailTestSubject(data.subject || '');
setEmailTestBody(data.body || '');
} catch (e) {
// 降级:使用客户端预设内容
const subjects = [
'【测试】AIFlow邮件服务配置验证',
'AIFlow平台 - 邮件投递测试',
'测试邮件:您的SMTP服务已就绪',
];
const bodies = [
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
];
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
} finally {
setEmailGenerating(false);
}
};
// 加载真实配置
useEffect(() => {
async function loadConfig() {
try {
const [siteRes, emailRes, smsRes] = await Promise.all([
adminAPI.getSiteSettings(),
adminAPI.getEmailConfig(),
adminAPI.getSmsConfig(),
]);
if (siteRes?.data) {
setBrand({
logo: siteRes.data.logo || '',
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
announcement: siteRes.data.announcement || '',
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
});
}
if (emailRes?.data) {
setMail({
smtp_host: emailRes.data.smtp_host || '',
smtp_port: emailRes.data.smtp_port || 587,
username: emailRes.data.username || '',
password: emailRes.data.password || '',
from_address: emailRes.data.from_address || '',
use_tls: emailRes.data.use_tls !== false,
});
}
if (smsRes?.data) {
setSms({
provider: smsRes.data.provider || '',
api_key: smsRes.data.api_key || '',
api_secret: smsRes.data.api_secret || '',
sign_name: smsRes.data.sign_name || '',
template_code: smsRes.data.template_code || '',
});
}
} catch (e) {
console.error('加载配置失败:', e);
} finally {
setLoading(false);
}
}
loadConfig();
}, []);
const showSaved = (block: string) => {
setSavedMsg(block + ' 保存成功');
setTimeout(() => setSavedMsg(null), 2000);
};
const showTestResult = (result: string) => {
setTestResult(result);
setTimeout(() => setTestResult(null), 5000);
};
// 保存品牌设置
const saveSiteSettings = async () => {
try {
await adminAPI.updateSiteSettings({
logo: brand.logo,
site_name: brand.siteName,
announcement: brand.announcement,
icp_number: brand.icpNumber,
contact_phone: brand.contactPhone,
});
showSaved('品牌设置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存邮件配置
const saveEmailConfig = async () => {
try {
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
showSaved('邮件配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存短信配置
const saveSmsConfig = async () => {
try {
await adminAPI.updateSmsConfig({
provider: sms.provider,
api_key: sms.api_key,
api_secret: sms.api_secret,
sign_name: sms.sign_name,
template_code: sms.template_code,
});
showSaved('短信配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 发送测试邮件 - 先保存配置再发送
const sendTestEmail = async () => {
if (!emailTestTo.trim()) {
showTestResult('请输入收件邮箱地址');
return;
}
try {
// 测试前先保存邮件配置到数据库,确保使用最新配置
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
const res = await adminAPI.testEmail({
test_to: emailTestTo.trim(),
test_subject: emailTestSubject || undefined,
test_body: emailTestBody || undefined,
});
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
setShowEmailTest(false);
} catch (e: any) {
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
}
};
const openEmailTest = () => {
setEmailTestTo('');
setEmailTestSubject('');
setEmailTestBody('');
setShowEmailTest(true);
};
// 发送测试短信
const sendTestSms = async () => {
try {
const res = await adminAPI.testSms();
showTestResult('测试短信发送成功: ' + (res?.message || 'OK'));
} catch (e: any) {
showTestResult('测试短信发送失败: ' + (e.detail || e.message));
}
};
if (loading) {
return <div style={{ padding: 40, textAlign: 'center' }}>加载中...</div>;
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>网站基础设置</h1>
{savedMsg && (
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
{' '}{savedMsg}
</span>
)}
</div>
{/* Brand Settings */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></SVGIcon>
{' '}品牌设置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">LOGO URL</label>
<input className="admin-form-input" value={brand.logo} onChange={e => setBrand({ ...brand, logo: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">网站名称</label>
<input className="admin-form-input" value={brand.siteName} onChange={e => setBrand({ ...brand, siteName: e.target.value })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">首页公告</label>
<textarea
className="admin-form-input admin-form-textarea"
value={brand.announcement}
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
style={{ minHeight: 60 }}
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">备案号</label>
<input className="admin-form-input" value={brand.icpNumber} onChange={e => setBrand({ ...brand, icpNumber: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">客服联系方式</label>
<input className="admin-form-input" value={brand.contactPhone} onChange={e => setBrand({ ...brand, contactPhone: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
</div>
</div>
{/* Mail Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}邮件配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">SMTP服务器</label>
<input className="admin-form-input" value={mail.smtp_host} onChange={e => setMail({ ...mail, smtp_host: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">端口</label>
<input className="admin-form-input" type="number" value={mail.smtp_port} onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">用户名</label>
<input className="admin-form-input" value={mail.username} onChange={e => setMail({ ...mail, username: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">密码</label>
<input className="admin-form-input" type="password" value={mail.password} onChange={e => setMail({ ...mail, password: e.target.value })} />
</div>
</div>
<div className="admin-form-group" style={{ maxWidth: '50%' }}>
<label className="admin-form-label">发件地址</label>
<input className="admin-form-input" value={mail.from_address} onChange={e => setMail({ ...mail, from_address: e.target.value })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
</div>
</div>
{/* SMS Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></SVGIcon>
{' '}短信配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">短信服务商</label>
<select className="admin-form-input" value={sms.provider} onChange={e => setSms({ ...sms, provider: e.target.value })}>
<option value="">请选择</option>
<option value="阿里云短信">阿里云短信</option>
<option value="腾讯云短信">腾讯云短信</option>
<option value="华为云短信">华为云短信</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">短信签名</label>
<input className="admin-form-input" value={sms.sign_name} onChange={e => setSms({ ...sms, sign_name: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input className="admin-form-input" value={sms.api_key} onChange={e => setSms({ ...sms, api_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Secret</label>
<input className="admin-form-input" type="password" value={sms.api_secret} onChange={e => setSms({ ...sms, api_secret: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSmsConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={sendTestSms}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试短信
</button>
</div>
</div>
{/* Email Test Modal */}
{showEmailTest && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowEmailTest(false)}>
<div style={{
background: '#fff', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
<SVGIcon size={20}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}发送测试邮件
</h3>
<div className="admin-form-group">
<label className="admin-form-label">收件邮箱 <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input
className="admin-form-input"
type="email"
value={emailTestTo}
onChange={e => setEmailTestTo(e.target.value)}
placeholder="例如: admin@example.com"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件主题</label>
<input
className="admin-form-input"
value={emailTestSubject}
onChange={e => setEmailTestSubject(e.target.value)}
placeholder="测试邮件标题"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件内容</label>
<textarea
className="admin-form-input admin-form-textarea"
value={emailTestBody}
onChange={e => setEmailTestBody(e.target.value)}
style={{ minHeight: 120 }}
placeholder="测试邮件内容..."
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={handleAIGenerateContent}
disabled={emailGenerating}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
</button>
</div>
<div className="admin-form-actions">
<button
className="admin-btn admin-btn-primary"
onClick={sendTestEmail}
disabled={!emailTestTo.trim()}
>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>
取消
</button>
</div>
</div>
</div>
)}
{/* Test Result Toast */}
{testResult && (
<div style={{
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
color: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 9999, fontSize: 'var(--text-sm)',
}}>
{testResult}
</div>
)}
</div>
);
};
export default SiteSettings;
+584
View File
@@ -0,0 +1,584 @@
/* ===== Admin Users Page ===== */
.admin-users-page {
display: flex;
flex-direction: column;
gap: var(--space-2xl);
animation: fadeIn var(--transition-normal);
}
.admin-page-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
/* ===== Card ===== */
.admin-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
}
.admin-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-xl);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border-light);
}
.admin-card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.admin-card-count {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
background: var(--color-bg-tertiary);
padding: 4px 12px;
border-radius: var(--radius-full);
}
.admin-card-subtitle {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* ===== Toolbar ===== */
.users-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
flex-wrap: wrap;
}
.users-search {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
min-width: 300px;
}
.users-search svg {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.users-search-input {
border: none;
outline: none;
background: transparent;
font-size: var(--text-sm);
color: var(--color-text-primary);
width: 100%;
}
.users-search-input::placeholder {
color: var(--color-text-disabled);
}
.users-filters {
display: flex;
gap: var(--space-sm);
}
.users-filter-select {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-xs);
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
outline: none;
cursor: pointer;
transition: border-color var(--transition-fast);
}
.users-filter-select:focus {
border-color: var(--color-accent);
}
/* ===== Table ===== */
.users-table-wrap {
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table thead {
background: var(--color-bg-tertiary);
}
.users-table th {
padding: var(--space-md) var(--space-lg);
text-align: left;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.users-table td {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-sm);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
white-space: nowrap;
}
.users-table tbody tr {
transition: background var(--transition-fast);
}
.users-table tbody tr:hover {
background: var(--color-bg-tertiary);
}
.users-cell-user {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-text-primary);
font-weight: 500;
}
.users-cell-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 700;
}
.users-cell-mono {
font-family: var(--font-mono);
font-size: var(--text-xs) !important;
}
.users-cell-date {
font-size: var(--text-xs) !important;
color: var(--color-text-tertiary) !important;
}
.users-cell-actions {
display: flex;
gap: var(--space-xs);
}
.users-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text-tertiary);
cursor: pointer;
transition: all var(--transition-fast);
}
.users-action-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.users-action-btn.danger:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.users-action-btn.success:hover {
border-color: var(--color-success);
color: var(--color-success);
}
/* Badges */
.users-badge {
display: inline-flex;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.users-level-badge {
display: inline-flex;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.users-empty {
text-align: center;
padding: var(--space-3xl);
color: var(--color-text-disabled);
font-size: var(--text-sm);
}
/* ===== User Detail Modal ===== */
.admin-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: fadeIn var(--transition-fast);
}
.admin-modal {
background: var(--color-bg-secondary);
border-radius: var(--radius-xl);
width: 100%;
max-width: 560px;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
animation: slideUp var(--transition-normal);
}
.admin-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xl) var(--space-2xl);
border-bottom: 1px solid var(--color-border-light);
}
.admin-modal-header h2 {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.admin-modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--color-text-tertiary);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-modal-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.admin-modal-body {
padding: var(--space-2xl);
}
.user-detail-top {
display: flex;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-2xl);
}
.user-detail-avatar {
width: 56px;
height: 56px;
border-radius: var(--radius-xl);
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
font-weight: 700;
flex-shrink: 0;
}
.user-detail-basic h3 {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.user-detail-tags {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.user-detail-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.user-detail-tag.role-tag {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.user-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
}
.user-detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.detail-value {
font-size: var(--text-sm);
color: var(--color-text-primary);
font-weight: 500;
}
/* ===== Register Settings ===== */
.register-settings {
display: flex;
flex-direction: column;
gap: var(--space-2xl);
}
.register-settings-section {
padding: var(--space-xl);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.register-section-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-md);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border-light);
}
.register-section-title svg {
color: var(--color-accent);
}
.register-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) 0;
}
.register-setting-row + .register-setting-row {
border-top: 1px solid var(--color-border-light);
}
.register-setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.register-setting-info strong {
font-size: var(--text-sm);
color: var(--color-text-primary);
}
.register-setting-info span {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-border);
border-radius: 24px;
transition: background var(--transition-fast);
}
.toggle-slider::before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform var(--transition-fast);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--color-accent);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}
.toggle-switch input:disabled + .toggle-slider {
opacity: 0.4;
cursor: not-allowed;
}
/* Aliyun Config */
.aliyun-config {
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--color-bg-primary);
border-radius: var(--radius-md);
border: 1px dashed var(--color-border);
}
.aliyun-config-header {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: var(--space-lg);
}
.aliyun-config-header svg {
color: var(--color-accent);
}
.aliyun-config-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
.aliyun-config-grid .form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.aliyun-config-grid .form-group label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-weight: 500;
}
.users-form-input {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-bg-secondary);
outline: none;
transition: border-color var(--transition-fast);
}
.users-form-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.users-form-input::placeholder {
color: var(--color-text-disabled);
font-size: var(--text-xs);
}
/* Save Button */
.users-save-btn {
align-self: flex-end;
padding: var(--space-md) var(--space-2xl);
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.users-save-btn:hover {
background: var(--color-accent-hover);
}
@media (max-width: 768px) {
.users-toolbar {
flex-direction: column;
align-items: stretch;
}
.users-search {
min-width: auto;
}
.users-filters {
flex-wrap: wrap;
}
.user-detail-grid {
grid-template-columns: 1fr;
}
.aliyun-config-grid {
grid-template-columns: 1fr;
}
}
+561
View File
@@ -0,0 +1,561 @@
import React, { useState, useMemo, useEffect } from 'react';
import { adminAPI } from '@/api';
import { mockUsers, mockMembershipPlans } from '@/mock/data';
import type { User, RegisterSettings } from '@/types';
import './Users.css';
// ===== SVG Icons =====
const IconSearch = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
);
const IconEye = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
const IconCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const IconX = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const IconShield = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const IconPhone = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
);
const IconMail = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
);
const IconServer = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
);
const IconClose = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
// ===== Helpers =====
const realNameStatusMap: Record<string, { label: string; color: string; bg: string }> = {
verified: { label: '已实名', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
pending: { label: '审核中', color: 'var(--color-warning)', bg: 'var(--color-warning-light)' },
unverified: { label: '未实名', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
};
const statusMap: Record<string, { label: string; color: string; bg: string }> = {
active: { label: '正常', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
banned: { label: '已封禁', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
};
// ===== User Detail Modal =====
interface UserDetailModalProps {
user: User;
onClose: () => void;
}
const UserDetailModal: React.FC<UserDetailModalProps> = ({ user, onClose }) => {
const plan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
const rn = realNameStatusMap[user.realNameStatus || 'unverified'];
return (
<div className="admin-modal-overlay" onClick={onClose}>
<div className="admin-modal users-detail-modal" onClick={e => e.stopPropagation()}>
<div className="admin-modal-header">
<h2>用户详情</h2>
<button className="admin-modal-close" onClick={onClose}><IconClose /></button>
</div>
<div className="admin-modal-body">
<div className="user-detail-top">
<div className="user-detail-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-detail-basic">
<h3>{user.username}</h3>
<div className="user-detail-tags">
<span className="user-detail-tag" style={{ color: rn.color, background: rn.bg }}>
<IconShield />
{rn.label}
</span>
<span className="user-detail-tag" style={{ color: statusMap[user.status].color, background: statusMap[user.status].bg }}>
{statusMap[user.status].label}
</span>
<span className="user-detail-tag role-tag">
{user.role === 'admin' ? '管理员' : '普通用户'}
</span>
</div>
</div>
</div>
<div className="user-detail-grid">
<div className="user-detail-item">
<span className="detail-label">用户ID</span>
<span className="detail-value">{user.id}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">邮箱</span>
<span className="detail-value">{user.email}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">手机号</span>
<span className="detail-value">{user.phone || '未绑定'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">实名姓名</span>
<span className="detail-value">{user.realName || '--'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">实名状态</span>
<span className="detail-value" style={{ color: rn.color, fontWeight: 500 }}>{rn.label}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">会员等级</span>
<span className="detail-value">{user.membership?.planName || '免费版'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">会员到期</span>
<span className="detail-value">{user.membership?.expireAt ? new Date(user.membership.expireAt).toLocaleDateString('zh-CN') : '--'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">注册时间</span>
<span className="detail-value">{new Date(user.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
</div>
</div>
</div>
);
};
// ===== Main Component =====
const UsersPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [levelFilter, setLevelFilter] = useState<string>('all');
const [realNameFilter, setRealNameFilter] = useState<string>('all');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [localUsers, setLocalUsers] = useState<User[]>(mockUsers);
// Register settings state
const [registerSettings, setRegisterSettings] = useState<RegisterSettings>({
phoneRequired: true,
phoneSmsRequired: true,
emailRequired: true,
emailVerifyRequired: true,
realNameRequired: false,
aliyunAccessKey: '',
aliyunSecretKey: '',
aliyunEndpoint: 'https://cloudauth.aliyuncs.com',
});
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsMsg, setSettingsMsg] = useState<{ type: string; text: string } | null>(null);
// 加载注册设置
useEffect(() => {
loadRegisterSettings();
}, []);
const loadRegisterSettings = async () => {
try {
const res = await adminAPI.getRegisterSettings();
const data = res?.data || res;
if (data && typeof data === 'object') {
setRegisterSettings({
phoneRequired: data.phone_required !== undefined ? data.phone_required : true,
phoneSmsRequired: data.phone_sms_required !== undefined ? data.phone_sms_required : true,
emailRequired: data.email_required !== undefined ? data.email_required : true,
emailVerifyRequired: data.email_verify_required !== undefined ? data.email_verify_required : true,
realNameRequired: data.real_name_required || false,
aliyunAccessKey: data.aliyun_access_key || '',
aliyunSecretKey: data.aliyun_secret_key || '',
aliyunEndpoint: data.aliyun_endpoint || 'https://dysmsapi.aliyuncs.com',
smsSignName: data.sms_sign_name || '',
smsTemplateCode: data.sms_template_code || '',
});
}
} catch (e) {
console.error('加载注册设置失败', e);
}
};
const filteredUsers = useMemo(() => {
let result = localUsers.filter(u => u.role === 'user');
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(u =>
u.username.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.phone && u.phone.includes(q)) ||
(u.realName && u.realName.includes(q))
);
}
if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
if (levelFilter !== 'all') result = result.filter(u => (u.membership?.level ?? 0).toString() === levelFilter);
if (realNameFilter !== 'all') result = result.filter(u => u.realNameStatus === realNameFilter);
return result;
}, [localUsers, searchQuery, statusFilter, levelFilter, realNameFilter]);
const handleToggleSetting = (key: keyof RegisterSettings) => {
setRegisterSettings(prev => ({
...prev,
[key]: !prev[key as keyof RegisterSettings],
}));
};
const handleSettingChange = (key: keyof RegisterSettings, value: string) => {
setRegisterSettings(prev => ({ ...prev, [key]: value }));
};
const handleSaveRegisterSettings = async () => {
setSettingsSaving(true);
setSettingsMsg(null);
try {
await adminAPI.updateRegisterSettings({
phone_required: registerSettings.phoneRequired,
phone_sms_required: registerSettings.phoneSmsRequired,
email_required: registerSettings.emailRequired,
email_verify_required: registerSettings.emailVerifyRequired,
real_name_required: registerSettings.realNameRequired,
aliyun_access_key: registerSettings.aliyunAccessKey,
aliyun_secret_key: registerSettings.aliyunSecretKey,
aliyun_endpoint: registerSettings.aliyunEndpoint,
sms_sign_name: registerSettings.smsSignName,
sms_template_code: registerSettings.smsTemplateCode,
});
setSettingsMsg({ type: 'success', text: '注册设置已保存' });
setTimeout(() => setSettingsMsg(null), 3000);
} catch (e: any) {
setSettingsMsg({ type: 'error', text: e?.message || e?.detail || '保存失败' });
} finally {
setSettingsSaving(false);
}
};
const handleToggleUserStatus = (userId: string) => {
setLocalUsers(prev =>
prev.map(u => u.id === userId ? {
...u,
status: u.status === 'active' ? 'banned' as const : 'active' as const,
} : u)
);
};
return (
<div className="admin-users-page">
<h1 className="admin-page-title">用户管理</h1>
{/* ===== 用户列表 ===== */}
<div className="admin-card">
<div className="admin-card-header">
<h2 className="admin-card-title">用户列表</h2>
<span className="admin-card-count">共 {filteredUsers.length} 个用户</span>
</div>
{/* 搜索和筛选 */}
<div className="users-toolbar">
<div className="users-search">
<IconSearch />
<input
type="text"
className="users-search-input"
placeholder="搜索用户名、邮箱、手机号..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="users-filters">
<select className="users-filter-select" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="all">全部状态</option>
<option value="active">正常</option>
<option value="banned">已封禁</option>
</select>
<select className="users-filter-select" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
<option value="all">全部等级</option>
<option value="0">免费版</option>
<option value="2">专业版</option>
<option value="3">企业版</option>
</select>
<select className="users-filter-select" value={realNameFilter} onChange={e => setRealNameFilter(e.target.value)}>
<option value="all">全部实名</option>
<option value="verified">已实名</option>
<option value="pending">审核中</option>
<option value="unverified">未实名</option>
</select>
</div>
</div>
{/* 用户表格 */}
<div className="users-table-wrap">
<table className="users-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>手机号</th>
<th>实名状态</th>
<th>会员等级</th>
<th>注册时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(u => {
const rn = realNameStatusMap[u.realNameStatus || 'unverified'];
const st = statusMap[u.status || 'active'];
return (
<tr key={u.id}>
<td>
<div className="users-cell-user">
<div className="users-cell-avatar">{u.username.charAt(0).toUpperCase()}</div>
<span>{u.username}</span>
</div>
</td>
<td className="users-cell-mono">{u.email}</td>
<td>{u.phone || '--'}</td>
<td>
<span className="users-badge" style={{ color: rn.color, background: rn.bg }}>
{rn.label}
</span>
</td>
<td>
<span className="users-level-badge">
{u.membership?.planName || '免费版'}
</span>
</td>
<td className="users-cell-date">{new Date(u.createdAt).toLocaleDateString('zh-CN')}</td>
<td>
<span className="users-badge" style={{ color: st.color, background: st.bg }}>
{st.label}
</span>
</td>
<td>
<div className="users-cell-actions">
<button className="users-action-btn" title="查看详情" onClick={() => setSelectedUser(u)}>
<IconEye />
</button>
<button
className={`users-action-btn ${u.status === 'active' ? 'danger' : 'success'}`}
title={u.status === 'active' ? '封禁' : '解封'}
onClick={() => handleToggleUserStatus(u.id)}
>
{u.status === 'active' ? <IconX /> : <IconCheck />}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="users-empty">无匹配的用户记录</div>
)}
</div>
</div>
{/* ===== 注册用户设置 ===== */}
<div className="admin-card" style={{ marginTop: 'var(--space-2xl)' }}>
<div className="admin-card-header">
<h2 className="admin-card-title">注册用户设置</h2>
<span className="admin-card-subtitle">配置用户注册方式与认证策略</span>
</div>
<div className="register-settings">
{/* 手机号注册 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconPhone />
手机号注册
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>手机号必填</strong>
<span>要求用户注册时填写手机号码</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.phoneRequired}
onChange={() => handleToggleSetting('phoneRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>短信验证码激活</strong>
<span>注册时发送短信验证码验证手机号</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.phoneSmsRequired}
onChange={() => handleToggleSetting('phoneSmsRequired')}
disabled={!registerSettings.phoneRequired}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{/* 邮箱注册 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconMail />
邮箱注册
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>邮箱必填</strong>
<span>要求用户注册时填写邮箱地址</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.emailRequired}
onChange={() => handleToggleSetting('emailRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>邮箱认证激活</strong>
<span>发送验证邮件到用户邮箱完成激活</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.emailVerifyRequired}
onChange={() => handleToggleSetting('emailVerifyRequired')}
disabled={!registerSettings.emailRequired}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{/* 实名认证 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconShield />
实名认证
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>强制实名认证</strong>
<span>要求用户完成实名认证后方可使用全部功能</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.realNameRequired}
onChange={() => handleToggleSetting('realNameRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="aliyun-config">
<div className="aliyun-config-header">
<IconServer />
<span>阿里云短信API配置(用于实名认证短信验证)</span>
</div>
<div className="aliyun-config-grid">
<div className="form-group">
<label>AccessKey ID</label>
<input
type="text"
className="users-form-input"
placeholder="请输入阿里云AccessKey ID"
value={registerSettings.aliyunAccessKey || ''}
onChange={e => handleSettingChange('aliyunAccessKey', e.target.value)}
/>
</div>
<div className="form-group">
<label>AccessKey Secret</label>
<input
type="password"
className="users-form-input"
placeholder="请输入阿里云AccessKey Secret"
value={registerSettings.aliyunSecretKey || ''}
onChange={e => handleSettingChange('aliyunSecretKey', e.target.value)}
/>
</div>
<div className="form-group">
<label>短信签名 (SignName)</label>
<input
type="text"
className="users-form-input"
placeholder="阿里云短信控制台中审核通过的签名"
value={registerSettings.smsSignName || ''}
onChange={e => handleSettingChange('smsSignName', e.target.value)}
/>
</div>
<div className="form-group">
<label>短信模板 (TemplateCode)</label>
<input
type="text"
className="users-form-input"
placeholder="SMS_XXXXXXXXXX"
value={registerSettings.smsTemplateCode || ''}
onChange={e => handleSettingChange('smsTemplateCode', e.target.value)}
/>
</div>
</div>
</div>
</div>
{settingsMsg && (
<div style={{
padding: 'var(--space-md) var(--space-lg)',
marginBottom: 'var(--space-lg)',
borderRadius: 'var(--radius-md)',
background: settingsMsg.type === 'success' ? 'var(--color-success-light)' : 'var(--color-error-light)',
color: settingsMsg.type === 'success' ? 'var(--color-success)' : 'var(--color-error)',
fontWeight: 500,
}}>
{settingsMsg.text}
</div>
)}
<button className="users-save-btn" onClick={handleSaveRegisterSettings} disabled={settingsSaving}>
{settingsSaving ? '保存中...' : '保存设置'}
</button>
</div>
</div>
{/* User Detail Modal */}
{selectedUser && (
<UserDetailModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
/>
)}
</div>
);
};
export default UsersPage;
+666
View File
@@ -0,0 +1,666 @@
/* ===== Assets Page ===== */
.assets-page {
height: 100%;
overflow-y: auto;
padding: var(--space-3xl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.assets-header {
display: flex;
align-items: baseline;
gap: var(--space-md);
}
.assets-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.assets-count {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
.assets-create-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: var(--space-sm) var(--space-lg);
background: var(--color-accent);
color: #FFFFFF;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.assets-create-btn:hover {
background: var(--color-accent-hover);
}
/* Tabs */
.assets-tabs {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.assets-tab {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.assets-tab:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.assets-tab.active {
background: var(--tab-bg, var(--color-accent-light));
color: var(--tab-color, var(--color-accent));
border-color: var(--tab-color, var(--color-accent));
font-weight: 600;
}
/* Sub-tabs */
.assets-subtabs {
display: flex;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.assets-subtab {
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
}
.assets-subtab:hover { color: var(--color-text-primary); }
.assets-subtab.active {
background: var(--color-accent);
color: #FFFFFF;
}
/* Assets List */
.assets-list {
flex: 1;
}
.assets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
}
.novel-grid {
grid-template-columns: repeat(2, 1fr);
}
/* File Card */
.asset-card {
display: flex;
gap: var(--space-lg);
padding: var(--space-xl);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
transition: all var(--transition-fast);
cursor: pointer;
}
.asset-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-hover);
}
.asset-card-icon {
flex-shrink: 0;
}
.asset-file-type {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 28px;
padding: 0 10px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
}
.asset-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.asset-card-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-card-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.asset-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: var(--space-sm);
}
.asset-card-size, .asset-card-date {
font-size: 11px;
color: var(--color-text-disabled);
}
/* Novel Card */
.asset-novel-card {
display: flex;
gap: var(--space-xl);
padding: var(--space-2xl);
background: linear-gradient(135deg, #FFFBEB 0%, #FFF7ED 50%, #FFFBF0 100%);
border: 1px solid #FDE68A;
border-radius: var(--radius-xl);
cursor: pointer;
transition: all var(--transition-normal);
}
.asset-novel-card:hover {
border-color: #F59E0B;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.novel-card-cover {
width: 80px;
height: 110px;
background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
border: 1px solid #FED7AA;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.novel-card-badge {
font-size: 10px;
font-weight: 600;
color: #D97706;
background: #FEF3C7;
padding: 1px 8px;
border-radius: var(--radius-full);
}
.novel-card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.novel-card-title {
font-size: var(--text-lg);
font-weight: 700;
color: #92400E;
}
.novel-card-desc {
font-size: var(--text-sm);
color: #A16207;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.novel-card-meta {
font-size: var(--text-xs);
color: #B45309;
display: flex;
gap: var(--space-sm);
margin-top: auto;
}
/* Empty */
.assets-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5xl);
color: var(--color-text-disabled);
font-size: var(--text-sm);
gap: var(--space-md);
}
/* Loading */
.assets-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
gap: var(--space-lg);
}
.audit-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-light);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Assets Modal ===== */
.assets-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: fadeIn var(--transition-fast);
padding: var(--space-2xl);
}
.assets-modal {
background: var(--color-bg-secondary);
border-radius: var(--radius-2xl);
width: 100%;
max-width: 680px;
max-height: 85vh;
overflow-y: auto;
position: relative;
animation: slideUp var(--transition-normal);
box-shadow: var(--shadow-xl);
}
.assets-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xl) var(--space-2xl);
border-bottom: 1px solid var(--color-border-light);
}
.assets-modal-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.assets-modal-close {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
}
.assets-modal-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.assets-modal-body {
padding: var(--space-2xl);
}
.assets-modal-meta {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-xl);
padding: var(--space-md);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
}
.assets-modal-type {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.assets-modal-cat {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.assets-modal-size, .assets-modal-date {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* Text Preview */
.assets-preview-text {
margin-bottom: var(--space-xl);
max-height: 400px;
overflow-y: auto;
}
.assets-preview-code {
padding: var(--space-lg);
background: #1e1e2e;
color: #cdd6f4;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
/* Image Preview */
.assets-preview-image {
margin-bottom: var(--space-xl);
text-align: center;
}
.assets-preview-image img {
max-width: 100%;
max-height: 400px;
border-radius: var(--radius-md);
object-fit: contain;
}
/* Video Preview */
.assets-preview-video {
margin-bottom: var(--space-xl);
}
.assets-preview-video video {
width: 100%;
max-height: 400px;
border-radius: var(--radius-md);
background: #000;
}
/* Audio Preview */
.assets-preview-audio {
margin-bottom: var(--space-xl);
padding: var(--space-xl);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
text-align: center;
}
.assets-preview-audio audio {
width: 100%;
max-width: 500px;
}
/* Edit Section */
.assets-edit-section {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.assets-edit-textarea {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--color-text-primary);
background: var(--color-bg-primary);
resize: vertical;
min-height: 200px;
outline: none;
transition: border-color var(--transition-fast);
}
.assets-edit-textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.assets-edit-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
/* Modal Actions */
.assets-modal-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
/* Assets Buttons */
.assets-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
transition: all var(--transition-fast);
cursor: pointer;
}
.assets-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
border: none;
}
.assets-btn-primary:hover {
background: var(--color-accent-hover);
}
.assets-btn-secondary {
background: var(--color-bg-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.assets-btn-secondary:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.assets-btn-cancel {
background: transparent;
color: var(--color-text-tertiary);
border: 1px solid var(--color-border);
}
.assets-btn-cancel:hover {
color: var(--color-text-primary);
}
.assets-btn-danger {
background: transparent;
color: var(--color-error);
border: 1px solid var(--color-error);
}
.assets-btn-danger:hover {
background: var(--color-error-light);
}
/* Create Asset Modal */
.assets-create-modal {
max-width: 500px;
}
.assets-create-modal .form-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.form-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.form-label .required {
color: var(--color-error);
}
.form-input,
.form-textarea {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-bg-primary);
outline: none;
transition: border-color var(--transition-fast);
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.form-textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
}
.form-submit-btn {
width: 100%;
padding: var(--space-md) var(--space-xl);
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.form-submit-btn:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.form-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Online Preview */
.assets-preview-online {
margin: var(--space-md) 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.assets-preview-online-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-secondary);
}
.assets-preview-reader {
background: var(--color-bg-secondary);
max-height: 500px;
overflow: auto;
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.assets-kb-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: #EEEDFF;
color: #6C63FF;
border-radius: var(--radius-xs);
font-size: 11px;
font-weight: 600;
margin-left: var(--space-sm);
}
/* Responsive */
@media (max-width: 768px) {
.assets-page { padding: var(--space-lg); }
.novel-grid { grid-template-columns: 1fr; }
.assets-grid { grid-template-columns: 1fr; }
.asset-novel-card { flex-direction: column; }
.novel-card-cover { width: 100%; height: 60px; flex-direction: row; }
.assets-modal { max-width: 100%; max-height: 90vh; border-radius: var(--radius-xl); }
.assets-modal-body { padding: var(--space-xl); }
}
+650
View File
@@ -0,0 +1,650 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Asset, AssetCategory, AssetCategoryDef, AssetFileType } from '@/types';
import { assetsAPI } from '@/api';
import './AssetsPage.css';
const fileTypeLabels: Record<string, { label: string; bg: string; color: string }> = {
word: { label: 'Word', bg: '#E8F1FF', color: '#2563EB' },
excel: { label: 'Excel', bg: '#E8F8EE', color: '#059669' },
ppt: { label: 'PPT', bg: '#FEF2E2', color: '#D97706' },
pdf: { label: 'PDF', bg: '#FDEBEB', color: '#DC2626' },
txt: { label: 'TXT', bg: '#F1F3F7', color: '#64748B' },
md: { label: 'MD', bg: '#E8EEF8', color: '#3B82F6' },
doc: { label: 'DOC', bg: '#E6F4EA', color: '#0D9488' },
other: { label: '其他', bg: '#F1F3F7', color: '#64748B' },
};
// SVG Icons
const CloseIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const DownloadIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
);
const TrashIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
);
const getFileTypeInfo = (ft?: string) => {
return fileTypeLabels[ft || 'other'] || fileTypeLabels.other;
};
function formatSize(bytes?: number): string {
if (!bytes) return '--';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 将文件扩展名或API返回的file_type映射到前端fileType标签
function mapFileTypeToLabel(ft: string): string {
const mapping: Record<string, string> = {
pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel',
pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt',
};
return mapping[ft] || 'other';
}
// Preview Modal Component
interface PreviewModalProps {
asset: Asset;
onClose: () => void;
onDelete: (id: string) => void;
}
const PreviewModal: React.FC<PreviewModalProps> = ({ asset, onClose, onDelete }) => {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState('');
const [showOnlinePreview, setShowOnlinePreview] = useState(false);
const navigate = useNavigate();
const isTextFile = asset.category === 'file' && ['txt', 'other', 'md', 'doc'].includes(asset.fileType || '');
const isImage = asset.category === 'image';
const isVideo = asset.category === 'video';
const isAudio = asset.category === 'audio';
const isTextEditable = asset.category === 'file' && (asset.fileType === 'txt' || asset.fileType === 'other' || asset.fileType === 'md' || asset.fileType === 'doc');
const isOfficeDoc = asset.category === 'file' && ['word', 'excel', 'ppt'].includes(asset.fileType || '');
const isPdf = asset.category === 'file' && asset.fileType === 'pdf';
const handleDownload = () => {
// Simulate download - in production would use asset.url
const a = document.createElement('a');
a.href = '#' + asset.id;
a.download = asset.name;
a.click();
};
const handleStartEdit = () => {
setEditContent(asset.description || '');
setEditing(true);
};
const handleSaveEdit = () => {
// In production, would call assetsAPI.updateAsset
setEditing(false);
};
// 在线预览内容渲染
const renderOnlinePreview = () => {
if (!showOnlinePreview) return null;
const ft = asset.fileType || 'other';
// PDF 使用浏览器原生预览
if (ft === 'pdf' && asset.url) {
return (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<iframe
src={asset.url}
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
title={`预览 ${asset.name}`}
/>
</div>
);
}
// Office文档(docx/xlsx/pptx) 使用微软Office Online查看器
if (asset.url && ['word', 'excel', 'ppt'].includes(ft)) {
const encodedUrl = encodeURIComponent(asset.url);
const officeViewerUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodedUrl}`;
return (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<iframe
src={officeViewerUrl}
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
title={`预览 ${asset.name}`}
/>
</div>
);
}
// 文本类文件的在线预览
if (['txt', 'word', 'other', 'md', 'doc'].includes(ft)) {
const previewContent = (asset as any).content || asset.description || '(文件内容预览)\n\n该文件暂无内容预览。\n\n--- 在线预览模式 ---\n您正在查看文件的只读预览。';
return (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<div className="assets-preview-reader">
<pre style={{whiteSpace:'pre-wrap',fontFamily:'var(--font-mono)',fontSize:'var(--text-sm)',lineHeight:1.8,padding:'var(--space-xl)',color:'var(--color-text-primary)'}}>
{previewContent}
</pre>
</div>
</div>
);
}
return null;
};
return (
<div className="assets-modal-overlay" onClick={onClose}>
<div className="assets-modal" onClick={e => e.stopPropagation()}>
<div className="assets-modal-header">
<h3 className="assets-modal-title">{asset.name}</h3>
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
</div>
<div className="assets-modal-body">
<div className="assets-modal-meta">
<span className="assets-modal-type">
{asset.category === 'file' && (
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
{getFileTypeInfo(asset.fileType).label}
</span>
)}
<span className="assets-modal-cat">{asset.category === 'image' ? '图片' : asset.category === 'video' ? '视频' : asset.category === 'audio' ? '音频' : asset.category === 'novel' ? '小说' : '文件'}</span>
</span>
{asset.size && <span className="assets-modal-size">{formatSize(asset.size)}</span>}
<span className="assets-modal-date">{formatDate(asset.updatedAt)}</span>
</div>
{/* Text preview */}
{isTextFile && !editing && !showOnlinePreview && (
<div className="assets-preview-text">
<pre className="assets-preview-code">
{(asset as any).content || asset.description || '(文件内容预览)\n\n这是一个文本类型的文件。\n内容由后端API提供。'}
</pre>
</div>
)}
{/* Text editing */}
{isTextEditable && editing && (
<div className="assets-edit-section">
<textarea
className="assets-edit-textarea"
value={editContent}
onChange={e => setEditContent(e.target.value)}
rows={12}
/>
<div className="assets-edit-actions">
<button className="assets-btn assets-btn-primary" onClick={handleSaveEdit}>保存</button>
<button className="assets-btn assets-btn-cancel" onClick={() => setEditing(false)}>取消</button>
</div>
</div>
)}
{/* Image preview */}
{isImage && !showOnlinePreview && (
<div className="assets-preview-image">
<img src={asset.url || ''} alt={asset.name} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
{!asset.url && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-tertiary)' }}>
无法加载图片预览
</div>
)}
</div>
)}
{/* Image online preview */}
{isImage && showOnlinePreview && (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<img src={asset.url || ''} alt={asset.name} style={{maxWidth:'100%',maxHeight:'500px',objectFit:'contain'}}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
)}
{/* Video preview */}
{isVideo && !showOnlinePreview && (
<div className="assets-preview-video">
<video controls src={asset.url || ''}>
您的浏览器不支持视频播放
</video>
</div>
)}
{/* Audio preview */}
{isAudio && !showOnlinePreview && (
<div className="assets-preview-audio">
<audio controls src={asset.url || ''}>
您的浏览器不支持音频播放
</audio>
</div>
)}
{/* Online Preview */}
{renderOnlinePreview()}
{/* Action buttons */}
<div className="assets-modal-actions">
{isTextFile && !editing && isTextEditable && (
<button className="assets-btn assets-btn-secondary" onClick={handleStartEdit}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
编辑
</button>
)}
{!editing && (
<button className="assets-btn assets-btn-secondary" onClick={handleDownload}>
<DownloadIcon />
下载
</button>
)}
{(isTextFile || isImage || isOfficeDoc || isPdf) && !editing && (
<button
className="assets-btn assets-btn-secondary"
onClick={() => setShowOnlinePreview(!showOnlinePreview)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{showOnlinePreview ? '关闭预览' : '在线预览'}
</button>
)}
{asset.category === 'novel' && (
<button className="assets-btn assets-btn-primary" onClick={() => navigate(`/assets/novel/${asset.id}`)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
阅读
</button>
)}
<button className="assets-btn assets-btn-danger" onClick={() => { if (window.confirm('确定删除该资产?')) { onDelete(asset.id); onClose(); } }}>
<TrashIcon />
删除
</button>
</div>
</div>
</div>
</div>
);
};
// Create Asset Modal
interface CreateAssetModalProps {
onClose: () => void;
onCreated: () => void;
}
const CreateAssetModal: React.FC<CreateAssetModalProps> = ({ onClose, onCreated }) => {
const [name, setName] = useState('');
const [fileType, setFileType] = useState('txt');
const [content, setContent] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
if (!name.trim()) return;
setCreating(true);
try {
await assetsAPI.createAsset({
name: name.trim(),
category: 'file',
file_type: fileType,
content: content,
description: content.slice(0, 100),
});
onCreated();
onClose();
} catch (err) {
console.error('创建失败', err);
} finally {
setCreating(false);
}
};
return (
<div className="assets-modal-overlay" onClick={onClose}>
<div className="assets-modal assets-create-modal" onClick={e => e.stopPropagation()}>
<div className="assets-modal-header">
<h3 className="assets-modal-title">创建资产</h3>
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
</div>
<div className="assets-modal-body">
<div className="form-section">
<label className="form-label">名称 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="请输入资产名称"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">文件类型</label>
<select className="form-input" value={fileType} onChange={e => setFileType(e.target.value)}>
<option value="txt">TXT</option>
<option value="word">Word</option>
<option value="excel">Excel</option>
<option value="ppt">PPT</option>
<option value="pdf">PDF</option>
<option value="other">其他</option>
</select>
</div>
<div className="form-section">
<label className="form-label">内容</label>
<textarea
className="form-textarea"
placeholder="输入文件内容..."
value={content}
onChange={e => setContent(e.target.value)}
rows={6}
/>
</div>
<button
className="form-submit-btn"
onClick={handleCreate}
disabled={!name.trim() || creating}
>
{creating ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
);
};
// ===== Main AssetsPage Component =====
const AssetsPage: React.FC = () => {
const [activeCategory, setActiveCategory] = useState<string>('all');
const [subCategory, setSubCategory] = useState<string>('all');
const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<AssetCategoryDef[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
const [showCreate, setShowCreate] = useState(false);
const navigate = useNavigate();
const fetchAssets = async (category?: string) => {
setLoading(true);
setError(null);
try {
const catParam = category && category !== 'all' ? category : undefined;
const res = await assetsAPI.getAssets(catParam);
// getAssets 返回分页格式 {total, page, page_size, items}
const rawItems = (res.data as any)?.items || res.data || [];
// 标准化资产数据:如果category为空但有fileType,则自动归类为file
const normalizedAssets: Asset[] = rawItems.map((item: any) => ({
...item,
category: item.category || (item.fileType || item.file_type ? 'file' : 'other'),
fileType: item.fileType || item.file_type || 'other',
}));
// 按id去重
const seen = new Set<string>();
const deduped = normalizedAssets.filter(a => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
});
setAssets(deduped);
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
};
const DEFAULT_CATEGORIES: AssetCategoryDef[] = [
{ id: 'file', name: '文件', type: 'file', color: '#5B5FED', icon: 'file', isDefault: true },
{ id: 'image', name: '图片', type: 'image', color: '#10B981', icon: 'image', isDefault: true },
{ id: 'video', name: '视频', type: 'video', color: '#EF4444', icon: 'video', isDefault: true },
{ id: 'audio', name: '音频', type: 'audio', color: '#8B5CF6', icon: 'audio', isDefault: true },
{ id: 'novel', name: '小说', type: 'novel', color: '#F59E0B', icon: 'novel', isDefault: true },
{ id: 'other', name: '其他', type: 'other', color: '#64748B', icon: 'other', isDefault: true },
{ id: 'custom', name: '自定义', type: 'custom', color: '#EC4899', icon: 'custom', isDefault: true },
];
const fetchCategories = async () => {
try {
const res = await assetsAPI.getCategories();
const apiCats = res.data || [];
const merged: AssetCategoryDef[] = [...apiCats];
for (const def of DEFAULT_CATEGORIES) {
if (!merged.some((c: any) => c.type === def.type)) {
merged.push(def);
}
}
setCategories(merged);
} catch (err) {
console.error('分类加载失败', err);
setCategories(DEFAULT_CATEGORIES);
}
};
useEffect(() => {
fetchCategories();
fetchAssets();
}, []);
useEffect(() => {
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
}, [activeCategory]);
// Filter by sub-category for files
const filtered = (() => {
// 如果是文件分类,先确保只显示文件类资产
let base = activeCategory === 'all' ? assets : assets.filter(a => a.category === activeCategory);
// 在文件分类下,按子分类再筛选
if (activeCategory === 'file' && subCategory !== 'all') {
base = base.filter(a => a.fileType === subCategory);
}
return base;
})();
const handleDelete = async (id: string) => {
try {
await assetsAPI.deleteAsset(id);
setAssets(prev => prev.filter(a => a.id !== id));
} catch (err) {
console.error('删除失败', err);
}
};
const showFileSubTabs = activeCategory === 'file';
return (
<div className="assets-page">
<div className="assets-header">
<h1 className="assets-title">我的资产</h1>
<span className="assets-count">{filtered.length} 项</span>
<button className="assets-create-btn" onClick={() => setShowCreate(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
创建
</button>
</div>
{/* Category Tabs */}
<div className="assets-tabs">
<button className={`assets-tab ${activeCategory === 'all' ? 'active' : ''}`} onClick={() => { setActiveCategory('all'); setSubCategory('all'); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
全部
</button>
{categories.length > 0 ? categories.map(cat => (
<button
key={cat.id}
className={`assets-tab ${activeCategory === cat.type ? 'active' : ''}`}
onClick={() => { setActiveCategory(cat.type); setSubCategory('all'); }}
style={activeCategory === cat.type ? { '--tab-color': cat.color, '--tab-bg': cat.color + '18' } as React.CSSProperties : undefined}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{cat.type === 'file' && <><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></>}
{cat.type === 'novel' && <><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></>}
{cat.type === 'image' && <><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></>}
{cat.type === 'video' && <><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></>}
{cat.type === 'audio' && <><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></>}
{cat.type === 'other' && <><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></>}
{cat.type === 'custom' && <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></>}
</svg>
{cat.name}
</button>
)) : (
<>
<button className={`assets-tab ${activeCategory === 'file' ? 'active' : ''}`} onClick={() => { setActiveCategory('file'); setSubCategory('all'); }} style={activeCategory === 'file' ? { '--tab-color': '#5B5FED', '--tab-bg': '#5B5FED18' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
文件
</button>
<button className={`assets-tab ${activeCategory === 'image' ? 'active' : ''}`} onClick={() => { setActiveCategory('image'); setSubCategory('all'); }} style={activeCategory === 'image' ? { '--tab-color': '#10B981', '--tab-bg': '#10B98118' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
图片
</button>
<button className={`assets-tab ${activeCategory === 'video' ? 'active' : ''}`} onClick={() => { setActiveCategory('video'); setSubCategory('all'); }} style={activeCategory === 'video' ? { '--tab-color': '#EF4444', '--tab-bg': '#EF444418' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
视频
</button>
<button className={`assets-tab ${activeCategory === 'audio' ? 'active' : ''}`} onClick={() => { setActiveCategory('audio'); setSubCategory('all'); }} style={activeCategory === 'audio' ? { '--tab-color': '#8B5CF6', '--tab-bg': '#8B5CF618' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
音频
</button>
<button className={`assets-tab ${activeCategory === 'novel' ? 'active' : ''}`} onClick={() => { setActiveCategory('novel'); setSubCategory('all'); }} style={activeCategory === 'novel' ? { '--tab-color': '#F59E0B', '--tab-bg': '#F59E0B18' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
小说
</button>
</>
)}
</div>
{/* File sub-tabs */}
{showFileSubTabs && (
<div className="assets-subtabs">
<button className={`assets-subtab ${subCategory === 'all' ? 'active' : ''}`} onClick={() => setSubCategory('all')}>全部文件</button>
{(['word', 'excel', 'ppt', 'pdf', 'txt', 'md', 'doc', 'other'] as AssetFileType[]).map(ft => (
<button key={ft} className={`assets-subtab ${subCategory === ft ? 'active' : ''}`} onClick={() => setSubCategory(ft)}>
{fileTypeLabels[ft].label}
</button>
))}
</div>
)}
{/* Asset list */}
<div className="assets-list">
{loading ? (
<div className="assets-loading">
<div className="audit-spinner" />
<span>加载中...</span>
</div>
) : error ? (
<div className="assets-empty">
<p>加载失败:{error}</p>
</div>
) : filtered.length === 0 ? (
<div className="assets-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<p>暂无资产</p>
</div>
) : (
<div className={`assets-grid ${activeCategory === 'novel' ? 'novel-grid' : ''}`}>
{filtered.map(asset => {
if (asset.category === 'novel') {
return (
<div key={asset.id} className="asset-novel-card" onClick={() => setSelectedAsset(asset)}>
<div className="novel-card-cover">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#F59E0B', opacity: 0.6 }}><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
<span className="novel-card-badge">小说</span>
</div>
<div className="novel-card-info">
<h3 className="novel-card-title">{asset.name}</h3>
<p className="novel-card-desc">{asset.description}</p>
<div className="novel-card-meta">
<span>{asset.chapters?.length || 0} 章</span>
<span>·</span>
<span>{asset.chapters?.reduce((s, c) => s + c.wordCount, 0).toLocaleString() || 0} 字</span>
<span>·</span>
<span>{formatDate(asset.updatedAt)}</span>
</div>
</div>
</div>
);
}
return (
<div key={asset.id} className="asset-card" onClick={() => setSelectedAsset(asset)}>
<div className="asset-card-icon">
{asset.category === 'file' && (
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
{getFileTypeInfo(asset.fileType).label}
</span>
)}
{asset.category === 'image' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
)}
{asset.category === 'video' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
)}
{asset.category === 'audio' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#8B5CF6" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
)}
</div>
<div className="asset-card-body">
<h4 className="asset-card-name">{asset.name}</h4>
{asset.description && <p className="asset-card-desc">{asset.description}</p>}
<div className="asset-card-footer">
{asset.size && <span className="asset-card-size">{formatSize(asset.size)}</span>}
<span className="asset-card-date">{formatDate(asset.updatedAt)}</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Preview Modal */}
{selectedAsset && (
<PreviewModal
asset={selectedAsset}
onClose={() => setSelectedAsset(null)}
onDelete={handleDelete}
/>
)}
{/* Create Modal */}
{showCreate && (
<CreateAssetModal
onClose={() => setShowCreate(false)}
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
/>
)}
</div>
);
};
export default AssetsPage;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+704
View File
@@ -0,0 +1,704 @@
/* ===== Landing Page Styles ===== */
.landing-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
background: var(--color-bg-primary);
z-index: var(--z-base);
-webkit-overflow-scrolling: touch;
}
/* Toast notification */
.landing-toast {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
padding: 10px 24px;
background: var(--color-text-primary);
color: #FFFFFF;
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideDown var(--transition-normal);
white-space: nowrap;
}
.landing-section-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
}
/* ===== Header ===== */
.landing-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
}
.landing-header-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
height: 60px;
display: flex;
align-items: center;
gap: var(--space-2xl);
}
.landing-brand {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.brand-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.landing-nav-links {
display: flex;
gap: var(--space-xl);
}
.landing-nav-links a {
font-size: var(--text-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
cursor: pointer;
}
.landing-nav-links a:hover {
color: var(--color-accent);
}
.landing-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-md);
}
/* ===== Buttons ===== */
.landing-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 20px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: none;
transition: all var(--transition-fast);
font-family: inherit;
text-decoration: none;
}
.landing-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
}
.landing-btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: 0 2px 12px rgba(91, 95, 237, 0.35);
}
.landing-btn-outline {
background: transparent;
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
}
.landing-btn-outline:hover {
background: var(--color-accent-subtle);
}
.landing-btn-lg {
padding: 12px 28px;
font-size: var(--text-base);
}
/* ===== Hero ===== */
.landing-hero {
padding: 80px 0 72px;
background: linear-gradient(180deg, #F8F7FF 0%, #FFFFFF 100%);
}
.landing-hero-content {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.hero-title {
font-size: 48px;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1.2;
letter-spacing: -0.03em;
margin: 0 0 var(--space-xl);
}
.hero-accent {
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-desc {
font-size: var(--text-lg);
color: var(--color-text-secondary);
max-width: 640px;
line-height: 1.7;
margin: 0 0 var(--space-2xl);
}
.hero-actions {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-3xl);
}
.hero-stats {
display: flex;
gap: var(--space-3xl);
}
.hero-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
}
.stat-number {
font-size: 28px;
font-weight: 700;
color: var(--color-accent);
}
.stat-label {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
/* ===== Carousel ===== */
.landing-banners {
padding: var(--space-3xl) 0;
}
.section-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-xl);
}
.section-header {
text-align: center;
margin-bottom: var(--space-2xl);
}
.section-desc {
font-size: var(--text-base);
color: var(--color-text-tertiary);
margin: var(--space-sm) 0 0;
}
.carousel-container {
position: relative;
overflow: hidden;
border-radius: var(--radius-xl);
}
.carousel-track {
display: flex;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.carousel-slide {
min-width: 100%;
box-sizing: border-box;
}
.carousel-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3xl) var(--space-3xl);
border-radius: var(--radius-xl);
min-height: 200px;
color: #FFFFFF;
overflow: hidden;
position: relative;
}
.carousel-card-content {
z-index: 1;
}
.carousel-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(255,255,255,0.25);
backdrop-filter: blur(4px);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
margin-bottom: var(--space-md);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.carousel-title {
font-size: 28px;
font-weight: 700;
margin: 0 0 var(--space-sm);
line-height: 1.3;
}
.carousel-subtitle {
font-size: var(--text-base);
opacity: 0.9;
margin: 0 0 var(--space-xl);
}
.carousel-cta {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 20px;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.4);
border-radius: var(--radius-md);
color: #FFFFFF;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
font-family: inherit;
}
.carousel-cta:hover {
background: rgba(255,255,255,0.35);
border-color: rgba(255,255,255,0.6);
}
.carousel-card-visual {
position: relative;
width: 160px;
height: 160px;
flex-shrink: 0;
}
.visual-circle {
position: absolute;
border-radius: 50%;
background: rgba(255,255,255,0.15);
}
.visual-circle.c1 {
width: 140px;
height: 140px;
top: 10px;
right: 0;
}
.visual-circle.c2 {
width: 90px;
height: 90px;
top: 50px;
right: 50px;
background: rgba(255,255,255,0.2);
}
.visual-circle.c3 {
width: 50px;
height: 50px;
top: 20px;
right: 20px;
background: rgba(255,255,255,0.25);
}
/* Dots */
.carousel-dots {
position: absolute;
bottom: var(--space-lg);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-sm);
z-index: 2;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.4);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0;
}
.carousel-dot.active {
background: #FFFFFF;
width: 24px;
border-radius: 4px;
}
/* Arrows */
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.9);
color: var(--color-text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
z-index: 2;
}
.carousel-arrow:hover {
background: #FFFFFF;
box-shadow: var(--shadow-lg);
}
.carousel-arrow-left {
left: var(--space-md);
}
.carousel-arrow-right {
right: var(--space-md);
}
/* ===== Market ===== */
.landing-market {
padding: var(--space-3xl) 0 var(--space-4xl);
background: var(--color-bg-secondary);
}
.market-filter-tabs {
display: flex;
justify-content: center;
gap: var(--space-sm);
margin-bottom: var(--space-2xl);
}
.filter-tab {
padding: 6px 16px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
background: #FFFFFF;
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.filter-tab:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.filter-tab.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #FFFFFF;
}
.market-app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--space-xl);
}
.market-app-card {
background: #FFFFFF;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
cursor: pointer;
transition: all var(--transition-fast);
}
.market-app-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-accent-light);
}
.app-card-cover {
height: 120px;
position: relative;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: var(--space-md);
gap: var(--space-sm);
}
.app-card-type-badge {
font-size: 11px;
font-weight: 600;
color: #FFFFFF;
padding: 2px 8px;
border-radius: var(--radius-full);
letter-spacing: 0.03em;
}
.app-card-price {
position: absolute;
top: var(--space-md);
left: var(--space-md);
font-size: var(--text-sm);
font-weight: 700;
color: #FFFFFF;
background: rgba(0,0,0,0.3);
padding: 2px 10px;
border-radius: var(--radius-full);
}
.app-card-body {
padding: var(--space-lg);
}
.app-card-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-xs);
}
.app-card-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin: 0 0 var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.app-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.app-card-rating {
display: flex;
align-items: center;
gap: 2px;
color: #F59E0B;
}
.app-card-rating svg {
fill: #F59E0B;
}
.market-more {
display: flex;
justify-content: center;
margin-top: var(--space-2xl);
}
/* ===== Features ===== */
.landing-features {
padding: var(--space-4xl) 0;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-xl);
}
.feature-card {
padding: var(--space-2xl);
background: #FFFFFF;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
transition: all var(--transition-fast);
}
.feature-card:hover {
border-color: var(--color-accent-light);
box-shadow: var(--shadow-md);
}
.feature-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
background: var(--color-accent-subtle);
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-lg);
}
.feature-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-sm);
}
.feature-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.6;
margin: 0;
}
/* ===== CTA ===== */
.landing-cta {
padding: var(--space-4xl) 0;
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
text-align: center;
}
.cta-title {
font-size: 32px;
font-weight: 700;
color: #FFFFFF;
margin: 0 0 var(--space-md);
}
.cta-desc {
font-size: var(--text-lg);
color: rgba(255,255,255,0.85);
margin: 0 0 var(--space-2xl);
}
.landing-cta .landing-btn-primary {
background: #FFFFFF;
color: var(--color-accent);
}
.landing-cta .landing-btn-primary:hover {
background: #F0EEFF;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
/* ===== Footer ===== */
.landing-footer {
padding: var(--space-3xl) 0 var(--space-xl);
background: var(--color-bg-tertiary);
border-top: 1px solid var(--color-border);
}
.footer-content {
display: flex;
justify-content: space-between;
gap: var(--space-2xl);
margin-bottom: var(--space-2xl);
}
.footer-links {
display: flex;
gap: var(--space-4xl);
}
.footer-col h4 {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-md);
}
.footer-col a {
display: block;
font-size: var(--text-sm);
color: var(--color-text-tertiary);
margin-bottom: var(--space-sm);
cursor: pointer;
transition: color var(--transition-fast);
}
.footer-col a:hover {
color: var(--color-accent);
}
.footer-bottom {
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.landing-nav-links {
display: none;
}
.hero-title {
font-size: 32px;
}
.hero-stats {
flex-direction: column;
gap: var(--space-lg);
}
.carousel-card {
flex-direction: column;
text-align: center;
min-height: 240px;
}
.carousel-card-visual {
display: none;
}
.features-grid {
grid-template-columns: 1fr;
}
.market-app-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.footer-content {
flex-direction: column;
}
}
+370
View File
@@ -0,0 +1,370 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { mockAdBanners, mockApps } from '@/mock/data';
import type { AdBanner, MarketApp } from '@/types';
import './LandingPage.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
const LandingPage: React.FC = () => {
const navigate = useNavigate();
const [currentSlide, setCurrentSlide] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const banners = mockAdBanners.filter(b => b.status === 'active');
const publishedApps = mockApps.filter(a => a.status === 'published');
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
const [toastMsg, setToastMsg] = useState('');
const filteredApps = appTypeFilter
? publishedApps.filter(a => a.type === appTypeFilter)
: publishedApps;
const showToast = (msg: string) => {
setToastMsg(msg);
setTimeout(() => setToastMsg(''), 2000);
};
const handleAppClick = (appName: string) => {
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
setTimeout(() => navigate('/login'), 800);
};
// Auto rotate carousel
const nextSlide = useCallback(() => {
if (banners.length === 0) return;
setCurrentSlide(prev => (prev + 1) % banners.length);
}, [banners.length]);
useEffect(() => {
if (banners.length <= 1 || isPaused) return;
const timer = setInterval(nextSlide, 4000);
return () => clearInterval(timer);
}, [banners.length, isPaused, nextSlide]);
const goToSlide = (idx: number) => setCurrentSlide(idx);
const getGradient = (app: MarketApp): string => {
const gradients: Record<string, string> = {
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
};
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
};
return (
<div className="landing-page">
{/* Toast notification */}
{toastMsg && (
<div className="landing-toast">{toastMsg}</div>
)}
{/* Hero / Nav bar */}
<header className="landing-header">
<div className="landing-header-inner">
<div className="landing-brand">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
<span className="brand-name">Qihu-AIFlow</span>
</div>
<nav className="landing-nav-links">
<a href="#market">应用市场</a>
<a href="#features">平台能力</a>
</nav>
<div className="landing-header-actions">
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>登录</button>
<button className="landing-btn landing-btn-primary" onClick={() => navigate('/login')}>免费注册</button>
</div>
</div>
</header>
{/* Hero Section */}
<section className="landing-hero">
<div className="landing-hero-content">
<h1 className="hero-title">
用 <span className="hero-accent">AI</span> 构建你的<br/>智能自动化工作流
</h1>
<p className="hero-desc">
Qihu-AIFlow 是一站式AI智能自动化PaaS平台,集成多模态大模型、知识库管理、应用市场,
让每个人都能轻松搭建属于自己的AI自动化SOP。
</p>
<div className="hero-actions">
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
免费开始使用
</button>
<button className="landing-btn landing-btn-outline landing-btn-lg" onClick={() => { document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' }); }}>
浏览应用市场
</button>
</div>
<div className="hero-stats">
<div className="hero-stat">
<span className="stat-number">3,500+</span>
<span className="stat-label">活跃用户</span>
</div>
<div className="hero-stat">
<span className="stat-number">120+</span>
<span className="stat-label">应用/Skills</span>
</div>
<div className="hero-stat">
<span className="stat-number">99.8%</span>
<span className="stat-label">服务可用率</span>
</div>
</div>
</div>
</section>
{/* Carousel Section */}
{banners.length > 0 && (
<section className="landing-banners">
<div className="landing-section-inner">
<h2 className="section-title">推荐应用</h2>
<div
className="carousel-container"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<div className="carousel-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
{banners.map(banner => (
<div key={banner.id} className="carousel-slide">
<div className="carousel-card" style={{ background: banner.linkAppId ? getGradient({ id: banner.linkAppId } as MarketApp) : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<div className="carousel-card-content">
<div className="carousel-badge">推荐</div>
<h3 className="carousel-title">{banner.title}</h3>
{banner.linkAppName && (
<p className="carousel-subtitle">立即体验 {banner.linkAppName}</p>
)}
<button
className="carousel-cta"
onClick={() => navigate('/login')}
>
查看详情
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</div>
<div className="carousel-card-visual">
<div className="visual-circle c1"/>
<div className="visual-circle c2"/>
<div className="visual-circle c3"/>
</div>
</div>
</div>
))}
</div>
{/* Dots */}
{banners.length > 1 && (
<div className="carousel-dots">
{banners.map((_, idx) => (
<button
key={idx}
className={`carousel-dot ${idx === currentSlide ? 'active' : ''}`}
onClick={() => goToSlide(idx)}
/>
))}
</div>
)}
{/* Arrows */}
{banners.length > 1 && (
<>
<button
className="carousel-arrow carousel-arrow-left"
onClick={() => setCurrentSlide(prev => (prev - 1 + banners.length) % banners.length)}
>
<SVGIcon size={20}><polyline points="15 18 9 12 15 6"/></SVGIcon>
</button>
<button
className="carousel-arrow carousel-arrow-right"
onClick={() => nextSlide()}
>
<SVGIcon size={20}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</>
)}
</div>
</div>
</section>
)}
{/* Market Preview Section */}
<section className="landing-market" id="market">
<div className="landing-section-inner">
<div className="section-header">
<h2 className="section-title">应用市场</h2>
<p className="section-desc">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
</div>
{/* Type Filter */}
<div className="market-filter-tabs">
<button
className={`filter-tab ${appTypeFilter === '' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('')}
>全部</button>
<button
className={`filter-tab ${appTypeFilter === 'skill' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('skill')}
>Skills</button>
<button
className={`filter-tab ${appTypeFilter === 'agent' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('agent')}
>Agent</button>
<button
className={`filter-tab ${appTypeFilter === 'mcp' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('mcp')}
>MCP</button>
</div>
{/* App Grid */}
<div className="market-app-grid">
{filteredApps.map(app => (
<div key={app.id} className="market-app-card" onClick={() => handleAppClick(app.name)}>
<div className="app-card-cover" style={{ background: getGradient(app) }}>
<span className="app-card-type-badge" style={{ background: typeColors[app.type] }}>
{typeLabels[app.type]}
</span>
{app.price > 0 && (
<span className="app-card-price">{app.price} 元</span>
)}
</div>
<div className="app-card-body">
<h4 className="app-card-name">{app.name}</h4>
<p className="app-card-desc">{app.description}</p>
<div className="app-card-meta">
<span className="app-card-rating">
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
{app.ratingAvg > 0 ? app.ratingAvg : '-'}
</span>
<span className="app-card-installs">{app.installCount.toLocaleString()} 次安装</span>
</div>
</div>
</div>
))}
</div>
<div className="market-more">
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>
登录查看更多应用
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</div>
</div>
</section>
{/* Features Section */}
<section className="landing-features" id="features">
<div className="landing-section-inner">
<div className="section-header">
<h2 className="section-title">平台核心能力</h2>
<p className="section-desc">从对话到自动化,Qihu-AIFlow 为你提供完整的 AI 工作流解决方案</p>
</div>
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon>
</div>
<h3 className="feature-title">多模型AI对话</h3>
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon>
</div>
<h3 className="feature-title">私有知识库</h3>
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon>
</div>
<h3 className="feature-title">应用市场</h3>
<p className="feature-desc">丰富的Skills、Agent、MCP应用,一站式接入工作流,即装即用</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
</div>
<h3 className="feature-title">Token驾驶舱</h3>
<p className="feature-desc">实时监控Token消耗,费用透明可控,每一笔都有据可查</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon>
</div>
<h3 className="feature-title">IM频道绑定</h3>
<p className="feature-desc">支持飞书、钉钉、微信等多平台绑定,AI助手融入日常工作流</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon>
</div>
<h3 className="feature-title">自动化任务编排</h3>
<p className="feature-desc">创建关键词触发、定时执行等自动化任务,让AI按你的节奏工作</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="landing-cta">
<div className="landing-section-inner">
<h2 className="cta-title">准备好开始了吗?</h2>
<p className="cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
免费注册,立即体验
</button>
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<div className="landing-section-inner">
<div className="footer-content">
<div className="footer-brand">
<div className="landing-brand">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
</div>
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
AI智能自动化PaaS平台
</p>
</div>
<div className="footer-links">
<div className="footer-col">
<h4>产品</h4>
<a>AI聊天</a>
<a>知识库</a>
<a>应用市场</a>
</div>
<div className="footer-col">
<h4>支持</h4>
<a>帮助文档</a>
<a>API参考</a>
<a>联系我们</a>
</div>
</div>
</div>
<div className="footer-bottom">
<span>Qihu-AIFlow 2026. All rights reserved.</span>
</div>
</div>
</footer>
</div>
);
};
export default LandingPage;
+342
View File
@@ -0,0 +1,342 @@
/* ===== Login Page ===== */
.login-page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--color-bg-primary);
position: relative;
overflow: hidden;
}
.login-bg-decor {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.08;
}
.bg-circle.c1 {
width: 600px; height: 600px;
background: var(--color-accent);
top: -200px; right: -100px;
}
.bg-circle.c2 {
width: 400px; height: 400px;
background: #8B8DFF;
bottom: -100px; left: -50px;
}
.bg-circle.c3 {
width: 300px; height: 300px;
background: var(--color-accent);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
opacity: 0.04;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 60px 60px;
}
/* Container */
.login-container {
display: flex;
width: 900px;
min-height: 560px;
background: #FFFFFF;
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
overflow: hidden;
z-index: var(--z-base);
position: relative;
}
/* Brand Panel */
.login-brand-panel {
width: 400px;
background: linear-gradient(135deg, #2D2B55 0%, #1A1A2E 50%, #2D2B55 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.login-brand-panel::before {
content: '';
position: absolute;
width: 300px; height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(91,95,237,0.3) 0%, transparent 70%);
top: -50px; right: -50px;
}
.login-brand-panel::after {
content: '';
position: absolute;
width: 200px; height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(139,141,255,0.2) 0%, transparent 70%);
bottom: -30px; left: -30px;
}
.brand-content {
text-align: center;
color: white;
z-index: 1;
}
.brand-icon {
margin-bottom: var(--space-xl);
}
.brand-title {
font-size: 36px;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: var(--space-sm);
}
.brand-desc {
font-size: var(--text-base);
color: rgba(255,255,255,0.6);
margin-bottom: var(--space-3xl);
}
.brand-features {
display: flex;
flex-direction: column;
gap: var(--space-md);
text-align: left;
}
.brand-feature {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--text-sm);
color: rgba(255,255,255,0.75);
}
.bf-icon {
color: #6C63FF;
font-weight: 700;
font-size: var(--text-base);
}
/* Form Panel */
.login-form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4xl);
}
.login-form-inner {
width: 100%;
max-width: 340px;
}
.login-tabs {
display: flex;
gap: var(--space-2xl);
margin-bottom: var(--space-3xl);
}
.login-tab {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-tertiary);
padding-bottom: var(--space-sm);
border-bottom: 2px solid transparent;
transition: all var(--transition-fast);
}
.login-tab.active {
color: var(--color-text-primary);
border-bottom-color: var(--color-accent);
}
.login-tab:hover {
color: var(--color-text-primary);
}
.login-form {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: 16px;
}
.form-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
font-size: var(--text-base);
color: var(--color-text-primary);
background: var(--color-bg-primary);
transition: all var(--transition-fast);
outline: none;
}
.form-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
background: #FFFFFF;
}
.form-input::placeholder {
color: var(--color-text-disabled);
}
.login-submit-btn {
height: 46px;
background: linear-gradient(135deg, #5B5FED, #6C63FF);
color: white;
border-radius: var(--radius-lg);
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.15em;
transition: all var(--transition-fast);
}
.login-submit-btn:hover {
background: linear-gradient(135deg, #4A4ECC, #5B5FED);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.login-submit-btn:active {
transform: translateY(0);
}
.login-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-submit-btn:disabled:hover {
transform: none;
box-shadow: none;
}
/* Register Method Tabs */
.register-method-tabs {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.register-method-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.register-method-tab:hover {
border-color: var(--color-accent-light);
}
.register-method-tab.active {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
color: var(--color-accent);
font-weight: 600;
}
/* SMS Row */
.sms-row {
display: flex;
gap: var(--space-sm);
}
.sms-input {
flex: 1;
}
.sms-btn {
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
border: 1px solid var(--color-accent);
background: transparent;
color: var(--color-accent);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
}
.sms-btn:hover:not(:disabled) {
background: var(--color-accent);
color: white;
}
.sms-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: var(--color-border);
color: var(--color-text-tertiary);
}
/* Required Marker */
.required {
color: var(--color-error);
}
@media (max-width: 768px) {
.login-container {
flex-direction: column;
width: 90vw;
min-height: auto;
}
.login-brand-panel {
width: 100%;
padding: var(--space-3xl) var(--space-2xl);
}
.login-form-panel {
padding: var(--space-2xl);
}
.brand-features {
display: none;
}
}
+267
View File
@@ -0,0 +1,267 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
import type { User } from '@/types';
import './LoginPage.css';
const LoginPage: React.FC = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
const [errorMsg, setErrorMsg] = useState('');
const [loading, setLoading] = useState(false);
// Register state
const [registerMethod, setRegisterMethod] = useState<'phone' | 'email'>('phone');
const [regPhone, setRegPhone] = useState('');
const [regEmail, setRegEmail] = useState('');
const [regUsername, setRegUsername] = useState('');
const [regPassword, setRegPassword] = useState('');
const [smsCode, setSmsCode] = useState('');
const [smsSent, setSmsSent] = useState(false);
const [smsCountdown, setSmsCountdown] = useState(0);
const [emailCode, setEmailCode] = useState('');
const [emailSent, setEmailSent] = useState(false);
const [emailCountdown, setEmailCountdown] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Login state
const [loginAccount, setLoginAccount] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const startCountdown = (setter: (v: number) => void) => {
let count = 60;
setter(count);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
count--;
setter(count);
if (count <= 0 && timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, 1000);
};
const handleSendSms = () => {
if (!regPhone.trim() || smsCountdown > 0) return;
setSmsSent(true);
startCountdown(setSmsCountdown);
};
const handleSendEmailCode = () => {
if (!regEmail.trim() || emailCountdown > 0) return;
setEmailSent(true);
startCountdown(setEmailCountdown);
};
const handleLogin = async () => {
setErrorMsg('');
if (!loginAccount.trim() || !loginPassword.trim()) {
setErrorMsg('请输入用户名和密码');
return;
}
setLoading(true);
try {
const res = await authAPI.login(loginAccount, loginPassword);
const { access_token, user: userData } = res.data;
localStorage.setItem('aiflow_token', access_token);
const userObj: User = {
id: userData.id,
username: userData.username,
email: userData.email || '',
phone: userData.phone || '',
avatar: userData.avatar || '',
role: userData.role,
status: userData.status,
realNameStatus: userData.real_name_status || 'unverified',
createdAt: userData.created_at || new Date().toISOString(),
membership: userData.membership_level > 0 ? {
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
level: userData.membership_level,
expireAt: userData.membership_expire_at || '',
autoRenew: userData.auto_renew || false,
} : undefined,
};
login(userObj);
navigate(userData.role === 'admin' ? '/admin' : '/chat');
} catch (e: any) {
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
const status = e.status || e.response?.status || 0;
const msg = e.detail || e.message || '';
if (status === 403) {
setErrorMsg('账号已被禁用,请联系管理员');
} else if (status === 401) {
// 后端的detail精确指明了原因:"用户名不存在" 或 "密码错误"
setErrorMsg(msg || '用户名或密码错误');
} else if (status === 0 || msg.includes('NetworkError') || msg.includes('Failed to fetch')) {
setErrorMsg('网络连接失败,请检查网络');
} else {
setErrorMsg(msg || '登录失败,请稍后重试');
}
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
setErrorMsg('');
if (!regUsername.trim() || !regPassword.trim() || regPassword.length < 6) {
setErrorMsg('用户名和密码为必填(密码至少6位)');
return;
}
setLoading(true);
try {
const data: any = { username: regUsername, password: regPassword };
if (registerMethod === 'phone') data.phone = regPhone;
else data.email = regEmail;
const res = await authAPI.register(data);
const { access_token, user: userData } = res.data;
localStorage.setItem('aiflow_token', access_token);
const userObj: User = {
id: userData.id,
username: userData.username,
email: userData.email || '',
phone: userData.phone || '',
avatar: userData.avatar || '',
role: 'user',
status: 'active',
realNameStatus: 'unverified',
createdAt: new Date().toISOString(),
};
login(userObj);
navigate('/chat');
} catch (e: any) {
setErrorMsg(e.message || '注册失败');
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && activeTab === 'login') handleLogin();
};
return (
<div className="login-page">
<div className="login-bg-decor">
<div className="bg-circle c1" />
<div className="bg-circle c2" />
<div className="bg-circle c3" />
<div className="bg-grid" />
</div>
<div className="login-container">
<div className="login-brand-panel">
<div className="brand-content">
<div className="brand-icon">
<svg width="48" height="48" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="white"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#5B5FED" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#5B5FED" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
</div>
<h1 className="brand-title">AIFlow</h1>
<p className="brand-desc">智能自动化PaaS平台</p>
<div className="brand-features">
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>一站式AI自动化SOP</span></div>
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>多模型自由切换</span></div>
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>知识库 + 应用市场</span></div>
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>Token消耗实时掌控</span></div>
</div>
</div>
</div>
<div className="login-form-panel">
<div className="login-form-inner">
<div className="login-tabs">
<button className={`login-tab ${activeTab === 'login' ? 'active' : ''}`} onClick={() => setActiveTab('login')}>登录</button>
<button className={`login-tab ${activeTab === 'register' ? 'active' : ''}`} onClick={() => setActiveTab('register')}>注册</button>
</div>
{errorMsg && <div className="form-error">{errorMsg}</div>}
{activeTab === 'login' ? (
<div className="login-form" onKeyDown={handleKeyDown}>
<div className="form-group">
<label className="form-label">用户名</label>
<input type="text" className="form-input" placeholder="请输入用户名" value={loginAccount} onChange={e => setLoginAccount(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">密码</label>
<input type="password" className="form-input" placeholder="请输入密码" value={loginPassword} onChange={e => setLoginPassword(e.target.value)} />
</div>
<button className="login-submit-btn" onClick={handleLogin} disabled={loading}>
{loading ? '登录中...' : '登 录'}
</button>
</div>
) : (
<div className="login-form">
<div className="register-method-tabs">
<button className={`register-method-tab ${registerMethod === 'phone' ? 'active' : ''}`} onClick={() => setRegisterMethod('phone')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
手机号注册
</button>
<button className={`register-method-tab ${registerMethod === 'email' ? 'active' : ''}`} onClick={() => setRegisterMethod('email')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
邮箱注册
</button>
</div>
{registerMethod === 'phone' ? (
<>
<div className="form-group">
<label className="form-label">手机号</label>
<input type="tel" className="form-input" placeholder="请输入手机号码" value={regPhone} onChange={e => setRegPhone(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">短信验证码</label>
<div className="sms-row">
<input type="text" className="form-input sms-input" placeholder="请输入验证码" value={smsCode} onChange={e => setSmsCode(e.target.value)} />
<button className="sms-btn" onClick={handleSendSms} disabled={!regPhone.trim() || smsCountdown > 0}>
{smsCountdown > 0 ? `${smsCountdown}秒后重发` : smsSent ? '重新发送' : '获取验证码'}
</button>
</div>
</div>
</>
) : (
<>
<div className="form-group">
<label className="form-label">邮箱</label>
<input type="email" className="form-input" placeholder="请输入邮箱地址" value={regEmail} onChange={e => setRegEmail(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">邮箱验证码</label>
<div className="sms-row">
<input type="text" className="form-input sms-input" placeholder="请输入邮箱验证码" value={emailCode} onChange={e => setEmailCode(e.target.value)} />
<button className="sms-btn" onClick={handleSendEmailCode} disabled={!regEmail.trim() || emailCountdown > 0}>
{emailCountdown > 0 ? `${emailCountdown}秒后重发` : emailSent ? '重新发送' : '发送验证码'}
</button>
</div>
</div>
</>
)}
<div className="form-group">
<label className="form-label">用户名 <span className="required">*</span></label>
<input type="text" className="form-input" placeholder="请输入用户名" value={regUsername} onChange={e => setRegUsername(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">密码 <span className="required">*</span></label>
<input type="password" className="form-input" placeholder="请设置密码(至少6位)" value={regPassword} onChange={e => setRegPassword(e.target.value)} />
</div>
<button className="login-submit-btn" onClick={handleRegister} disabled={loading}>
{loading ? '注册中...' : '注 册'}
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default LoginPage;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,303 @@
/* ===== NotificationPage Styles ===== */
.notification-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-primary);
}
/* ----- Header ----- */
.notification-page-header {
padding: var(--space-3xl) var(--space-4xl) var(--space-xl);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: var(--space-xl);
flex-shrink: 0;
}
.notification-title-row {
display: flex;
align-items: center;
gap: var(--space-md);
}
.notification-page-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.3;
}
.notification-unread-badge {
display: inline-flex;
align-items: center;
padding: 2px 12px;
background: var(--color-accent-light);
color: var(--color-accent);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
}
.notification-header-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
}
/* ----- Filter Tabs ----- */
.notification-filter-tabs {
display: flex;
gap: var(--space-xs);
background: var(--color-bg-primary);
padding: var(--space-xs);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
}
.notif-filter-tab {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.notif-filter-tab:hover {
color: var(--color-text-primary);
background: var(--color-bg-hover);
}
.notif-filter-tab.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: 600;
}
.notif-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
background: var(--color-bg-active);
color: var(--color-text-tertiary);
line-height: 1;
}
.notif-filter-tab.active .notif-filter-count {
background: var(--color-accent);
color: white;
}
/* ----- Mark All Read Button ----- */
.mark-all-read-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-accent);
background: var(--color-accent-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.mark-all-read-btn:hover {
background: var(--color-accent-light);
color: var(--color-accent-hover);
}
/* ----- Notification List ----- */
.notification-list {
flex: 1;
overflow-y: auto;
padding: var(--space-lg) var(--space-4xl);
display: flex;
flex-direction: column;
gap: 2px;
}
/* ----- Notification Item ----- */
.notification-item {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-lg);
padding: var(--space-xl) var(--space-lg);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.notification-item:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-light);
}
.notification-item.unread {
background: var(--color-accent-subtle);
}
.notification-item.unread:hover {
background: var(--color-accent-light);
}
/* Unread dot */
.notification-unread-dot {
position: absolute;
top: var(--space-lg);
left: var(--space-sm);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
flex-shrink: 0;
}
/* Type Icon */
.notification-type-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Content */
.notification-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.notification-content-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.notification-type-label {
display: inline-block;
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.notification-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-body {
font-size: var(--text-sm);
color: var(--color-text-secondary);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Time */
.notification-time {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
margin-top: 2px;
}
/* ----- Empty State ----- */
.notification-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5xl);
gap: var(--space-md);
flex: 1;
}
.notification-empty-icon {
color: var(--color-text-disabled);
}
.notification-empty-text {
font-size: var(--text-base);
color: var(--color-text-tertiary);
}
/* ----- Responsive ----- */
@media (max-width: 768px) {
.notification-page-header {
padding: var(--space-xl) var(--space-lg);
}
.notification-header-actions {
flex-direction: column;
align-items: flex-start;
}
.notification-filter-tabs {
flex-wrap: wrap;
width: 100%;
}
.notif-filter-tab {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-xs);
}
.notification-list {
padding: var(--space-md) var(--space-lg);
}
.notification-item {
padding: var(--space-md);
gap: var(--space-md);
}
.notification-time {
display: none;
}
}
@media (max-width: 480px) {
.notification-title-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
.notification-type-icon {
width: 32px;
height: 32px;
}
}
@@ -0,0 +1,218 @@
import React, { useState, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { mockNotifications } from '@/mock/data';
import type { Notification } from '@/types';
import './NotificationPage.css';
// ===== SVG Icons for notification types =====
const typeIconMap: Record<Notification['type'], { icon: JSX.Element; color: string; bg: string }> = {
announcement: {
color: '#5B5FED',
bg: '#EEEDFF',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
),
},
reminder: {
color: '#E8922C',
bg: '#FEF4E8',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
),
},
renewal: {
color: '#3B82F6',
bg: '#EBF2FE',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
),
},
order: {
color: '#219B5E',
bg: '#E6F7EE',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
),
},
update: {
color: '#8B5CF6',
bg: '#F3F0FF',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>
</svg>
),
},
};
const typeLabelMap: Record<Notification['type'] | 'all', string> = {
all: '全部',
announcement: '公告',
reminder: '提醒',
renewal: '续费',
order: '订单',
update: '更新',
};
// ===== Helper: format relative time =====
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const date = new Date(dateStr).getTime();
const diff = now - date;
if (diff < 0) return '刚刚';
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
if (days < 30) return `${Math.floor(days / 7)}周前`;
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// ===== NotificationPage =====
const NotificationPage: React.FC = () => {
const { user } = useAuth();
const [activeFilter, setActiveFilter] = useState<Notification['type'] | 'all'>('all');
const [notifications, setNotifications] = useState<Notification[]>(() =>
mockNotifications.filter(n => n.userId === 'u1')
);
const unreadCount = useMemo(() => notifications.filter(n => !n.isRead).length, [notifications]);
const filteredNotifications = useMemo(() => {
if (activeFilter === 'all') return notifications;
return notifications.filter(n => n.type === activeFilter);
}, [notifications, activeFilter]);
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: notifications.length };
notifications.forEach(n => {
counts[n.type] = (counts[n.type] || 0) + 1;
});
return counts;
}, [notifications]);
const handleMarkAsRead = (id: string) => {
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, isRead: true } : n))
);
};
const handleMarkAllAsRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
};
if (!user) return null;
const filterKeys = ['all', 'announcement', 'reminder', 'renewal', 'order', 'update'] as const;
return (
<div className="notification-page">
{/* Header */}
<div className="notification-page-header">
<div className="notification-title-row">
<h1 className="notification-page-title">消息通知</h1>
{unreadCount > 0 && (
<span className="notification-unread-badge">{unreadCount}条未读</span>
)}
</div>
<div className="notification-header-actions">
{/* Filter Tabs */}
<div className="notification-filter-tabs">
{filterKeys.map(key => (
<button
key={key}
className={`notif-filter-tab ${activeFilter === key ? 'active' : ''}`}
onClick={() => setActiveFilter(key)}
>
<span>{typeLabelMap[key]}</span>
{typeCounts[key] !== undefined && (
<span className="notif-filter-count">{typeCounts[key]}</span>
)}
</button>
))}
</div>
{/* Mark All Read */}
{unreadCount > 0 && (
<button className="mark-all-read-btn" onClick={handleMarkAllAsRead}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
全部标为已读
</button>
)}
</div>
</div>
{/* Notification List */}
<div className="notification-list">
{filteredNotifications.length > 0 ? (
filteredNotifications.map(notif => {
const typeConfig = typeIconMap[notif.type];
return (
<div
key={notif.id}
className={`notification-item ${!notif.isRead ? 'unread' : ''}`}
onClick={() => handleMarkAsRead(notif.id)}
>
{/* Unread dot */}
{!notif.isRead && <span className="notification-unread-dot" />}
{/* Type Icon */}
<div
className="notification-type-icon"
style={{ backgroundColor: typeConfig.bg, color: typeConfig.color }}
>
{typeConfig.icon}
</div>
{/* Content */}
<div className="notification-content">
<div className="notification-content-header">
<span className="notification-type-label" style={{ color: typeConfig.color }}>
{typeLabelMap[notif.type]}
</span>
<h4 className="notification-title">{notif.title}</h4>
</div>
<p className="notification-body">{notif.content}</p>
</div>
{/* Time */}
<span className="notification-time">{formatRelativeTime(notif.createdAt)}</span>
</div>
);
})
) : (
<div className="notification-empty">
<div className="notification-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</div>
<p className="notification-empty-text">暂无{activeFilter === 'all' ? '' : typeLabelMap[activeFilter]}通知</p>
</div>
)}
</div>
</div>
);
};
export default NotificationPage;
@@ -0,0 +1,469 @@
/* ===== Novel Preview Page ===== */
.novel-preview-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-primary);
animation: fadeIn var(--transition-normal);
}
.novel-preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-lg);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
/* Top Bar */
.novel-topbar {
display: flex;
align-items: flex-start;
gap: var(--space-lg);
padding: var(--space-lg) var(--space-2xl);
background: linear-gradient(135deg, #FFFBEB, #FFF7ED);
border-bottom: 1px solid #FDE68A;
flex-shrink: 0;
}
.novel-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
color: #92400E;
transition: all var(--transition-fast);
flex-shrink: 0;
margin-top: 2px;
}
.novel-back-btn:hover {
background: #FEF3C7;
}
.novel-intro {
flex: 1;
min-width: 0;
}
.novel-intro-title {
font-size: var(--text-xl);
font-weight: 700;
color: #78350F;
margin-bottom: var(--space-xs);
}
.novel-intro-desc {
font-size: var(--text-sm);
color: #A16207;
line-height: 1.6;
margin-bottom: var(--space-sm);
}
.novel-intro-stats {
font-size: var(--text-xs);
color: #B45309;
display: flex;
gap: var(--space-sm);
}
/* Body Layout */
.novel-body {
flex: 1;
display: flex;
overflow: hidden;
}
/* Left Sidebar */
.novel-sidebar-left {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--color-border-light);
overflow-y: auto;
background: #FAFAFA;
flex-shrink: 0;
}
.novel-sidebar-title {
padding: var(--space-lg);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
}
.novel-chapter-list {
display: flex;
flex-direction: column;
gap: 1px;
padding: var(--space-sm);
}
.novel-chapter-item {
display: flex;
gap: var(--space-md);
padding: var(--space-md) var(--space-md);
border-radius: var(--radius-md);
text-align: left;
transition: all var(--transition-fast);
width: 100%;
}
.novel-chapter-item:hover {
background: var(--color-bg-hover);
}
.novel-chapter-item.active {
background: #FEF3C7;
}
.chapter-item-num {
font-size: 11px;
font-weight: 700;
color: var(--color-text-disabled);
font-family: var(--font-mono);
flex-shrink: 0;
width: 20px;
}
.novel-chapter-item.active .chapter-item-num {
color: #D97706;
}
.chapter-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chapter-item-title {
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chapter-item-count {
font-size: 10px;
color: var(--color-text-disabled);
}
/* Center Content */
.novel-content {
flex: 1;
overflow-y: auto;
padding: var(--space-3xl) var(--space-4xl);
background: #FFFFFF;
min-width: 0;
}
.novel-content-header {
margin-bottom: var(--space-3xl);
}
.novel-chapter-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text-primary);
}
.novel-content-body {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.novel-paragraph {
font-size: var(--text-md);
line-height: 2;
color: var(--color-text-primary);
text-indent: 2em;
letter-spacing: 0.01em;
}
.novel-content-footer {
margin-top: var(--space-3xl);
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border-light);
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.novel-content-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-disabled);
font-size: var(--text-sm);
}
/* Right Sidebar */
.novel-sidebar-right {
width: 280px;
min-width: 280px;
border-left: 1px solid var(--color-border-light);
overflow-y: auto;
background: #FAFAFA;
flex-shrink: 0;
}
.novel-right-tabs {
display: flex;
border-bottom: 1px solid var(--color-border-light);
}
.novel-right-tab {
flex: 1;
padding: var(--space-md) var(--space-sm);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-align: center;
transition: all var(--transition-fast);
border-bottom: 2px solid transparent;
}
.novel-right-tab:hover {
color: var(--color-text-primary);
}
.novel-right-tab.active {
color: #D97706;
border-bottom-color: #D97706;
}
.novel-right-content {
padding: var(--space-lg);
}
/* World View */
.novel-worldview {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.worldview-summary {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.7;
}
.worldview-section h4 {
font-size: 11px;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-sm);
}
.worldview-section ul {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.worldview-section li {
font-size: var(--text-xs);
color: var(--color-text-secondary);
padding-left: var(--space-md);
position: relative;
line-height: 1.5;
}
.worldview-section li::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #D97706;
}
.worldview-timeline {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.timeline-item {
display: flex;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: #FFFFFF;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
}
.timeline-era {
font-size: 10px;
font-weight: 700;
color: #D97706;
font-family: var(--font-mono);
flex-shrink: 0;
}
.timeline-event {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.4;
}
/* Characters */
.novel-characters {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.character-card {
display: flex;
gap: var(--space-md);
padding: var(--space-md);
background: #FFFFFF;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.character-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, #F59E0B, #FBBF24);
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
font-weight: 700;
flex-shrink: 0;
}
.character-info {
flex: 1;
min-width: 0;
}
.character-name-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: 4px;
}
.character-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.character-role {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-full);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
}
.character-role.main {
background: #FEF3C7;
color: #92400E;
}
.character-role.villain {
background: #FEE2E2;
color: #991B1B;
}
.character-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.5;
margin-bottom: var(--space-xs);
}
.character-rels {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.character-rel-tag {
font-size: 10px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--color-accent-subtle);
color: var(--color-accent);
font-weight: 500;
}
/* Hooks & Anchors */
.novel-hooks {
display: flex;
flex-direction: column;
gap: var(--space-2xl);
}
.hooks-section h4 {
font-size: 11px;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-sm);
}
.hook-item, .anchor-item {
display: flex;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: #FFFFFF;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
margin-bottom: var(--space-sm);
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.5;
}
.hook-item svg {
color: #F59E0B;
flex-shrink: 0;
margin-top: 2px;
}
.anchor-item svg {
color: var(--color-accent);
flex-shrink: 0;
margin-top: 2px;
}
/* Responsive */
@media (max-width: 1024px) {
.novel-sidebar-right { width: 240px; min-width: 240px; }
.novel-sidebar-left { width: 160px; min-width: 160px; }
.novel-content { padding: var(--space-2xl); }
}
@media (max-width: 768px) {
.novel-body { flex-direction: column; }
.novel-sidebar-left { width: 100%; min-width: 0; max-height: 200px; border-right: 0; border-bottom: 1px solid var(--color-border-light); }
.novel-sidebar-right { width: 100%; min-width: 0; max-height: 300px; border-left: 0; border-top: 1px solid var(--color-border-light); }
.novel-content { flex: 1; padding: var(--space-lg); }
.novel-topbar { padding: var(--space-md); }
}
@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { Asset, NovelChapter } from '@/types';
import { mockAssets } from '@/mock/data';
import './NovelPreviewPage.css';
const NovelPreviewPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [activeChapter, setActiveChapter] = useState<string>('');
const [rightTab, setRightTab] = useState<'worldview' | 'characters' | 'hooks'>('worldview');
const novel: Asset | undefined = mockAssets.find(a => a.id === id && a.category === 'novel');
const chapters = novel?.chapters || [];
const meta = novel?.novelMeta;
const currentChapter: NovelChapter | undefined = chapters.find(c => c.id === (activeChapter || chapters[0]?.id));
if (!novel) {
return (
<div className="novel-preview-page">
<div className="novel-preview-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p>未找到该小说</p>
<button className="novel-back-btn" onClick={() => navigate('/assets')}>返回资产</button>
</div>
</div>
);
}
return (
<div className="novel-preview-page">
{/* Top: Novel Intro */}
<div className="novel-topbar">
<button className="novel-back-btn" onClick={() => navigate('/assets')}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
</button>
<div className="novel-intro">
<h1 className="novel-intro-title">{novel.name}</h1>
<p className="novel-intro-desc">{novel.description}</p>
<div className="novel-intro-stats">
<span>{chapters.length} 章</span>
<span>·</span>
<span>{chapters.reduce((s, c) => s + c.wordCount, 0).toLocaleString()} 字</span>
</div>
</div>
</div>
<div className="novel-body">
{/* Left: Chapter List */}
<aside className="novel-sidebar-left">
<h3 className="novel-sidebar-title">章节目录</h3>
<div className="novel-chapter-list">
{chapters.map(ch => (
<button
key={ch.id}
className={`novel-chapter-item ${(activeChapter || chapters[0]?.id) === ch.id ? 'active' : ''}`}
onClick={() => setActiveChapter(ch.id)}
>
<span className="chapter-item-num">{String(ch.sortOrder).padStart(2, '0')}</span>
<div className="chapter-item-info">
<span className="chapter-item-title">{ch.title}</span>
<span className="chapter-item-count">{ch.wordCount.toLocaleString()} 字</span>
</div>
</button>
))}
</div>
</aside>
{/* Center: Content */}
<main className="novel-content">
{currentChapter ? (
<>
<div className="novel-content-header">
<h2 className="novel-chapter-title">{currentChapter.title}</h2>
</div>
<div className="novel-content-body">
{currentChapter.content.split('\n\n').map((para, i) => (
<p key={i} className="novel-paragraph">{para}</p>
))}
</div>
<div className="novel-content-footer">
<span>本章字数:{currentChapter.wordCount.toLocaleString()} 字</span>
</div>
</>
) : (
<div className="novel-content-empty">
<p>暂无章节内容</p>
</div>
)}
</main>
{/* Right: World View, Characters, Hooks & Anchors */}
<aside className="novel-sidebar-right">
<div className="novel-right-tabs">
<button
className={`novel-right-tab ${rightTab === 'worldview' ? 'active' : ''}`}
onClick={() => setRightTab('worldview')}
>世界观</button>
<button
className={`novel-right-tab ${rightTab === 'characters' ? 'active' : ''}`}
onClick={() => setRightTab('characters')}
>人物</button>
<button
className={`novel-right-tab ${rightTab === 'hooks' ? 'active' : ''}`}
onClick={() => setRightTab('hooks')}
>钩子&锚点</button>
</div>
<div className="novel-right-content">
{rightTab === 'worldview' && meta?.worldView && (
<div className="novel-worldview">
<p className="worldview-summary">{meta.worldView.summary}</p>
<div className="worldview-section">
<h4>世界规则</h4>
<ul>
{meta.worldView.rules.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
<div className="worldview-section">
<h4>时间线</h4>
<div className="worldview-timeline">
{meta.worldView.timeline.map((t, i) => (
<div key={i} className="timeline-item">
<span className="timeline-era">{t.era}</span>
<span className="timeline-event">{t.events}</span>
</div>
))}
</div>
</div>
</div>
)}
{rightTab === 'characters' && meta?.characters && (
<div className="novel-characters">
{meta.characters.map(char => (
<div key={char.id} className="character-card">
<div className="character-avatar">{char.name.charAt(0)}</div>
<div className="character-info">
<div className="character-name-row">
<span className="character-name">{char.name}</span>
<span className={`character-role ${char.role === '主角' ? 'main' : char.role === '反派' ? 'villain' : ''}`}>{char.role}</span>
</div>
<p className="character-desc">{char.description}</p>
{char.relationships.length > 0 && (
<div className="character-rels">
{char.relationships.map((r, i) => (
<span key={i} className="character-rel-tag">{r}</span>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
{rightTab === 'hooks' && meta && (
<div className="novel-hooks">
<div className="hooks-section">
<h4>故事钩子</h4>
{meta.hooks.map((h, i) => (
<div key={i} className="hook-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span>{h}</span>
</div>
))}
</div>
<div className="hooks-section">
<h4>故事锚点</h4>
{meta.anchors.map((a, i) => (
<div key={i} className="anchor-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>
<span>{a}</span>
</div>
))}
</div>
</div>
)}
</div>
</aside>
</div>
</div>
);
};
export default NovelPreviewPage;
+995
View File
@@ -0,0 +1,995 @@
/* ===== Pricing Page ===== */
.pricing-page {
padding: var(--space-3xl) var(--space-4xl);
max-width: 1200px;
width: 100%;
margin: 0 auto;
height: 100%;
overflow-y: auto;
animation: fadeIn var(--transition-normal);
scrollbar-width: thin;
scrollbar-color: transparent transparent;
box-sizing: border-box;
}
.pricing-page::-webkit-scrollbar {
width: 0;
}
.pricing-page:hover::-webkit-scrollbar {
width: 5px;
}
.pricing-page:hover::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.pricing-page:hover::-webkit-scrollbar-track {
background: transparent;
}
/* ===== Header ===== */
.pricing-header {
text-align: center;
margin-bottom: var(--space-3xl);
}
.pricing-title {
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-text-primary);
letter-spacing: -0.02em;
margin-bottom: var(--space-sm);
}
.pricing-subtitle {
font-size: var(--text-base);
color: var(--color-text-tertiary);
}
/* ===== Billing Cycle Tabs ===== */
.pricing-cycle-tabs {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
margin-bottom: var(--space-3xl);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
padding: 4px;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.pricing-cycle-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
position: relative;
}
.pricing-cycle-tab.active {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
}
.pricing-cycle-tab:hover:not(.active) {
color: var(--color-text-secondary);
}
.pricing-savings-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--color-success-light);
color: var(--color-success);
font-size: 10px;
font-weight: 700;
}
/* ===== Plans Grid ===== */
.pricing-plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-xl);
margin-bottom: var(--space-4xl);
max-width: 100%;
justify-content: center;
}
/* Single card centers naturally with auto-fit */
@media (min-width: 1025px) {
.pricing-plans-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.pricing-plans-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.pricing-plans-grid {
grid-template-columns: 1fr;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
}
/* ===== Plan Card ===== */
.pricing-plan-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-2xl);
padding: var(--space-3xl) var(--space-2xl);
display: flex;
flex-direction: column;
gap: var(--space-2xl);
position: relative;
overflow: hidden;
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.pricing-plan-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.pricing-plan--current {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.pricing-plan--popular {
border-color: #F59E0B;
}
/* Popular Ribbon */
.pricing-popular-ribbon {
position: absolute;
top: 16px;
right: -32px;
background: linear-gradient(135deg, #F59E0B, #EF4444);
color: #FFFFFF;
font-size: 11px;
font-weight: 700;
padding: 4px 40px;
transform: rotate(45deg);
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.pricing-popular-ribbon svg {
color: #FFFFFF;
flex-shrink: 0;
}
.pricing-current-badge {
position: absolute;
top: var(--space-lg);
left: var(--space-lg);
padding: 4px 12px;
border-radius: var(--radius-full);
background: var(--color-accent);
color: #FFFFFF;
font-size: 11px;
font-weight: 600;
}
/* Plan Head */
.pricing-plan-head {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-md);
}
.pricing-plan-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
}
.pricing-plan-price-row {
display: flex;
align-items: baseline;
gap: 2px;
}
.pricing-plan-price {
font-size: var(--text-3xl);
font-weight: 800;
color: var(--color-text-primary);
font-family: var(--font-mono);
letter-spacing: -0.02em;
}
.pricing-currency {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-tertiary);
}
.pricing-plan-cycle {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
.pricing-plan-savings {
font-size: var(--text-xs);
color: var(--color-success);
font-weight: 600;
padding: 2px 10px;
background: var(--color-success-light);
border-radius: var(--radius-full);
}
.pricing-plan-tokens {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-top: var(--space-xs);
}
/* Features */
.pricing-plan-features {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
.pricing-feature-item {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.pricing-feature-check {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: var(--color-success-light);
color: var(--color-success);
flex-shrink: 0;
}
/* Subscribe Button */
.pricing-subscribe-btn {
width: 100%;
padding: var(--space-md);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 700;
background: var(--color-accent);
color: #FFFFFF;
transition: all var(--transition-fast);
height: 48px;
}
.pricing-subscribe-btn:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
.pricing-btn--current {
background: var(--color-accent-light);
color: var(--color-accent);
cursor: default;
}
.pricing-btn--current:hover {
background: var(--color-accent-light);
box-shadow: none;
}
/* ===== Section ===== */
.pricing-section {
margin-bottom: var(--space-4xl);
}
.pricing-section-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.pricing-section-header svg {
color: var(--color-accent);
}
.pricing-section-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
}
/* ===== Services Grid ===== */
.pricing-services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.pricing-service-card {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--space-xl);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
gap: var(--space-lg);
transition: box-shadow var(--transition-fast);
}
.pricing-service-card:hover {
box-shadow: var(--shadow-md);
}
.pricing-service-info {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pricing-service-name {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
}
.pricing-service-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.5;
}
.pricing-service-action {
display: flex;
align-items: center;
justify-content: space-between;
}
.pricing-service-price {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-accent);
font-family: var(--font-mono);
}
.pricing-buy-btn {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
transition: all var(--transition-fast);
}
.pricing-buy-btn:hover {
background: var(--color-accent);
color: #FFFFFF;
}
/* ===== Task Management (expanded service) ===== */
.pricing-service-main {
display: flex;
flex-direction: column;
gap: var(--space-lg);
cursor: default;
}
.pricing-service-card.expanded {
grid-column: 1 / -1;
flex-direction: column;
}
.pricing-service-card.expanded .pricing-service-main {
flex-direction: row;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.pricing-service-card.expanded .pricing-service-info {
flex: 1;
}
.pricing-task-detail {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2xl);
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border-light);
animation: slideUp var(--transition-normal);
}
.task-detail-section {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.task-detail-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-primary);
}
/* Task Creation Options */
.task-options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
}
.task-option-card {
padding: var(--space-lg);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
transition: all var(--transition-fast);
}
.task-option-card:hover {
background: var(--color-accent-subtle);
border-color: var(--color-accent);
}
.task-option-header {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.task-option-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.5;
}
/* Task Triggers */
.task-triggers-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.task-trigger-card {
display: flex;
gap: var(--space-md);
padding: var(--space-lg);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
transition: all var(--transition-fast);
}
.task-trigger-card:hover {
background: var(--color-accent-subtle);
border-color: var(--color-accent);
}
.task-trigger-icon {
display: flex;
align-items: flex-start;
padding-top: 2px;
color: var(--color-accent);
flex-shrink: 0;
}
.task-trigger-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-trigger-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.task-trigger-desc {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.5;
}
.task-trigger-example {
font-size: 11px;
color: var(--color-text-tertiary);
font-style: italic;
padding-top: 2px;
}
@media (max-width: 768px) {
.pricing-task-detail {
grid-template-columns: 1fr;
}
.task-options-grid {
grid-template-columns: 1fr;
}
}
/* ===== Coupons ===== */
.pricing-coupons-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
}
.pricing-coupon-card {
display: flex;
border-radius: var(--radius-xl);
overflow: hidden;
border: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
transition: box-shadow var(--transition-fast);
}
.pricing-coupon-card:hover {
box-shadow: var(--shadow-md);
}
.pricing-coupon--used {
opacity: 0.5;
}
.pricing-coupon-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl) var(--space-xl);
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
min-width: 120px;
border-right: 2px dashed var(--color-border);
}
.pricing-coupon-value {
font-size: var(--text-xl);
font-weight: 800;
color: #D97706;
font-family: var(--font-mono);
}
.pricing-coupon-type {
font-size: var(--text-xs);
color: #B45309;
margin-top: var(--space-xs);
font-weight: 500;
}
.pricing-coupon-right {
flex: 1;
padding: var(--space-lg) var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pricing-coupon-code {
font-size: var(--text-base);
font-weight: 800;
color: var(--color-text-primary);
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
.pricing-coupon-desc {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.pricing-coupon-valid {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-top: var(--space-xs);
}
.pricing-coupon-valid svg {
color: var(--color-text-tertiary);
}
.pricing-coupon-status {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
background: var(--color-success-light);
color: var(--color-success);
margin-top: var(--space-sm);
}
.pricing-coupon--used .pricing-coupon-status {
background: var(--color-bg-tertiary);
color: var(--color-text-disabled);
}
.pricing-empty {
text-align: center;
color: var(--color-text-disabled);
grid-column: 1 / -1;
padding: var(--space-3xl);
}
/* ===== Payment Methods ===== */
.pricing-payment-row {
display: flex;
gap: var(--space-lg);
}
.pricing-payment-method {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg) var(--space-2xl);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
background: var(--color-bg-secondary);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
.pricing-payment-method:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.pricing-payment-method svg {
color: var(--color-accent);
}
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.pricing-page {
padding: var(--space-xl) var(--space-lg);
}
.pricing-plans-grid {
grid-template-columns: 1fr;
max-width: 480px;
margin-left: auto;
margin-right: auto;
margin-bottom: var(--space-4xl);
}
.pricing-services-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) {
.pricing-services-grid,
.pricing-coupons-row {
grid-template-columns: 1fr;
}
.pricing-payment-row {
flex-direction: column;
}
.pricing-plan-card {
padding: var(--space-xl);
}
.pricing-services-grid-v2 {
grid-template-columns: 1fr;
}
}
/* ===== V2 增值服务卡片(四大类) ===== */
.pricing-services-grid-v2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xl);
}
.pricing-service-card-v2 {
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
gap: var(--space-lg);
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.pricing-service-card-v2:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-accent);
}
.pricing-service-card-v2-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
flex-shrink: 0;
}
.pricing-service-card-v2-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
flex: 1;
}
.pricing-service-card-v2-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
}
.pricing-service-card-v2-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.pricing-service-card-v2-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.6;
margin: 0;
}
.pricing-service-card-v2-features {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: var(--space-xs);
}
.pricing-service-card-v2-feature-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 500;
}
.pricing-service-card-v2-action {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-md);
border-top: 1px solid var(--color-border-light);
}
.pricing-service-card-v2-price {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-accent);
font-family: var(--font-mono);
}
.pricing-service-card-v2-btn {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: #FFFFFF;
background: var(--color-accent);
transition: all var(--transition-fast);
}
.pricing-service-card-v2-btn:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
/* ===== Modal ===== */
.pricing-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn var(--transition-normal);
}
.pricing-modal {
background: #FFFFFF;
border-radius: var(--radius-2xl);
width: 500px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
padding: var(--space-3xl);
box-shadow: var(--shadow-xl);
}
.pricing-modal-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-xs) 0;
}
.pricing-modal-subtitle {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
margin: 0 0 var(--space-2xl) 0;
}
.pricing-modal-app-type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.pricing-modal-app-type-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xl) var(--space-md);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.pricing-modal-app-type-card:hover {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.pricing-modal-app-type-card.active {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
box-shadow: 0 0 0 1px var(--color-accent);
}
.pricing-modal-app-type-label {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
}
.pricing-modal-app-type-desc {
font-size: 11px;
color: var(--color-text-tertiary);
line-height: 1.4;
}
.pricing-modal-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-bottom: var(--space-2xl);
}
.pricing-modal-form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pricing-modal-form-group label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.pricing-modal-form-group label .required {
color: #EF4444;
}
.pricing-modal-form-group input,
.pricing-modal-form-group textarea {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-bg-secondary);
outline: none;
transition: border-color var(--transition-fast);
font-family: inherit;
}
.pricing-modal-form-group input:focus,
.pricing-modal-form-group textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-subtle);
}
.pricing-modal-actions {
display: flex;
gap: var(--space-md);
justify-content: flex-end;
}
.pricing-modal-btn-cancel {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
background: transparent;
transition: all var(--transition-fast);
}
.pricing-modal-btn-cancel:hover {
background: var(--color-bg-tertiary);
}
.pricing-modal-btn-confirm {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: #FFFFFF;
background: var(--color-accent);
border: none;
transition: all var(--transition-fast);
}
.pricing-modal-btn-confirm:hover:not(:disabled) {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
.pricing-modal-btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+662
View File
@@ -0,0 +1,662 @@
import React, { useState, useEffect, useCallback } from 'react';
import { paymentAPI } from '@/api';
import { useAuth } from '@/contexts/AuthContext';
import './PricingPage.css';
// ---- SVG Icons ----
const IconCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<polyline points="20 6 9 17 4 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconService = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
<line x1="9" y1="9" x2="15" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="9" y1="13" x2="13" y2="13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="9" y1="17" x2="11" y2="17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
const IconTag = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
const IconCoupon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2" stroke="currentColor" strokeWidth="1.6" />
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" strokeWidth="1.6" strokeDasharray="2 2" />
<circle cx="6" cy="12" r="3" stroke="currentColor" strokeWidth="1.2" />
<text x="18" y="13.5" textAnchor="middle" fontSize="5" fontWeight="800" fill="currentColor">OFF</text>
</svg>
);
const IconClock = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconSparkles = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 2l1.5 5h5l-4 3 1.5 5-4-3-4 3 1.5-5-4-3h5L12 2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
const IconAppCustom = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
const IconPlatform = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<rect x="2" y="9" width="20" height="13" rx="2" stroke="currentColor" strokeWidth="1.6" />
<path d="M6 9V7a6 6 0 0112 0v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="16" r="2" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
const IconDataCustom = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" strokeWidth="1.6" />
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" stroke="currentColor" strokeWidth="1.6" />
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2 2" />
</svg>
);
const IconPrivateProject = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path d="M3 21h18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M5 21V7l7-4 7 4v14" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M9 14v7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M15 11v10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
const IconWechat = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M8 12a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" fill="currentColor" />
<path d="M15 10.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" fill="currentColor" />
<path d="M21 13.5c0 3.6-3.7 6.5-8.3 6.5-.8 0-1.5-.1-2.2-.2L7 21l1.3-3.2C6.5 16.5 5 15.1 5 13.5 5 9.9 8.7 7 13.3 7S21 9.9 21 13.5z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
const IconAlipay = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="2" y="4" width="20" height="16" rx="3" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 14.5c1.5-2.5 3.5-4 5.5-4.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<path d="M7 12l4-3.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<circle cx="14" cy="14" r="1" fill="currentColor" />
<text x="12" y="17" textAnchor="middle" fontSize="5" fontWeight="700" fill="currentColor">Alipay</text>
</svg>
);
function formatYuan(n: number): string {
return n.toLocaleString('en-US');
}
interface ServiceItem {
id: string;
name: string;
description: string;
type: string;
price: number;
features?: string[];
assigned_agent_id?: string;
}
interface CouponItem {
id: string;
code: string;
type: string;
value: number;
min_amount: number;
expire_at: string;
status: string;
}
const SERVICE_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
app_custom: { label: '应用定制', icon: <IconAppCustom />, color: '#4F46E5', bg: '#EEF2FF' },
platform_proxy: { label: '平台代理', icon: <IconPlatform />, color: '#059669', bg: '#ECFDF5' },
data_custom: { label: '数据定制', icon: <IconDataCustom />, color: '#D97706', bg: '#FFFBEB' },
private_project: { label: '私有化项目', icon: <IconPrivateProject />, color: '#7C3AED', bg: '#F5F3FF' },
};
const PricingPage: React.FC = () => {
const { user } = useAuth();
const [billingCycle, setBillingCycle] = useState<'month' | 'quarter' | 'year'>('month');
const [plans, setPlans] = useState<any[]>([]);
const [services, setServices] = useState<ServiceItem[]>([]);
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [loading, setLoading] = useState(true);
const [subscribeMsg, setSubscribeMsg] = useState('');
// Modal states
const [showModal, setShowModal] = useState<string | null>(null); // service type or null
const [appCustomType, setAppCustomType] = useState<string>('skill'); // skill/agent/mcp
const [platformForm, setPlatformForm] = useState({ companyName: '', contactName: '', usage: '' });
const [dataRequestForm, setDataRequestForm] = useState({ title: '', description: '', requirements: '' });
const [privateProjectForm, setPrivateProjectForm] = useState({ title: '', description: '', requirements: '' });
const [submitLoading, setSubmitLoading] = useState(false);
const currentPlanLevel = user?.membership_level ?? 0;
const loadData = useCallback(async () => {
try {
setLoading(true);
const [plansRes, servicesRes, couponsRes] = await Promise.all([
paymentAPI.getPlans(),
paymentAPI.getServices(),
paymentAPI.getCoupons(),
]);
if (plansRes.code === 0) setPlans(plansRes.data);
if (servicesRes.code === 0) setServices(servicesRes.data);
if (couponsRes.code === 0) setCoupons(couponsRes.data);
} catch (e) {
console.error('加载付费数据失败', e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const getPrice = (plan: any, cycle: typeof billingCycle) => {
const base = Number(plan.price) || 0;
switch (cycle) {
case 'quarter': return Math.round(base * 2.7);
case 'year': return Math.round(base * 10);
default: return base;
}
};
const getCycleLabel = (cycle: typeof billingCycle) => {
switch (cycle) { case 'month': return '/月'; case 'quarter': return '/季'; case 'year': return '/年'; }
};
const handleSubscribe = async (plan: any) => {
try {
setSubscribeMsg('');
const res = await paymentAPI.createOrder({
item_id: plan.id,
order_type: 'membership',
});
if (res.code === 0) {
const payRes = await paymentAPI.payOrder(res.data.id);
if (payRes.code === 0) {
setSubscribeMsg('成功订阅 ' + plan.name + '!');
await loadData();
}
}
} catch (e: any) {
setSubscribeMsg('订阅失败:' + (e.message || '未知错误'));
}
};
// 应用定制服务 - 提交
const handleAppCustomSubmit = async (serviceId: string) => {
try {
setSubmitLoading(true);
const res = await paymentAPI.createCustomOrder({
service_id: serviceId,
description: '应用定制服务 - ' + (appCustomType === 'skill' ? 'Skills' : appCustomType === 'agent' ? 'Agent' : 'MCP') + ' 类型',
budget: 0,
contact_info: '',
});
if (res.code === 0) {
setSubscribeMsg('应用定制服务已提交!即将跳转到定制对话窗口...');
setShowModal(null);
// 跳转到聊天页面,带定制服务ID
setTimeout(() => {
window.location.hash = '#/chat?service_id=' + res.data.id;
}, 1500);
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
// 平台代理申请 - 提交
const handlePlatformProxySubmit = async (serviceId: string) => {
if (!platformForm.companyName.trim()) {
setSubscribeMsg('请填写公司名称');
return;
}
try {
setSubmitLoading(true);
const res = await paymentAPI.createServiceRequest({
service_id: serviceId,
title: '平台代理申请 - ' + platformForm.companyName,
description: '公司名称: ' + platformForm.companyName + '\n联系人: ' + platformForm.contactName + '\n用途说明: ' + platformForm.usage,
requirements: {
company_name: platformForm.companyName,
contact_name: platformForm.contactName,
usage: platformForm.usage,
},
});
if (res.code === 0) {
setSubscribeMsg('平台代理申请已成功提交!工单号: ' + res.data.id);
setShowModal(null);
setPlatformForm({ companyName: '', contactName: '', usage: '' });
await loadData();
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
// 数据定制服务 - 提交
const handleDataCustomSubmit = async (serviceId: string) => {
if (!dataRequestForm.title.trim()) {
setSubscribeMsg('请填写需求标题');
return;
}
try {
setSubmitLoading(true);
const res = await paymentAPI.createServiceRequest({
service_id: serviceId,
title: dataRequestForm.title,
description: dataRequestForm.description,
requirements: { detail: dataRequestForm.requirements },
});
if (res.code === 0) {
setSubscribeMsg('数据定制需求已成功提交!工单号: ' + res.data.id);
setShowModal(null);
setDataRequestForm({ title: '', description: '', requirements: '' });
await loadData();
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
// 私有化项目服务 - 提交
const handlePrivateProjectSubmit = async (serviceId: string) => {
if (!privateProjectForm.title.trim()) {
setSubscribeMsg('请填写咨询标题');
return;
}
try {
setSubmitLoading(true);
const res = await paymentAPI.createServiceRequest({
service_id: serviceId,
title: privateProjectForm.title,
description: privateProjectForm.description,
requirements: { detail: privateProjectForm.requirements },
});
if (res.code === 0) {
setSubscribeMsg('私有化项目咨询已成功提交!工单号: ' + res.data.id);
setShowModal(null);
setPrivateProjectForm({ title: '', description: '', requirements: '' });
await loadData();
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
const getServiceAction = (svc: ServiceItem) => {
switch (svc.type) {
case 'app_custom':
return { label: '前往定制', action: () => setShowModal('app_custom_' + svc.id) };
case 'platform_proxy':
return { label: '立即申请', action: () => setShowModal('platform_proxy_' + svc.id) };
case 'data_custom':
return { label: '提交需求', action: () => setShowModal('data_custom_' + svc.id) };
case 'private_project':
return { label: '咨询方案', action: () => setShowModal('private_project_' + svc.id) };
default:
return { label: '了解更多', action: () => {} };
}
};
if (loading) return <div className="pricing-page"><p style={{textAlign:'center',marginTop:60}}>加载中...</p></div>;
return (
<div className="pricing-page">
<div className="pricing-header">
<h1 className="pricing-title">订阅付费中心</h1>
<p className="pricing-subtitle">选择最适合你的方案,解锁 AI 无限可能</p>
</div>
<div className="pricing-cycle-tabs">
{(['month', 'quarter', 'year'] as const).map((cycle) => (
<button
key={cycle}
className={`pricing-cycle-tab ${billingCycle === cycle ? 'active' : ''}`}
onClick={() => setBillingCycle(cycle)}
>
{cycle === 'month' ? '月付' : cycle === 'quarter' ? '季付' : '年付'}
{cycle === 'year' && <span className="pricing-savings-badge">84折</span>}
</button>
))}
</div>
{subscribeMsg && <div className="pricing-subscribe-msg">{subscribeMsg}</div>}
<div className="pricing-plans-grid">
{plans.map((plan: any, idx: number) => {
const isCurrent = currentPlanLevel === plan.level;
const price = getPrice(plan, billingCycle);
const isPopular = idx === 1;
return (
<div
key={plan.id}
className={`pricing-plan-card ${isCurrent ? 'pricing-plan--current' : ''} ${isPopular ? 'pricing-plan--popular' : ''}`}
>
{isPopular && (
<div className="pricing-popular-ribbon"><IconSparkles /><span>最受欢迎</span></div>
)}
{isCurrent && <div className="pricing-current-badge">当前方案</div>}
<div className="pricing-plan-head">
<span className="pricing-plan-name">{plan.name}</span>
<div className="pricing-plan-price-row">
<span className="pricing-plan-price">
<span className="pricing-currency">$</span>{price === 0 ? '0' : formatYuan(price)}
</span>
<span className="pricing-plan-cycle">{getCycleLabel(billingCycle)}</span>
</div>
<p className="pricing-plan-tokens">日赠 {formatYuan(plan.daily_free_tokens)} Tokens</p>
</div>
<div className="pricing-plan-features">
{(plan.features || []).map((feat: string, i: number) => (
<div key={i} className="pricing-feature-item">
<span className="pricing-feature-check"><IconCheck /></span>
<span>{feat}</span>
</div>
))}
</div>
<button
className={`pricing-subscribe-btn ${isCurrent ? 'pricing-btn--current' : ''}`}
onClick={() => !isCurrent && handleSubscribe(plan)}
>
{isCurrent ? '当前方案' : '立即订阅'}
</button>
</div>
);
})}
</div>
{/* 增值服务 - 四大类 */}
<div className="pricing-section">
<div className="pricing-section-header">
<IconService />
<h2 className="pricing-section-title">增值服务</h2>
</div>
<div className="pricing-services-grid-v2">
{services.map((svc) => {
const config = SERVICE_TYPE_CONFIG[svc.type] || SERVICE_TYPE_CONFIG.app_custom;
const actionInfo = getServiceAction(svc);
return (
<div key={svc.id} className="pricing-service-card-v2">
<div className="pricing-service-card-v2-icon" style={{ background: config.bg, color: config.color }}>
{config.icon}
</div>
<div className="pricing-service-card-v2-body">
<span className="pricing-service-card-v2-tag" style={{ color: config.color, background: config.bg }}>
{config.label}
</span>
<h4 className="pricing-service-card-v2-name">{svc.name}</h4>
<p className="pricing-service-card-v2-desc">{svc.description}</p>
{(svc.features && svc.features.length > 0) && (
<div className="pricing-service-card-v2-features">
{svc.features.map((feat: string, i: number) => (
<span key={i} className="pricing-service-card-v2-feature-tag">{feat}</span>
))}
</div>
)}
</div>
<div className="pricing-service-card-v2-action">
{svc.price > 0 ? (
<span className="pricing-service-card-v2-price">{formatYuan(svc.price)} 元起</span>
) : (
<span className="pricing-service-card-v2-price">免费咨询</span>
)}
<button
className="pricing-service-card-v2-btn"
onClick={actionInfo.action}
>
{actionInfo.label}
</button>
</div>
</div>
);
})}
{services.length === 0 && <p className="pricing-empty">暂无增值服务</p>}
</div>
</div>
{/* 优惠券 */}
<div className="pricing-section">
<div className="pricing-section-header">
<IconCoupon />
<h2 className="pricing-section-title">优惠券</h2>
</div>
<div className="pricing-coupons-row">
{coupons.map((cp) => {
const isPercent = cp.type === 'percent';
return (
<div key={cp.id} className="pricing-coupon-card">
<div className="pricing-coupon-left">
<span className="pricing-coupon-value">{isPercent ? `${cp.value}%` : `$${cp.value}`}</span>
<span className="pricing-coupon-type">{isPercent ? '折扣券' : '立减券'}</span>
</div>
<div className="pricing-coupon-right">
<div className="pricing-coupon-code">{cp.code}</div>
<div className="pricing-coupon-desc">满 {formatYuan(cp.min_amount || 0)} 元可用</div>
<div className="pricing-coupon-valid"><IconClock /><span>有效期至 {cp.expire_at || '--'}</span></div>
<span className="pricing-coupon-status">{cp.status === 'active' ? '可使用' : cp.status}</span>
</div>
</div>
);
})}
{coupons.length === 0 && <p className="pricing-empty">暂无可用优惠券</p>}
</div>
</div>
{/* 支付方式 */}
<div className="pricing-section">
<div className="pricing-section-header">
<IconTag />
<h2 className="pricing-section-title">支付方式</h2>
</div>
<div className="pricing-payment-row">
<div className="pricing-payment-method"><IconWechat /><span>微信支付</span></div>
<div className="pricing-payment-method"><IconAlipay /><span>支付宝</span></div>
</div>
</div>
{/* ===== Modal - 应用定制服务 ===== */}
{showModal && showModal.startsWith('app_custom_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">应用定制服务</h3>
<p className="pricing-modal-subtitle">请选择您需要的定制类型</p>
<div className="pricing-modal-app-type-grid">
{[
{ type: 'skill', label: 'Skills', desc: '自定义技能开发,扩展AI能力边界' },
{ type: 'agent', label: 'Agent', desc: '智能体定制,构建专属AI助手' },
{ type: 'mcp', label: 'MCP', desc: 'MCP协议开发,连接外部数据源' },
].map((item) => (
<div
key={item.type}
className={`pricing-modal-app-type-card ${appCustomType === item.type ? 'active' : ''}`}
onClick={() => setAppCustomType(item.type)}
>
<div className="pricing-modal-app-type-label">{item.label}</div>
<div className="pricing-modal-app-type-desc">{item.desc}</div>
</div>
))}
</div>
<div className="pricing-modal-actions">
<button
className="pricing-modal-btn-cancel"
onClick={() => setShowModal(null)}
>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handleAppCustomSubmit(showModal.replace('app_custom_', ''))}
>
{submitLoading ? '提交中...' : '确认并前往定制'}
</button>
</div>
</div>
</div>
)}
{/* ===== Modal - 平台代理申请 ===== */}
{showModal && showModal.startsWith('platform_proxy_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">平台代理申请</h3>
<p className="pricing-modal-subtitle">填写以下信息,提交代理申请</p>
<div className="pricing-modal-form">
<div className="pricing-modal-form-group">
<label>公司名称 <span className="required">*</span></label>
<input
type="text"
placeholder="请输入公司名称"
value={platformForm.companyName}
onChange={e => setPlatformForm({ ...platformForm, companyName: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>联系人</label>
<input
type="text"
placeholder="请输入联系人姓名"
value={platformForm.contactName}
onChange={e => setPlatformForm({ ...platformForm, contactName: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>用途说明</label>
<textarea
placeholder="请简要说明代理用途"
rows={3}
value={platformForm.usage}
onChange={e => setPlatformForm({ ...platformForm, usage: e.target.value })}
/>
</div>
</div>
<div className="pricing-modal-actions">
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handlePlatformProxySubmit(showModal.replace('platform_proxy_', ''))}
>{submitLoading ? '提交中...' : '提交申请'}</button>
</div>
</div>
</div>
)}
{/* ===== Modal - 数据定制服务 ===== */}
{showModal && showModal.startsWith('data_custom_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">数据定制服务</h3>
<p className="pricing-modal-subtitle">请描述您的数据定制需求</p>
<div className="pricing-modal-form">
<div className="pricing-modal-form-group">
<label>需求标题 <span className="required">*</span></label>
<input
type="text"
placeholder="请输入需求标题"
value={dataRequestForm.title}
onChange={e => setDataRequestForm({ ...dataRequestForm, title: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>需求描述</label>
<textarea
placeholder="请描述您的数据定制需求详情"
rows={3}
value={dataRequestForm.description}
onChange={e => setDataRequestForm({ ...dataRequestForm, description: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>具体要求</label>
<textarea
placeholder="请说明数据格式、数量、交付时间等具体要求"
rows={2}
value={dataRequestForm.requirements}
onChange={e => setDataRequestForm({ ...dataRequestForm, requirements: e.target.value })}
/>
</div>
</div>
<div className="pricing-modal-actions">
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handleDataCustomSubmit(showModal.replace('data_custom_', ''))}
>{submitLoading ? '提交中...' : '提交需求'}</button>
</div>
</div>
</div>
)}
{/* ===== Modal - 私有化项目服务 ===== */}
{showModal && showModal.startsWith('private_project_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">私有化项目服务</h3>
<p className="pricing-modal-subtitle">请描述您的私有化部署需求,我们将为您提供专业方案</p>
<div className="pricing-modal-form">
<div className="pricing-modal-form-group">
<label>项目标题 <span className="required">*</span></label>
<input
type="text"
placeholder="请输入项目标题"
value={privateProjectForm.title}
onChange={e => setPrivateProjectForm({ ...privateProjectForm, title: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>项目描述</label>
<textarea
placeholder="请描述您的私有化部署需求"
rows={3}
value={privateProjectForm.description}
onChange={e => setPrivateProjectForm({ ...privateProjectForm, description: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>具体要求</label>
<textarea
placeholder="请说明部署规模、安全要求、时间计划等"
rows={2}
value={privateProjectForm.requirements}
onChange={e => setPrivateProjectForm({ ...privateProjectForm, requirements: e.target.value })}
/>
</div>
</div>
<div className="pricing-modal-actions">
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handlePrivateProjectSubmit(showModal.replace('private_project_', ''))}
>{submitLoading ? '提交中...' : '提交咨询'}</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PricingPage;
File diff suppressed because it is too large. Load diff
+920
View File
@@ -0,0 +1,920 @@
import React, { useState, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { profileAPI } from '@/api';
import { mockOrders, mockMembershipPlans, mockUserCoupons, mockApps, mockOperationLogs } from '@/mock/data';
import type { Order, UserCoupon, MarketApp, OperationLog } from '@/types';
import './ProfilePage.css';
// ===== Mock API Keys =====
interface ApiKey {
id: string;
prefix: string;
fullKey: string;
createdAt: string;
status: 'active' | 'disabled';
}
const mockApiKeys: ApiKey[] = [
{ id: 'ak1', prefix: 'sk-aiflow-****a1b2', fullKey: 'sk-aiflow-u1-x8j3a1b2c3d4e5f6g7h8i9j0k1l2', createdAt: '2026-03-15T08:00:00Z', status: 'active' },
{ id: 'ak2', prefix: 'sk-aiflow-****c3d4', fullKey: 'sk-aiflow-u1-y9k4c3d4e5f6g7h8i9j0k1l2m3n4', createdAt: '2026-04-20T10:00:00Z', status: 'active' },
{ id: 'ak3', prefix: 'sk-aiflow-****e5f6', fullKey: 'sk-aiflow-u1-z0l5e5f6g7h8i9j0k1l2m3n4o5p6', createdAt: '2026-05-10T14:00:00Z', status: 'disabled' },
];
// ===== SVG Icons =====
const SvgIcons = {
// Password
lock: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
),
phone: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
),
mail: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
),
log: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
),
order: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="12" y1="17" x2="8" y2="17"/>
</svg>
),
member: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
),
coupon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12V20H4V12"/><path d="M22 7H2v5h20V7z"/><circle cx="12" cy="7" r="2"/><circle cx="12" cy="17" r="2"/><line x1="12" y1="9" x2="12" y2="15"/>
</svg>
),
apps: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/>
</svg>
),
key: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
),
copy: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
),
check: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
eye: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
),
calendar: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
),
diamond: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="4" transform="rotate(45 12 12)"/>
</svg>
),
close: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
checkCircle: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
</svg>
),
};
// ===== Tabs =====
type ProfileTab = 'security' | 'orders' | 'membership' | 'coupons' | 'apps' | 'apikeys';
interface TabDef {
key: ProfileTab;
label: string;
icon: JSX.Element;
}
const tabs: TabDef[] = [
{ key: 'security', label: '账号安全', icon: SvgIcons.lock },
{ key: 'orders', label: '我的订单', icon: SvgIcons.order },
{ key: 'membership', label: '我的会员', icon: SvgIcons.member },
{ key: 'coupons', label: '我的优惠券', icon: SvgIcons.coupon },
{ key: 'apps', label: '我的已购应用', icon: SvgIcons.apps },
{ key: 'apikeys', label: 'API密钥管理', icon: SvgIcons.key },
];
// ===== Helper =====
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function formatShortDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// ===== ProfilePage =====
const ProfilePage: React.FC = () => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<ProfileTab>('security');
// Security form states
const [passwordForm, setPasswordForm] = useState({ old: '', newPass: '', confirm: '' });
const [bindForm, setBindForm] = useState({ type: 'phone' as 'phone' | 'email', value: '' });
// Order filter
const [orderFilter, setOrderFilter] = useState<Order['status'] | 'all'>('all');
// Coupon filter
const [couponFilter, setCouponFilter] = useState<UserCoupon['status'] | 'all'>('all');
// API Key
const [apiKeys, setApiKeys] = useState<ApiKey[]>(mockApiKeys);
const [showKeyId, setShowKeyId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
if (!user) return null;
const memberPlan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
// User's purchased apps (via mockOrders -> app type orders)
const purchasedAppOrders = useMemo(() => mockOrders.filter(o => o.userId === 'u1' && o.orderType === 'app' && o.status === 'paid'), []);
const purchasedApps: MarketApp[] = useMemo(() => {
return purchasedAppOrders
.map(o => mockApps.find(a => a.name === o.itemName))
.filter((a): a is MarketApp => !!a);
}, [purchasedAppOrders]);
// Login logs (recent 5)
const loginLogs = useMemo(() =>
mockOperationLogs.filter(l => l.userId === 'u1').slice(0, 5),
[]);
// Filter orders
const filteredOrders = useMemo(() => {
const userOrders = mockOrders.filter(o => o.userId === 'u1');
return orderFilter === 'all' ? userOrders : userOrders.filter(o => o.status === orderFilter);
}, [orderFilter]);
// Filter coupons
const filteredCoupons = useMemo(() => {
return couponFilter === 'all' ? mockUserCoupons : mockUserCoupons.filter(c => c.status === couponFilter);
}, [couponFilter]);
const handleCopyKey = (key: ApiKey) => {
navigator.clipboard.writeText(key.fullKey);
setCopiedId(key.id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleGenerateKey = () => {
const newKey: ApiKey = {
id: `ak${Date.now()}`,
prefix: `sk-aiflow-****${Math.random().toString(36).slice(2, 6)}`,
fullKey: `sk-aiflow-u1-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`,
createdAt: new Date().toISOString(),
status: 'active',
};
setApiKeys(prev => [newKey, ...prev]);
};
const handleCopyPrefix = (key: ApiKey) => {
navigator.clipboard.writeText(key.prefix);
setCopiedId(key.id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleToggleKeyStatus = (keyId: string) => {
setApiKeys(prev => prev.map(k =>
k.id === keyId ? { ...k, status: k.status === 'active' ? 'disabled' as const : 'active' as const } : k
));
};
const orderStatusLabels: Record<Order['status'] | 'all', string> = {
all: '全部',
pending: '待支付',
paid: '已支付',
refunded: '已退款',
};
const orderStatusColors: Record<Order['status'], string> = {
pending: 'var(--color-warning)',
paid: 'var(--color-success)',
refunded: 'var(--color-text-tertiary)',
};
const orderTypeLabels: Record<Order['orderType'], string> = {
membership: '会员',
app: '应用',
service: '服务',
};
const couponStatusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }> = {
unused: { label: '未使用', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
used: { label: '已使用', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
expired: { label: '已过期', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
};
const getCouponDescription = (coupon: UserCoupon['coupon']) => {
if (coupon.type === 'fixed') return `满${coupon.minAmount}减${coupon.value}`;
return `${coupon.value}折券,满${coupon.minAmount}可用`;
};
const getLevelGradient = (level: number) => {
const gradients: Record<number, string> = {
0: 'linear-gradient(135deg, #8B919E, #BFC4CE)',
2: 'linear-gradient(135deg, #5B5FED, #8B8DFF)',
3: 'linear-gradient(135deg, #F59E0B, #FBBF24)',
};
return gradients[level] || gradients[0];
};
return (
<div className="profile-page">
{/* Header */}
<div className="profile-header">
<div className="profile-user-card">
<div
className="profile-avatar"
style={{ background: getLevelGradient(user.membership?.level ?? 0) }}
>
<span>{user.username.charAt(0).toUpperCase()}</span>
</div>
<div className="profile-user-info">
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
{user.membership && (
<span
className="profile-level-badge"
style={{ background: getLevelGradient(user.membership.level) }}
>
{user.membership.planName}
</span>
)}
</div>
{user.membership && (
<p className="profile-expire">
<span className="profile-expire-icon">{SvgIcons.calendar}</span>
会员到期:{formatShortDate(user.membership.expireAt)}
</p>
)}
</div>
</div>
</div>
{/* Body */}
<div className="profile-body">
{/* Left Tabs */}
<nav className="profile-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`profile-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
<span className="profile-tab-icon">{tab.icon}</span>
<span className="profile-tab-label">{tab.label}</span>
</button>
))}
</nav>
{/* Right Content */}
<div className="profile-content">
{activeTab === 'security' && (
<SecurityContent
passwordForm={passwordForm}
setPasswordForm={setPasswordForm}
bindForm={bindForm}
setBindForm={setBindForm}
loginLogs={loginLogs}
/>
)}
{activeTab === 'orders' && (
<OrdersContent
orders={filteredOrders}
filter={orderFilter}
setFilter={setOrderFilter}
statusLabels={orderStatusLabels}
statusColors={orderStatusColors}
typeLabels={orderTypeLabels}
/>
)}
{activeTab === 'membership' && (
<MembershipContent user={user} memberPlan={memberPlan} />
)}
{activeTab === 'coupons' && (
<CouponsContent
coupons={filteredCoupons}
filter={couponFilter}
setFilter={setCouponFilter}
getDescription={getCouponDescription}
statusConfig={couponStatusConfig}
/>
)}
{activeTab === 'apps' && (
<AppsContent apps={purchasedApps} purchasedOrders={purchasedAppOrders} />
)}
{activeTab === 'apikeys' && (
<ApiKeysContent
apiKeys={apiKeys}
showKeyId={showKeyId}
setShowKeyId={setShowKeyId}
copiedId={copiedId}
onCopy={handleCopyKey}
onCopyPrefix={handleCopyPrefix}
onGenerate={handleGenerateKey}
onToggleStatus={handleToggleKeyStatus}
/>
)}
</div>
</div>
</div>
);
};
// ===== Security Content =====
interface SecurityContentProps {
passwordForm: { old: string; newPass: string; confirm: string };
setPasswordForm: React.Dispatch<React.SetStateAction<{ old: string; newPass: string; confirm: string }>>;
bindForm: { type: 'phone' | 'email'; value: string };
setBindForm: React.Dispatch<React.SetStateAction<{ type: 'phone' | 'email'; value: string }>>;
loginLogs: OperationLog[];
}
const SecurityContent: React.FC<SecurityContentProps> = ({ passwordForm, setPasswordForm, bindForm, setBindForm, loginLogs }) => {
const [pwdMsg, setPwdMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwdLoading, setPwdLoading] = useState(false);
const handleChangePassword = async () => {
setPwdMsg(null);
if (!passwordForm.old.trim()) {
setPwdMsg({ type: 'error', text: '请输入旧密码' });
return;
}
if (!passwordForm.newPass.trim() || passwordForm.newPass.length < 6) {
setPwdMsg({ type: 'error', text: '新密码长度至少为6位' });
return;
}
if (passwordForm.newPass !== passwordForm.confirm) {
setPwdMsg({ type: 'error', text: '两次输入的新密码不一致' });
return;
}
setPwdLoading(true);
try {
await profileAPI.updatePassword({
old_password: passwordForm.old,
new_password: passwordForm.newPass,
});
setPwdMsg({ type: 'success', text: '密码修改成功' });
setPasswordForm({ old: '', newPass: '', confirm: '' });
} catch (e: any) {
setPwdMsg({ type: 'error', text: e.message || e.detail || '密码修改失败' });
} finally {
setPwdLoading(false);
}
};
return (
<div className="security-content">
{/* Change Password */}
<section className="profile-section">
<h3 className="section-title">
<span className="section-icon">{SvgIcons.lock}</span>
修改密码
</h3>
{pwdMsg && (
<div className={`form-message ${pwdMsg.type}`}>
{pwdMsg.text}
</div>
)}
<div className="form-grid">
<div className="form-item">
<label className="form-label-small">旧密码</label>
<input
type="password"
className="form-input"
placeholder="请输入旧密码"
value={passwordForm.old}
onChange={e => setPasswordForm(p => ({ ...p, old: e.target.value }))}
/>
</div>
<div className="form-item">
<label className="form-label-small">新密码</label>
<input
type="password"
className="form-input"
placeholder="请输入新密码(至少6位)"
value={passwordForm.newPass}
onChange={e => setPasswordForm(p => ({ ...p, newPass: e.target.value }))}
/>
</div>
<div className="form-item">
<label className="form-label-small">确认新密码</label>
<input
type="password"
className="form-input"
placeholder="请再次输入新密码"
value={passwordForm.confirm}
onChange={e => setPasswordForm(p => ({ ...p, confirm: e.target.value }))}
/>
</div>
<div className="form-item form-item-btn">
<button className="btn-primary" onClick={handleChangePassword} disabled={pwdLoading}>
{pwdLoading ? '修改中...' : '修改密码'}
</button>
</div>
</div>
</section>
{/* Bind Phone/Email */}
<section className="profile-section">
<h3 className="section-title">
<span className="section-icon">{SvgIcons.phone}</span>
绑定手机 / 邮箱
</h3>
<div className="bind-container">
<div className="bind-type-toggle">
<button
className={`bind-type-btn ${bindForm.type === 'phone' ? 'active' : ''}`}
onClick={() => setBindForm(f => ({ ...f, type: 'phone' }))}
>
{SvgIcons.phone}
<span>手机</span>
</button>
<button
className={`bind-type-btn ${bindForm.type === 'email' ? 'active' : ''}`}
onClick={() => setBindForm(f => ({ ...f, type: 'email' }))}
>
{SvgIcons.mail}
<span>邮箱</span>
</button>
</div>
<div className="bind-input-row">
<div className="form-item" style={{ flex: 1 }}>
<label className="form-label-small">
{bindForm.type === 'phone' ? '手机号' : '邮箱地址'}
</label>
<input
type={bindForm.type === 'phone' ? 'tel' : 'email'}
className="form-input"
placeholder={bindForm.type === 'phone' ? '请输入手机号' : '请输入邮箱地址'}
value={bindForm.value}
onChange={e => setBindForm(f => ({ ...f, value: e.target.value }))}
/>
</div>
<div className="form-item form-item-btn">
<label className="form-label-small">&nbsp;</label>
<button className="btn-secondary">获取验证码</button>
</div>
</div>
<div className="form-item form-item-btn">
<button className="btn-primary">确认绑定</button>
</div>
</div>
</section>
{/* Login Logs */}
<section className="profile-section">
<h3 className="section-title">
<span className="section-icon">{SvgIcons.log}</span>
最近登录日志
</h3>
<div className="simple-table-wrapper">
<table className="simple-table">
<thead>
<tr>
<th>操作</th>
<th>目标</th>
<th>IP地址</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{loginLogs.map(log => (
<tr key={log.id}>
<td>{log.action}</td>
<td className="td-muted">{log.target}</td>
<td className="td-mono">{log.ip}</td>
<td className="td-muted">{formatDate(log.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
};
// ===== Orders Content =====
interface OrdersContentProps {
orders: Order[];
filter: Order['status'] | 'all';
setFilter: React.Dispatch<React.SetStateAction<Order['status'] | 'all'>>;
statusLabels: Record<Order['status'] | 'all', string>;
statusColors: Record<Order['status'], string>;
typeLabels: Record<Order['orderType'], string>;
}
const OrdersContent: React.FC<OrdersContentProps> = ({ orders, filter, setFilter, statusLabels, statusColors, typeLabels }) => {
return (
<div className="tab-panel-content">
{/* Filter Tabs */}
<div className="filter-tabs">
{(Object.keys(statusLabels) as (Order['status'] | 'all')[]).map(key => (
<button
key={key}
className={`filter-tab ${filter === key ? 'active' : ''}`}
onClick={() => setFilter(key)}
>
{statusLabels[key]}
</button>
))}
</div>
{/* Table */}
<div className="simple-table-wrapper">
<table className="simple-table">
<thead>
<tr>
<th>订单号</th>
<th>类型</th>
<th>商品名</th>
<th>金额</th>
<th>支付方式</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id}>
<td className="td-mono">{order.id}</td>
<td>
<span className="order-type-tag">{typeLabels[order.orderType]}</span>
</td>
<td>{order.itemName}</td>
<td className="td-price">¥{order.amount.toFixed(2)}</td>
<td className="td-muted">{order.paymentMethod === 'wechat' ? '微信支付' : '支付宝'}</td>
<td>
<span className="status-dot" style={{ backgroundColor: statusColors[order.status] }} />
{statusLabels[order.status]}
</td>
<td className="td-muted">{formatDate(order.createdAt)}</td>
</tr>
))}
{orders.length === 0 && (
<tr><td colSpan={7} className="td-empty">暂无订单</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
};
// ===== Membership Content =====
interface MembershipContentProps {
user: NonNullable<ReturnType<typeof useAuth>['user']>;
memberPlan: typeof mockMembershipPlans[0] | undefined;
}
const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan }) => {
const [autoRenew, setAutoRenew] = useState(user.membership?.autoRenew ?? false);
// 使用 memberPlan 或从 user.membership 获取 level,判断是否付费会员
const effectiveLevel = memberPlan?.level ?? user.membership?.level ?? 0;
const effectiveName = memberPlan?.name || user.membership?.planName || '免费版';
const hasPaidMembership = effectiveLevel > 0;
return (
<div className="tab-panel-content">
<div className="membership-card">
<div className="membership-card-header">
<div className="membership-card-icon">
{SvgIcons.diamond}
</div>
<div className="membership-card-title">
<h2>{effectiveName}</h2>
{hasPaidMembership && (
<span className="membership-level-tag">Lv.{effectiveLevel}</span>
)}
</div>
</div>
{hasPaidMembership && user.membership && (
<>
{memberPlan && (
<div className="membership-stats">
<div className="membership-stat">
<span className="stat-label">每日可用Token</span>
<span className="stat-value">{memberPlan.dailyFreeTokens.toLocaleString()}</span>
</div>
<div className="membership-stat">
<span className="stat-label">会员到期</span>
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
</div>
<div className="membership-stat">
<span className="stat-label">月费</span>
<span className="stat-value">¥{memberPlan.price}/月</span>
</div>
</div>
)}
{!memberPlan && user.membership && (
<div className="membership-stats">
<div className="membership-stat">
<span className="stat-label">会员到期</span>
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
</div>
</div>
)}
{memberPlan && (
<div className="membership-features">
<h4 className="features-title">会员权益</h4>
<ul className="features-list">
{memberPlan.features.map((f, i) => (
<li key={i} className="feature-item">
<span className="feature-check">{SvgIcons.checkCircle}</span>
{f}
</li>
))}
</ul>
</div>
)}
<div className="membership-auto-renew">
<span className="auto-renew-label">自动续费</span>
<label className="toggle-switch">
<input
type="checkbox"
checked={autoRenew}
onChange={e => setAutoRenew(e.target.checked)}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="membership-actions">
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
</div>
</>
)}
{!hasPaidMembership && (
<div className="membership-empty">
<p>您当前使用的是免费版,升级会员享受更多权益</p>
<button
className="btn-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.location.hash = '#/pricing';
}}
>
开通会员
</button>
</div>
)}
</div>
</div>
);
};
// ===== Coupons Content =====
interface CouponsContentProps {
coupons: UserCoupon[];
filter: UserCoupon['status'] | 'all';
setFilter: React.Dispatch<React.SetStateAction<UserCoupon['status'] | 'all'>>;
getDescription: (coupon: UserCoupon['coupon']) => string;
statusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }>;
}
const CouponsContent: React.FC<CouponsContentProps> = ({ coupons, filter, setFilter, getDescription, statusConfig }) => {
const filterLabels: Record<UserCoupon['status'] | 'all', string> = {
all: '全部',
unused: '未使用',
used: '已使用',
expired: '已过期',
};
return (
<div className="tab-panel-content">
<div className="filter-tabs">
{(Object.keys(filterLabels) as (UserCoupon['status'] | 'all')[]).map(key => (
<button
key={key}
className={`filter-tab ${filter === key ? 'active' : ''}`}
onClick={() => setFilter(key)}
>
{filterLabels[key]}
</button>
))}
</div>
<div className="coupon-list">
{coupons.map(uc => {
const cfg = statusConfig[uc.status];
return (
<div key={uc.id} className={`coupon-item ${uc.status !== 'unused' ? 'coupon-disabled' : ''}`}>
<div className="coupon-left">
<div className="coupon-code">{uc.coupon.code}</div>
<div className="coupon-desc">{getDescription(uc.coupon)}</div>
<div className="coupon-date">
有效期:{formatShortDate(uc.coupon.validFrom)} ~ {formatShortDate(uc.coupon.validTo)}
</div>
</div>
<div className="coupon-right">
<span
className="coupon-status"
style={{ color: cfg.color, backgroundColor: cfg.bg }}
>
{cfg.label}
</span>
{uc.status === 'unused' && (
<button className="btn-coupon-use">去使用</button>
)}
</div>
</div>
);
})}
{coupons.length === 0 && (
<div className="content-empty">暂无优惠券</div>
)}
</div>
</div>
);
};
// ===== Apps Content =====
interface AppsContentProps {
apps: MarketApp[];
purchasedOrders: Order[];
}
const AppsContent: React.FC<AppsContentProps> = ({ apps, purchasedOrders }) => {
const appTypeLabels: Record<string, string> = {
skill: '技能',
agent: '智能体',
mcp: 'MCP',
};
const findOrderDate = (appName: string) => {
const order = purchasedOrders.find(o => o.itemName === appName);
return order ? formatDate(order.createdAt) : '';
};
return (
<div className="tab-panel-content">
<div className="apps-grid">
{apps.map(app => (
<div key={app.id} className="app-card">
<div className="app-card-header">
<div className="app-card-icon">
<span>{app.icon || app.name.charAt(0)}</span>
</div>
<div className="app-card-info">
<h4 className="app-card-name">{app.name}</h4>
<div className="app-card-meta">
<span className="app-type-tag">{appTypeLabels[app.type] || app.type}</span>
<span className="app-source-tag">{app.source === 'official' ? '官方' : '用户'}</span>
</div>
</div>
</div>
<p className="app-card-desc">{app.description}</p>
<div className="app-card-footer">
<span className="app-purchase-date">
{SvgIcons.calendar}
{findOrderDate(app.name)}
</span>
</div>
</div>
))}
{apps.length === 0 && (
<div className="content-empty">暂未购买任何应用</div>
)}
</div>
</div>
);
};
// ===== API Keys Content =====
interface ApiKeysContentProps {
apiKeys: ApiKey[];
showKeyId: string | null;
setShowKeyId: React.Dispatch<React.SetStateAction<string | null>>;
copiedId: string | null;
onCopy: (key: ApiKey) => void;
onCopyPrefix: (key: ApiKey) => void;
onGenerate: () => void;
onToggleStatus: (keyId: string) => void;
}
const ApiKeysContent: React.FC<ApiKeysContentProps> = ({
apiKeys, showKeyId, setShowKeyId, copiedId, onCopy, onCopyPrefix, onGenerate, onToggleStatus,
}) => {
return (
<div className="tab-panel-content">
<div className="apikey-header">
<div className="apikey-info">
<h3>API密钥</h3>
<p className="apikey-desc">使用API密钥访问AIFlow接口,请妥善保管您的密钥</p>
</div>
<button className="btn-primary" onClick={onGenerate}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
生成新API Key
</button>
</div>
<div className="apikey-list">
<div className="apikey-list-header">
<span>密钥前缀</span>
<span>创建时间</span>
<span>状态</span>
<span>操作</span>
</div>
{apiKeys.map(key => (
<div key={key.id} className="apikey-row">
<div className="apikey-prefix-col">
<code className="apikey-prefix">{key.prefix}</code>
{showKeyId === key.id && (
<div className="apikey-full-reveal">
<code>{key.fullKey}</code>
</div>
)}
</div>
<span className="apikey-date">{formatDate(key.createdAt)}</span>
<span>
<span className={`apikey-status ${key.status}`}>
{key.status === 'active' ? '活跃' : '已禁用'}
</span>
</span>
<div className="apikey-actions">
<button
className="apikey-action-btn"
onClick={() => {
if (showKeyId === key.id) {
setShowKeyId(null);
} else {
setShowKeyId(key.id);
onCopyPrefix(key);
}
}}
title={showKeyId === key.id ? '隐藏密钥' : '显示密钥'}
>
{SvgIcons.eye}
</button>
<button
className="apikey-action-btn"
onClick={() => {
if (showKeyId === key.id || showKeyId === null) {
onCopy(key);
} else {
onCopyPrefix(key);
}
}}
title="复制密钥"
>
{copiedId === key.id ? SvgIcons.check : SvgIcons.copy}
</button>
<button
className={`apikey-action-btn ${key.status === 'active' ? 'danger' : ''}`}
onClick={() => onToggleStatus(key.id)}
title={key.status === 'active' ? '禁用' : '启用'}
>
{key.status === 'active' ? '禁用' : '启用'}
</button>
</div>
</div>
))}
</div>
</div>
);
};
export default ProfilePage;
@@ -0,0 +1,480 @@
/* ===== Token Dashboard Page ===== */
.tkd-page {
padding: var(--space-3xl) var(--space-4xl);
max-width: 1280px;
margin: 0 auto;
animation: fadeIn var(--transition-normal);
}
/* ===== Header ===== */
.tkd-header {
margin-bottom: var(--space-3xl);
}
.tkd-title {
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-text-primary);
letter-spacing: -0.02em;
margin-bottom: var(--space-xs);
}
.tkd-subtitle {
font-size: var(--text-base);
color: var(--color-text-tertiary);
}
/* ===== Warning Banner ===== */
.tkd-warning-banner {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--color-warning-light);
border: 1px solid rgba(232, 146, 44, 0.2);
border-left: 4px solid var(--color-warning);
border-radius: var(--radius-lg);
margin-bottom: var(--space-2xl);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.tkd-warning-banner svg {
color: var(--color-warning);
flex-shrink: 0;
}
.tkd-warning-banner strong {
color: var(--color-warning);
font-weight: 700;
}
/* ===== KPI Cards ===== */
.tkd-kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-xl);
margin-bottom: var(--space-2xl);
}
.tkd-kpi-card {
display: flex;
align-items: center;
gap: var(--space-xl);
padding: var(--space-2xl);
border-radius: var(--radius-xl);
position: relative;
overflow: hidden;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.tkd-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.tkd-kpi--blue {
background: linear-gradient(135deg, #5B5FED 0%, #8B8DFF 100%);
}
.tkd-kpi--green {
background: linear-gradient(135deg, #219B5E 0%, #34C97A 100%);
}
.tkd-kpi--orange {
background: linear-gradient(135deg, #E8922C 0%, #F5A84C 100%);
}
.tkd-kpi-card::after {
content: '';
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
top: -30px;
right: -30px;
}
.tkd-kpi-card::before {
content: '';
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
bottom: -20px;
left: -20px;
}
.tkd-kpi-icon-wrapper {
width: 52px;
height: 52px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
}
.tkd-kpi-icon--blue { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
.tkd-kpi-icon--green { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
.tkd-kpi-icon--orange { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
.tkd-kpi-content {
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1;
}
.tkd-kpi-label {
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
}
.tkd-kpi-value {
font-size: clamp(1.25rem, 5vw, var(--text-3xl));
font-weight: 800;
color: #FFFFFF;
font-family: var(--font-mono);
letter-spacing: -0.02em;
line-height: 1.1;
word-break: break-all;
overflow-wrap: break-word;
}
.tkd-kpi-sub {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.6);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== Middle Row ===== */
.tkd-middle-row {
display: grid;
grid-template-columns: 1fr 1fr 220px;
gap: var(--space-xl);
margin-bottom: var(--space-2xl);
}
/* ===== Rank Card ===== */
.tkd-rank-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-xs);
}
.tkd-rank-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.tkd-rank-header svg {
color: var(--color-text-tertiary);
}
.tkd-rank-title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-primary);
}
.tkd-rank-model-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tkd-rank-model-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-accent);
}
.tkd-rank-model-tokens {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
/* Rank List */
.tkd-rank-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tkd-rank-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
transition: background var(--transition-fast);
}
.tkd-rank-item:hover {
background: var(--color-bg-hover);
}
.tkd-rank-item-idx {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-accent);
background: var(--color-accent-light);
}
.tkd-rank-item:nth-child(1) .tkd-rank-item-idx { background: #FEF3C7; color: #D97706; }
.tkd-rank-item:nth-child(2) .tkd-rank-item-idx { background: #E5E7EB; color: #6B7280; }
.tkd-rank-item:nth-child(3) .tkd-rank-item-idx { background: #FDE8CD; color: #B45309; }
.tkd-rank-item-name {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.tkd-rank-item-val {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
.tkd-rank-empty {
text-align: center;
color: var(--color-text-disabled);
font-size: var(--text-sm);
padding: var(--space-xl);
}
/* ===== Actions Card ===== */
.tkd-actions-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
justify-content: center;
}
.tkd-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-xl);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 600;
transition: all var(--transition-fast);
height: 48px;
}
.tkd-btn svg {
flex-shrink: 0;
}
.tkd-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
}
.tkd-btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.tkd-btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
}
.tkd-btn-secondary:hover {
background: var(--color-accent-subtle);
transform: translateY(-1px);
}
/* ===== Table ===== */
.tkd-table-wrapper {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xs);
}
.tkd-table-title {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
padding: var(--space-xl) var(--space-xl) 0;
}
.tkd-table-scroll {
overflow-x: auto;
padding: var(--space-lg) var(--space-xl) var(--space-sm);
}
.tkd-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.tkd-table th {
text-align: left;
padding: var(--space-md) var(--space-md);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border-light);
white-space: nowrap;
}
.tkd-table td {
padding: var(--space-md);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
white-space: nowrap;
}
.tkd-table tbody tr:hover {
background: var(--color-bg-tertiary);
}
.tkd-table tbody tr:last-child td {
border-bottom: none;
}
.tkd-col-num {
text-align: right;
font-family: var(--font-mono);
}
.tkd-col-bold {
font-weight: 600;
color: var(--color-text-primary);
}
.tkd-col-cost {
color: var(--color-accent);
font-weight: 600;
}
.tkd-col-time {
color: var(--color-text-tertiary);
font-size: var(--text-xs);
}
.tkd-model-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-full);
background: var(--color-accent-light);
color: var(--color-accent);
font-size: var(--text-xs);
font-weight: 600;
}
/* ===== Pagination ===== */
.tkd-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-lg) var(--space-xl);
border-top: 1px solid var(--color-border-light);
}
.tkd-page-btn {
padding: var(--space-sm) var(--space-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
transition: all var(--transition-fast);
}
.tkd-page-btn:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.tkd-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tkd-page-info {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
/* ===== Loading ===== */
.tkd-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-5xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.tkd-loading .audit-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-light);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.tkd-page {
padding: var(--space-xl) var(--space-lg);
}
.tkd-kpi-grid {
grid-template-columns: 1fr;
}
.tkd-middle-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.tkd-kpi-card {
padding: var(--space-lg);
}
.tkd-kpi-value {
font-size: var(--text-xl);
}
}
@@ -0,0 +1,479 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { tokenAPI } from '@/api';
import type { TokenUsageLog } from '@/types';
import './TokenDashboardPage.css';
// ---- SVG Icons ----
const IconToken = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="12" r="5" stroke="currentColor" strokeWidth="1.4" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<line x1="12" y1="2" x2="12" y2="5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<line x1="12" y1="19" x2="12" y2="22" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
const IconWallet = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<line x1="2" y1="9" x2="22" y2="9" stroke="currentColor" strokeWidth="1.4" />
<circle cx="17" cy="12" r="1.8" fill="currentColor" />
</svg>
);
const IconApp = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="2" y="2" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
<rect x="14" y="2" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
<rect x="2" y="14" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
<rect x="14" y="14" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
</svg>
);
const IconModel = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
const IconTrophy = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M6 4h12v4a6 6 0 01-12 0V4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M6 4H4a2 2 0 00-2 2v2a4 4 0 004 4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M18 4h2a2 2 0 012 2v2a4 4 0 01-4 4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<line x1="12" y1="14" x2="12" y2="20" stroke="currentColor" strokeWidth="1.8" />
<line x1="8" y1="20" x2="16" y2="20" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
const IconWarning = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 22h20L12 2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="18.5" r="0.8" fill="currentColor" />
</svg>
);
const IconDownload = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
const IconRecharge = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconArrowUp = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<polyline points="18 15 12 9 6 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
// ---- Formatting helpers ----
function formatNumber(n: number | undefined | null): string {
if (n == null) return '0';
if (n >= 1000000000) {
return (n / 1000000000).toFixed(2).replace(/\.?0+$/, '') + 'B';
}
if (n >= 1000000) {
return (n / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M';
}
if (n >= 10000) {
return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return n.toLocaleString('en-US');
}
// ---- Data analysis ----
function getTopModel(logs: TokenUsageLog[]): { name: string; total: number } {
const map = new Map<string, number>();
logs.forEach((l) => {
map.set(l.modelName, (map.get(l.modelName) || 0) + l.totalTokens);
});
let max = 0;
let name = '';
map.forEach((v, k) => { if (v > max) { max = v; name = k; } });
return { name, total: max };
}
function getTopApps(logs: TokenUsageLog[]): { name: string; total: number }[] {
const map = new Map<string, number>();
logs.forEach((l) => {
if (l.appName) {
map.set(l.appName, (map.get(l.appName) || 0) + l.totalTokens);
}
});
return Array.from(map.entries())
.map(([name, total]) => ({ name, total }))
.sort((a, b) => b.total - a.total)
.slice(0, 3);
}
function getTodayAppCount(logs: TokenUsageLog[], serverTime?: string): number {
// 优先使用服务器时间,否则使用本地时间
const today = (serverTime || new Date().toISOString()).slice(0, 10);
const apps = new Set<string>();
logs.forEach((l) => {
if (l.appName && l.createdAt && String(l.createdAt).startsWith(today)) {
apps.add(l.appName);
}
});
return apps.size;
}
const TokenDashboardPage: React.FC = () => {
const navigate = useNavigate();
const [totalConsumed, setTotalConsumed] = useState(0);
const [balance, setBalance] = useState(0);
const [logs, setLogs] = useState<TokenUsageLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [recharging, setRecharging] = useState(false);
// 从后端API获取服务器时间(用于时间同步)
const [serverTime, setServerTime] = useState<string>('');
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [overviewRes, balanceRes, logsRes] = await Promise.all([
tokenAPI.getOverview(),
tokenAPI.getBalance(),
tokenAPI.getLogs(),
]);
setTotalConsumed(overviewRes.data?.total_used || overviewRes.data?.total_consumed || overviewRes.data?.totalConsumed || 0);
setBalance(balanceRes.data?.balance || 0);
// 从overview响应中获取服务器时间
const serverTimeStr = overviewRes.data?.server_time || overviewRes.data?.serverTime || overviewRes.data?.current_time || overviewRes.data?.time || '';
setServerTime(serverTimeStr);
// 兼容分页格式和直接数组两种返回格式
const rawLogs = (logsRes.data as any)?.items || logsRes.data || [];
// 兼容后端返回的 snake_case 字段名
setLogs(rawLogs.map((item: any) => ({
...item,
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
modelName: item.modelName ?? item.model_name ?? '',
cost: item.cost ?? 0,
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
})));
} catch (err: any) {
setError(err.message || '数据加载失败');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// 导出CSV功能
const handleExportCSV = async () => {
setExporting(true);
try {
// 分页获取所有token日志(后端限制page_size <= 100)
const allLogs: TokenUsageLog[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await tokenAPI.getLogs({ page_size: 100, page });
const rawLogs = (res.data as any)?.items || res.data || [];
if (!Array.isArray(rawLogs) || rawLogs.length === 0) {
hasMore = false;
break;
}
allLogs.push(...rawLogs.map((item: any) => ({
...item,
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
modelName: item.modelName ?? item.model_name ?? '',
cost: item.cost ?? 0,
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
appName: item.appName ?? item.app_name ?? '',
})));
if (rawLogs.length < 100) hasMore = false;
else page++;
}
if (allLogs.length === 0) {
alert('暂无数据可导出');
return;
}
// 生成CSV内容
const headers = ['时间', '模型', '应用', 'Prompt Tokens', 'Completion Tokens', '总Tokens', '费用'];
const csvRows = [headers.join(',')];
allLogs.forEach(log => {
const row = [
formatTime(log.createdAt),
`"${(log.modelName || '').replace(/"/g, '""')}"`,
`"${(log.appName || '').replace(/"/g, '""')}"`,
log.promptTokens || 0,
log.completionTokens || 0,
log.totalTokens || 0,
(log.cost ?? 0).toFixed(4),
];
csvRows.push(row.join(','));
});
const csvContent = '\uFEFF' + csvRows.join('\n');
// 触发浏览器下载
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `token-usage-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err: any) {
const msg = err?.detail || err?.message || (typeof err === 'string' ? err : JSON.stringify(err));
alert('导出失败: ' + msg);
} finally {
setExporting(false);
}
};
// 一键充值功能 - 跳转到付费中心
const handleRecharge = () => {
navigate('/pricing');
};
const topModel = getTopModel(logs);
const topApps = getTopApps(logs);
const todayAppCount = getTodayAppCount(logs, serverTime);
const lowBalanceThreshold = 200000;
const isLowBalance = balance < lowBalanceThreshold;
// Pagination
const [page, setPage] = useState(1);
const pageSize = 5;
const totalPages = Math.ceil(logs.length / pageSize);
const pagedLogs = logs.slice((page - 1) * pageSize, page * pageSize);
if (loading) {
return (
<div className="tkd-page">
<div className="tkd-header">
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
</div>
<div className="tkd-loading">
<div className="audit-spinner" />
<span>加载中...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="tkd-page">
<div className="tkd-header">
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
</div>
<div className="tkd-warning-banner">
<IconWarning />
<span>数据加载失败:{error}</span>
</div>
</div>
);
}
return (
<div className="tkd-page">
{/* Header */}
<div className="tkd-header">
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
</div>
{/* Balance Warning Banner */}
{isLowBalance && (
<div className="tkd-warning-banner">
<IconWarning />
<span>Token余额不足</span>
<strong>{formatNumber(balance)}</strong>
<span>,建议尽快充值以确保服务连续性</span>
</div>
)}
{/* KPI Cards */}
<div className="tkd-kpi-grid">
<div className="tkd-kpi-card tkd-kpi--blue">
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--blue">
<IconToken />
</div>
<div className="tkd-kpi-content">
<span className="tkd-kpi-label">累计总消耗 Token</span>
<span className="tkd-kpi-value">{formatNumber(totalConsumed)}</span>
<span className="tkd-kpi-sub">历史总计</span>
</div>
</div>
<div className="tkd-kpi-card tkd-kpi--green">
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--green">
<IconWallet />
</div>
<div className="tkd-kpi-content">
<span className="tkd-kpi-label">当前余额</span>
<span className="tkd-kpi-value">{formatNumber(balance)}</span>
<span className="tkd-kpi-sub">
{isLowBalance ? '余额偏低' : '余量充足'}
</span>
</div>
</div>
<div className="tkd-kpi-card tkd-kpi--orange">
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--orange">
<IconApp />
</div>
<div className="tkd-kpi-content">
<span className="tkd-kpi-label">当日使用中应用</span>
<span className="tkd-kpi-value">{todayAppCount}</span>
<span className="tkd-kpi-sub">个应用活跃</span>
</div>
</div>
</div>
{/* Rankings + Actions */}
<div className="tkd-middle-row">
{/* Top Model */}
<div className="tkd-rank-card">
<div className="tkd-rank-header">
<h3 className="tkd-rank-title">消耗量第一的大模型</h3>
<IconModel />
</div>
<div className="tkd-rank-model-body">
<span className="tkd-rank-model-name">{topModel.name || '-'}</span>
<span className="tkd-rank-model-tokens">{formatNumber(topModel.total)} Tokens</span>
</div>
</div>
{/* Top Apps */}
<div className="tkd-rank-card">
<div className="tkd-rank-header">
<h3 className="tkd-rank-title">消耗量 TOP3 应用</h3>
<IconTrophy />
</div>
<div className="tkd-rank-list">
{topApps.map((app, idx) => (
<div key={app.name} className="tkd-rank-item">
<span className="tkd-rank-item-idx">{idx + 1}</span>
<span className="tkd-rank-item-name">{app.name}</span>
<span className="tkd-rank-item-val">{formatNumber(app.total)}</span>
</div>
))}
{topApps.length === 0 && (
<div className="tkd-rank-empty">暂无数据</div>
)}
</div>
</div>
{/* Actions */}
<div className="tkd-actions-card">
<button className="tkd-btn tkd-btn-primary" onClick={handleExportCSV} disabled={exporting}>
<IconDownload />
<span>{exporting ? '导出中...' : '导出数据'}</span>
</button>
<button className="tkd-btn tkd-btn-secondary" onClick={handleRecharge} disabled={recharging}>
<IconRecharge />
<span>{recharging ? '充值中...' : '一键充值'}</span>
</button>
</div>
</div>
{/* Detail Table */}
<div className="tkd-table-wrapper">
<h3 className="tkd-table-title">Token 消耗明细</h3>
<div className="tkd-table-scroll">
<table className="tkd-table">
<thead>
<tr>
<th>时间</th>
<th>模型</th>
<th>应用</th>
<th className="tkd-col-num">Prompt Tokens</th>
<th className="tkd-col-num">Completion Tokens</th>
<th className="tkd-col-num">总 Tokens</th>
<th className="tkd-col-num">费用</th>
</tr>
</thead>
<tbody>
{pagedLogs.map((log) => (
<tr key={log.id}>
<td className="tkd-col-time">{formatTime(log.createdAt)}</td>
<td>
<span className="tkd-model-badge">{log.modelName}</span>
</td>
<td>{log.appName || '-'}</td>
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>
<td className="tkd-col-num">{formatNumber(log.completionTokens)}</td>
<td className="tkd-col-num tkd-col-bold">{formatNumber(log.totalTokens)}</td>
<td className="tkd-col-num tkd-col-cost">${(log.cost ?? 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="tkd-pagination">
<button
className="tkd-page-btn"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
上一页
</button>
<span className="tkd-page-info">{page} / {totalPages}</span>
<button
className="tkd-page-btn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
下一页
</button>
</div>
)}
</div>
</div>
);
};
function formatTime(iso: string | number | undefined | null): string {
if (iso == null) return '-';
let d: Date;
// Handle Unix timestamp (seconds or milliseconds) or ISO string
if (typeof iso === 'number') {
d = new Date(iso < 10000000000 ? iso * 1000 : iso);
} else if (/^\d+$/.test(iso)) {
const ts = parseInt(iso, 10);
d = new Date(ts < 10000000000 ? ts * 1000 : ts);
} else {
d = new Date(iso);
}
// Check validity
if (isNaN(d.getTime())) return String(iso).slice(0, 19) || '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default TokenDashboardPage;
+263
View File
@@ -0,0 +1,263 @@
/* ===== AIFlow Design System ===== */
/* 设计方向:融合QwenPaw温暖科技感 + 极简主义 */
/* Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
/* CSS Custom Properties - Design Tokens */
:root {
/* Colors - Light Theme */
--color-bg-primary: #F8F9FB;
--color-bg-secondary: #FFFFFF;
--color-bg-tertiary: #F1F3F7;
--color-bg-hover: #EBEEF3;
--color-bg-active: #E3E7F0;
--color-text-primary: #101214;
--color-text-secondary: #545A65;
--color-text-tertiary: #8B919E;
--color-text-disabled: #BFC4CE;
--color-text-inverse: #FFFFFF;
--color-accent: #5B5FED;
--color-accent-hover: #4A4ECC;
--color-accent-light: #EEEDFF;
--color-accent-subtle: #F6F5FF;
--color-success: #219B5E;
--color-success-light: #E6F7EE;
--color-warning: #E8922C;
--color-warning-light: #FEF4E8;
--color-error: #D9383A;
--color-error-light: #FDEBEB;
--color-info: #3B82F6;
--color-info-light: #EBF2FE;
--color-border: #E3E6EB;
--color-border-light: #EEF0F4;
--color-border-hover: #CDD2DA;
--color-sidebar-bg: #F5F6F9;
--color-sidebar-hover: #EAECF2;
--color-sidebar-active: #E1E3F0;
/* Shadows */
--shadow-xs: 0 1px 2px rgba(0,0,0,0.04);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
--shadow-lg: 0 12px 32px rgba(0,0,0,0.08), 0 2px 6px rgba(0,0,0,0.04);
--shadow-xl: 0 20px 48px rgba(0,0,0,0.1), 0 4px 12px rgba(0,0,0,0.06);
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 12px;
--space-lg: 16px;
--space-xl: 20px;
--space-2xl: 24px;
--space-3xl: 32px;
--space-4xl: 40px;
--space-5xl: 48px;
/* Border Radius */
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-full: 9999px;
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
--text-xs: 11px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
/* Layout */
--sidebar-width: 240px;
--sidebar-collapsed: 64px;
--header-height: 56px;
--admin-sidebar-width: 220px;
/* Transitions */
--transition-fast: 120ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* Z-index */
--z-base: 1;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-toast: 500;
}
/* Reset & Base */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: var(--text-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-sans);
color: var(--color-text-primary);
background: var(--color-bg-primary);
line-height: 1.6;
overflow: hidden;
height: 100vh;
}
#root {
height: 100vh;
overflow: hidden;
}
a {
color: var(--color-accent);
text-decoration: none;
}
a:hover {
color: var(--color-accent-hover);
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
}
input, textarea, select {
font-family: inherit;
font-size: inherit;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-text-disabled);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
}
/* Utility Classes */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-1 { flex: 1; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.justify-center { justify-content: center; }
.gap-xs { gap: var(--space-xs); }
.gap-sm { gap: var(--space-sm); }
.gap-md { gap: var(--space-md); }
.gap-lg { gap: var(--space-lg); }
.gap-xl { gap: var(--space-xl); }
.text-center { text-align: center; }
.text-right { text-align: right; }
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.w-full { width: 100%; }
.h-full { height: 100%; }
.relative { position: relative; }
.absolute { position: absolute; }
.hidden { display: none; }
.pointer { cursor: pointer; }
.select-none { user-select: none; }
/* Animation utilities */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideDown {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideLeft {
from { opacity: 0; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-fadeIn { animation: fadeIn var(--transition-normal); }
.animate-slideUp { animation: slideUp var(--transition-normal); }
.animate-slideDown { animation: slideDown var(--transition-normal); }
.animate-pulse { animation: pulse 2s ease infinite; }
/* Skeleton loading */
.skeleton {
background: linear-gradient(90deg, var(--color-bg-tertiary) 25%, var(--color-bg-hover) 50%, var(--color-bg-tertiary) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
/* Responsive breakpoints utility */
@media (max-width: 768px) {
:root {
--sidebar-width: 0px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
:root {
--sidebar-width: 64px;
}
}
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: 18px;
color: var(--color-text-secondary);
background: var(--color-bg-primary);
}
+540
View File
@@ -0,0 +1,540 @@
// ===== AIFlow Type Definitions =====
// 用户
export interface User {
id: string;
username: string;
email: string;
phone?: string;
avatar?: string;
role: 'user' | 'admin';
status: 'active' | 'banned';
realNameStatus?: 'unverified' | 'pending' | 'verified';
realName?: string;
idCard?: string;
membership_level?: number;
createdAt: string;
membership?: UserMembership;
}
export interface UserMembership {
planId: string;
planName: string;
level: number;
expireAt: string;
autoRenew: boolean;
}
// 注册方式设置
export interface RegisterSettings {
phoneRequired: boolean;
phoneSmsRequired: boolean;
emailRequired: boolean;
emailVerifyRequired: boolean;
realNameRequired: boolean;
aliyunAccessKey?: string;
aliyunSecretKey?: string;
aliyunEndpoint?: string;
smsSignName?: string;
smsTemplateCode?: string;
}
// 对话
export interface Conversation {
id: string;
userId: string;
title: string;
modelId: string;
status: 'active' | 'archived';
pinned?: boolean;
knowledgeGraphCount?: number;
createdAt: string;
updatedAt: string;
lastMessage?: string;
}
export interface Message {
id: string;
conversationId: string;
role: 'user' | 'assistant' | 'system';
content: string;
tokenUsed: number;
createdAt: string;
used_skills?: string[];
}
// 频道
// IM账户绑定频道
export type IMPlatform = 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp' | 'telegram' | 'slack';
export interface Channel {
id: string;
userId: string;
platform: IMPlatform;
name: string;
accountName: string;
agentId?: string;
knowledgeBaseId?: string;
status: 'connected' | 'disconnected' | 'pending';
lastActive?: string;
boundModel: string;
features: string[];
createdAt: string;
}
// 知识库
export interface KnowledgeBase {
id: string;
userId: string;
name: string;
description: string;
documentCount: number;
directoryPath: string;
createdAt: string;
updatedAt: string;
}
export interface KnowledgeDocument {
id: string;
knowledgeBaseId: string;
filename: string;
fileType: string;
fileSize: number;
status: 'processing' | 'completed' | 'failed';
chunkCount: number;
createdAt: string;
}
// 应用市场
export type AppType = 'skill' | 'agent' | 'mcp';
export interface MarketApp {
id: string;
name: string;
type: AppType;
source: 'official' | 'user';
publisherId: string;
publisherName: string;
description: string;
icon: string;
price: number;
isMemberFree: boolean;
joinPromotion: boolean;
status: 'pending' | 'published' | 'removed';
ratingAvg: number;
reviewCount: number;
installCount: number;
installed?: boolean;
createdAt: string;
}
export interface AppReview {
id: string;
userId: string;
userName: string;
appId: string;
rating: number;
content: string;
createdAt: string;
}
// Token
export interface TokenUsageLog {
id: string;
userId: string;
modelId: string;
modelName: string;
appId?: string;
appName?: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
cost: number;
createdAt: string;
}
export interface TokenBalance {
userId: string;
totalConsumed: number;
balance: number;
}
// 订单
export interface Order {
id: string;
userId: string;
orderType: 'membership' | 'service' | 'app';
itemName: string;
amount: number;
paymentMethod: 'wechat' | 'alipay';
status: 'pending' | 'paid' | 'refunded';
createdAt: string;
}
// 会员
export interface MembershipPlan {
id: string;
name: string;
level: number;
durationType: 'month' | 'quarter' | 'year';
price: number;
dailyFreeTokens: number;
features: string[];
freeAppIds?: string[];
freeModelIds?: string[];
}
// 优惠券
export interface Coupon {
id: string;
code: string;
type: 'fixed' | 'percent';
value: number;
minAmount: number;
validFrom: string;
validTo: string;
status: 'active' | 'expired';
}
export interface UserCoupon {
id: string;
userId: string;
couponId: string;
coupon: Coupon;
status: 'unused' | 'used' | 'expired';
}
// 通知
export interface Notification {
id: string;
userId: string;
type: 'announcement' | 'reminder' | 'renewal' | 'order' | 'update';
title: string;
content: string;
isRead: boolean;
createdAt: string;
}
// 后台
export interface ModelProvider {
id: string;
name: string;
protocol: string;
apiKey: string;
apiBaseUrl: string;
createdAt: string;
updatedAt?: string;
}
export interface AIModel {
id: string;
providerId: string;
providerName?: string;
name: string;
apiKey: string;
endpoint: string;
capabilities?: string[];
timeout: number;
status: 'free' | 'paid';
pricePerMillionTokens: number;
isEnabled: boolean;
totalCalls?: number;
totalTokens?: number;
}
export interface ProtocolDef {
id: string;
name: string;
base_path: string;
description: string;
}
export interface FetchableModel {
id: string;
name: string;
capabilities: string[];
pricing: string;
}
export interface SiteSettings {
logo: string;
siteName: string;
announcement: string;
icpNumber: string;
contactEmail: string;
contactPhone: string;
}
export interface PaymentConfig {
id: string;
type: 'wechat' | 'alipay';
app_id: string;
merchant_id?: string;
api_key: string;
api_v3_key?: string;
cert_path?: string;
cert_serial_no?: string;
gateway_url?: string;
notify_url?: string;
return_url?: string;
app_private_key?: string;
alipay_public_key?: string;
status: 'active' | 'inactive';
created_at?: string;
updated_at?: string;
}
// 日志
export interface OperationLog {
id: string;
userId: string;
username: string;
action: string;
target: string;
detail: string;
ip: string;
createdAt: string;
}
export interface AuditLog {
id: string;
adminId: string;
adminName: string;
action: string;
targetType: string;
targetId: string;
detail: string;
createdAt: string;
}
// 广告位
export interface AdBanner {
id: string;
title: string;
imageUrl: string;
linkAppId?: string;
linkAppName?: string;
sortOrder: number;
clickCount: number;
status: 'active' | 'inactive';
createdAt: string;
}
// 风控
export interface RiskRule {
id: string;
name: string;
type: 'rate_limit' | 'ban' | 'alert';
threshold: number;
duration: number;
isEnabled: boolean;
}
// 资产
export type AssetFileType = 'word' | 'excel' | 'ppt' | 'pdf' | 'txt' | 'md' | 'doc' | 'other';
export type AssetCategory = 'file' | 'novel' | 'image' | 'video' | 'audio' | 'other' | 'custom';
export interface AssetCategoryDef {
id: string;
name: string;
type: AssetCategory;
icon: string;
color: string;
isDefault: boolean;
}
export interface NovelChapter {
id: string;
novelId: string;
title: string;
wordCount: number;
sortOrder: number;
content: string;
createdAt: string;
}
export interface NovelWorldView {
summary: string;
rules: string[];
timeline: { era: string; events: string }[];
}
export interface NovelCharacter {
id: string;
name: string;
role: string;
description: string;
relationships: string[];
}
export interface NovelMeta {
hooks: string[];
anchors: string[];
worldView: NovelWorldView;
characters: NovelCharacter[];
}
export interface Asset {
id: string;
userId: string;
name: string;
category: AssetCategory;
subCategory?: string;
fileType?: AssetFileType;
size?: number;
description?: string;
coverUrl?: string;
novelMeta?: NovelMeta;
chapters?: NovelChapter[];
url: string;
content?: string;
createdAt: string;
updatedAt: string;
}
// ===== AI Evolution Types =====
export type SkillState = 'baby' | 'main' | 'staging' | 'frozen';
export interface EvolutionSkill {
id: string;
skill_name: string;
description: string;
tags: string[];
category: string;
state: SkillState;
use_count: number;
version: number;
last_used_at: string | null;
created_at: string;
updated_at: string;
skill_md_content?: string | null;
}
export interface EvolutionEvent {
id: string;
skill_name: string;
event_type: string;
description: string;
version_before: number | null;
version_after: number | null;
created_at: string;
}
export interface EvolutionTrajectory {
id: string;
conversation_id: string;
file_path: string;
status: string;
atom_count: number;
token_used: number;
created_at: string;
processed_at: string | null;
}
export interface AbilityDimension {
label: string;
value: number;
maxValue: number;
}
export interface EvolutionProfile {
level: number;
level_title: string;
total_skills: number;
active_skills: number;
staging_skills: number;
baby_skills: number;
total_use_count: number;
dimensions: AbilityDimension[];
tags: string[];
growth_suggestions: string[];
}
export interface SkillCategory {
name: string;
skills: EvolutionSkill[];
}
export interface SkillTreeData {
categories: SkillCategory[];
total_count: number;
main_count: number;
baby_count: number;
staging_count: number;
frozen_count: number;
}
export interface SkillUsageStat {
skill_name: string;
category: string;
use_count: number;
state: string;
last_used_at: string | null;
}
export interface CategoryDistribution {
category: string;
count: number;
}
export interface DailyTrend {
date: string;
count: number;
}
export interface StatisticsData {
ranking: SkillUsageStat[];
total_use_count: number;
category_distribution: CategoryDistribution[];
daily_trend: DailyTrend[];
low_use_skills: EvolutionSkill[];
}
// Evolution v2 types
export interface VersionEntry {
version: number;
event_type: string;
description: string;
created_at: string;
version_before?: number | null;
version_after?: number | null;
}
export interface PipelineStatus {
scheduler_running: boolean;
watcher_status: string;
pipeline_status: string;
discovered_count: number;
done_count: number;
total_skills: number;
total_trajectories: number;
}
export interface WatcherStatus {
watcher_status: string;
pipeline_status: string;
pipeline_processed: number;
watcher_error: string | null;
scheduler_running: boolean;
skill_count: number;
trajectory_count: number;
}
export interface EvolutionReport {
id: string;
report_type: 'weekly' | 'monthly';
period_start: string;
period_end: string;
report_data: any;
created_at: string;
}
export interface PublishRecord {
id: string;
skill_name: string;
market_app_id: string;
price: number;
status: string;
published_at: string;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
base: '/',
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
},
});