fix: KB upload (assets.content column), KG conversion, achievement SQL index, card font color, markdown renderer

This commit is contained in:
qyyfw committed 2026-07-08 15:05:19 +08:00
1 parent 985e0548b0
commit ce0a7b4243
42 files changed
+29056

No files matched your search

@@ -0,0 +1,327 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from typing import Optional, List
from datetime import datetime
import json
import traceback
from ...database import SessionLocal, get_db
from ...auth import get_current_user
from ...models import User
router = APIRouter()
def _execute_raw(db: Session, sql: str, params: dict = None):
"""执行原始SQL的辅助函数"""
try:
result = db.execute(text(sql), params or {})
db.commit()
return result
except Exception as e:
db.rollback()
raise e
@router.get("/achievements")
async def list_achievements(
category: Optional[str] = Query(None),
is_active: Optional[bool] = Query(True),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""列出所有成就(带用户进度)"""
try:
achievements = []
# Build query
sql = "SELECT * FROM achievements WHERE 1=1"
params = {}
if category:
sql += " AND category = :category"
params["category"] = category
if is_active is not None:
sql += " AND is_active = :is_active"
params["is_active"] = 1 if is_active else 0
sql += " ORDER BY display_order ASC"
result = _execute_raw(db, sql, params)
rows = result.fetchall()
user_id = user.id if hasattr(user, 'id') else None
for row in rows:
ach = {
"id": row[0],
"name": row[1],
"description": row[2],
"icon": row[3],
"category": row[4],
"criteria_type": row[5],
"criteria_key": row[6],
"criteria_value": row[7],
"reward_type": row[8],
"reward_value": float(row[9]) if row[9] else 0,
"badge_icon": row[10],
"is_active": bool(row[11]) if row[11] is not None else True,
"display_order": row[12],
"user_progress": 0,
"is_completed": False,
"reward_claimed": False
}
# Get user progress if user is logged in
if user_id:
try:
progress_result = _execute_raw(db,
"SELECT progress, is_completed, reward_claimed FROM user_achievements WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach["id"]})
prog_row = progress_result.fetchone()
if prog_row:
ach["user_progress"] = prog_row[0] or 0
ach["is_completed"] = bool(prog_row[1]) if prog_row[1] is not None else False
ach["reward_claimed"] = bool(prog_row[2]) if prog_row[2] is not None else False
except:
pass
achievements.append(ach)
return {"code": 0, "data": {"items": achievements, "total": len(achievements)}}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": [], "total": 0}}
@router.get("/achievements/{ach_id}")
async def get_achievement_detail(
ach_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取成就详情"""
try:
result = _execute_raw(db, "SELECT * FROM achievements WHERE id = :id", {"id": ach_id})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="成就不存在")
achievement = {
"id": row[0],
"name": row[1],
"description": row[2],
"icon": row[3],
"category": row[4],
"criteria_type": row[5],
"criteria_key": row[6],
"criteria_value": row[7],
"reward_type": row[8],
"reward_value": float(row[9]) if row[9] else 0,
"badge_icon": row[10],
"is_active": bool(row[11]) if row[11] is not None else True,
"display_order": row[12]
}
return {"code": 0, "data": achievement}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/achievements")
async def create_achievement(
name: str = Query(...),
category: str = Query(...),
criteria_type: str = Query(...),
criteria_value: int = Query(...),
description: Optional[str] = Query(""),
icon: Optional[str] = Query(""),
criteria_key: Optional[str] = Query(None),
reward_type: str = Query("points"),
reward_value: float = Query(0),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""创建新成就(管理员)"""
try:
import uuid
ach_id = str(uuid.uuid4())
sql = """INSERT INTO achievements
(id, name, description, icon, category, criteria_type, criteria_key,
criteria_value, reward_type, reward_value, is_active, display_order)
VALUES (:id, :name, :desc, :icon, :cat, :ctype, :ckey,
:cvalue, :rtype, :rvalue, 1, 0)"""
_execute_raw(db, sql, {
"id": ach_id,
"name": name,
"desc": description,
"icon": icon,
"cat": category,
"ctype": criteria_type,
"ckey": criteria_key,
"cvalue": criteria_value,
"rtype": reward_type,
"rvalue": reward_value
})
return {"code": 0, "message": "成就创建成功", "data": {"id": ach_id}}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/achievements/{ach_id}")
async def update_achievement(
ach_id: str,
name: Optional[str] = Query(None),
description: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
reward_value: Optional[float] = Query(None),
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""更新成就(管理员)"""
try:
updates = []
params = {"id": ach_id}
if name is not None:
updates.append("name = :name")
params["name"] = name
if description is not None:
updates.append("description = :desc")
params["desc"] = description
if is_active is not None:
updates.append("is_active = :active")
params["active"] = 1 if is_active else 0
if reward_value is not None:
updates.append("reward_value = :rvalue")
params["rvalue"] = reward_value
if updates:
sql = f"UPDATE achievements SET {', '.join(updates)} WHERE id = :id"
_execute_raw(db, sql, params)
return {"code": 0, "message": "更新成功"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/achievements/{ach_id}")
async def delete_achievement(
ach_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""删除成就(管理员)"""
try:
_execute_raw(db, "DELETE FROM achievements WHERE id = :id", {"id": ach_id})
return {"code": 0, "message": "删除成功"}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/user/achievements")
async def get_my_achievements(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""获取当前用户的成就进度"""
try:
user_id = user.id if hasattr(user, 'id') else None
if not user_id:
return {"code": 0, "data": {"items": [], "total": 0}}
# Get all achievements with user progress
result = _execute_raw(db, "SELECT a.*, ua.progress, ua.is_completed, ua.reward_claimed, ua.completed_at FROM achievements a LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = :uid WHERE a.is_active = 1 ORDER BY a.display_order ASC", {"uid": user_id})
rows = result.fetchall()
achievements = []
for row in rows:
achievements.append({
"id": row[0],
"name": row[1],
"description": row[2],
"icon": row[3],
"category": row[4],
"criteria_type": row[5],
"criteria_value": row[7],
"reward_type": row[8],
"reward_value": float(row[9]) if row[9] else 0,
"progress": row[15] or 0,
"is_completed": bool(row[16]) if row[16] is not None else False,
"reward_claimed": bool(row[17]) if row[17] is not None else False,
"completed_at": str(row[18]) if row[18] else None
})
return {"code": 0, "data": {"items": achievements, "total": len(achievements)}}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": [], "total": 0}}
@router.post("/user/achievements/{ach_id}/claim")
async def claim_achievement_reward(
ach_id: str,
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""领取成就奖励"""
try:
user_id = user.id if hasattr(user, 'id') else None
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# Check if achievement is completed
result = _execute_raw(db,
"SELECT is_completed, reward_claimed FROM user_achievements WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id})
row = result.fetchone()
if not row or not row[0]:
raise HTTPException(status_code=400, detail="成就未完成")
if row[1]:
raise HTTPException(status_code=400, detail="奖励已领取")
# Mark reward as claimed
_execute_raw(db,
"UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id})
return {"code": 0, "message": "奖励领取成功"}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/check-progress")
async def check_user_progress(
db: Session = Depends(get_db),
user = Depends(get_current_user)
):
"""检查并更新用户成就进度"""
try:
user_id = user.id if hasattr(user, 'id') else None
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# Get user stats (this would need to be implemented based on actual data)
# For now, just return success
return {"code": 0, "message": "进度检查完成", "data": {"updated": 0}}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/leaderboard")
async def get_achievement_leaderboard(
period: str = Query("all"),
db: Session = Depends(get_db)
):
"""获取成就排行榜"""
try:
# This would aggregate user achievements and rank them
# For now, return empty
return {"code": 0, "data": {"items": []}}
except Exception as e:
traceback.print_exc()
return {"code": 0, "data": {"items": []}}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+990
View File
@@ -0,0 +1,990 @@
/**
* AIFlow API 客户端
* 统一管理所有前后端API请求
*/
const API_BASE = '/app/api';
// 从localStorage获取token
function getToken(): string | null {
return localStorage.getItem('aiflow_token');
}
// 通用请求方法
export 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_base_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'),
getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`),
// 用户管理
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`),
// Phase 2: Enhanced model management
testModelConnection: (id: string) => post<any>(`/admin/models/${id}/test-connection`),
testAllModels: () => post<any>('/admin/models/test-all'),
fetchProviderModels: (id: string) => post<any>(`/admin/models/providers/${id}/fetch-models`),
createAdminModel: (data: any) => post<any>('/admin/models/', data),
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
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: (params: string) => post<any>(`/admin/membership/plans${params}`),
updatePlan: (id: string, params: string) => put<any>(`/admin/membership/plans/${id}${params}`),
deletePlan: (id: string) => del<any>(`/admin/membership/plans/${id}`),
getSubscriptions: (page?: number) => get<any>(`/admin/membership/subscriptions${page ? `?page=${page}` : ''}`),
getAutomationRules: () => get<any>('/admin/membership/automation-rules'),
getBenefitsComparison: () => get<any>('/admin/membership/benefits-comparison'),
getMembershipConfigs: () => get<any[]>('/admin/membership/global-configs'),
updateMembershipConfig: (key: string, params: string) => put<any>(`/admin/membership/global-configs/${key}${params}`),
getUserMemberships: (page?: number, planId?: string, status?: string) => {
const p = new URLSearchParams();
if (page) p.append('page', String(page));
if (planId) p.append('plan_id', planId);
if (status) p.append('status', status);
const qs = p.toString();
return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`);
},
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`),
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}`),
// 技能管理 (Phase 2.2)
getAdminSkills: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return get<any>(`/admin/skills/${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getAdminSkill: (id: string) => get<any>(`/admin/skills/${id}`),
createAdminSkill: (data: any) => post<any>('/admin/skills/', data),
updateAdminSkill: (id: string, data: any) => put<any>(`/admin/skills/${id}`, data),
deleteAdminSkill: (id: string) => del<any>(`/admin/skills/${id}`),
changeAdminSkillState: (id: string, data: any) => put<any>(`/admin/skills/${id}/state`, data),
getAdminSkillVersions: (id: string) => get<any>(`/admin/skills/${id}/versions`),
rollbackAdminSkill: (id: string, data: any) => post<any>(`/admin/skills/${id}/rollback`, data),
publishAdminSkill: (id: string, data: any) => post<any>(`/admin/skills/${id}/publish`, data),
unpublishAdminSkill: (id: string) => post<any>(`/admin/skills/${id}/unpublish`),
getAdminSkillsStats: () => get<any>('/admin/skills/stats/overview'),
// 智能体管理 (Phase 2.3)
getAdminAgents: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/agents/${query}`);
},
getAdminAgent: (id: string) => get<any>(`/admin/agents/${id}`),
createAdminAgent: (data: any) => post<any>('/admin/agents/', data),
updateAdminAgent: (id: string, data: any) => put<any>(`/admin/agents/${id}`, data),
deleteAdminAgent: (id: string) => del<any>(`/admin/agents/${id}`),
toggleAdminAgent: (id: string, isActive: boolean) => put<any>(`/admin/agents/${id}/toggle`, { is_active: isActive }),
getAdminAgentsStats: () => get<any>('/admin/agents/stats/overview'),
getAdminAgentReviews: (id: string) => get<any>(`/admin/agents/${id}/reviews`),
getAdminAvailableSkills: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/agents/available/skills${query}`);
},
getAdminAvailableModels: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/agents/available/models${query}`);
},
// 知识库管理 (Phase 2.4)
getAdminKnowledgeStats: () => get<any>('/admin/knowledge/stats/overview'),
getAdminKnowledge: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/knowledge/${query}`);
},
getAdminKnowledgeDetail: (id: string) => get<any>(`/admin/knowledge/${id}`),
createAdminKnowledge: (data: any) => post<any>('/admin/knowledge/', data),
updateAdminKnowledge: (id: string, data: any) => put<any>(`/admin/knowledge/${id}`, data),
deleteAdminKnowledge: (id: string) => del<any>(`/admin/knowledge/${id}`),
toggleAdminKnowledge: (id: string, isActive: boolean) => put<any>(`/admin/knowledge/${id}/toggle`, { is_active: isActive }),
getAdminKnowledgeDocs: (kbId: string, params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/knowledge/${kbId}/documents${query}`);
},
deleteAdminKnowledgeDoc: (kbId: string, docId: string) => del<any>(`/admin/knowledge/${kbId}/documents/${docId}`),
getAdminKnowledgeExtServices: () => get<any>('/admin/knowledge/external/services'),
createAdminKnowledgeExtService: (data: any) => post<any>('/admin/knowledge/external/services', data),
updateAdminKnowledgeExtService: (id: string, data: any) => put<any>(`/admin/knowledge/external/services/${id}`, data),
deleteAdminKnowledgeExtService: (id: string) => del<any>(`/admin/knowledge/external/services/${id}`),
checkAdminKnowledgeExtStatus: (id: string) => get<any>(`/admin/knowledge/external/services/${id}/status`),
syncAdminKnowledge: (kbId: string, data: any) => post<any>(`/admin/knowledge/external/sync/${kbId}`, data),
batchDeleteAdminKnowledge: (ids: string[]) => post<any>('/admin/knowledge/batch/delete', ids),
// OSS存储配置 (Phase 3.1)
getAssetStats: () => get<any>('/admin/assets/stats'),
getAdminAssets: (query: string) => get<any>(`/admin/assets/?${query}`),
deleteAdminAsset: (id: string) => del<any>(`/admin/assets/${id}`),
getAssetActivity: () => get<any>('/admin/assets/activity'),
getAdminStorageStats: () => get<any>('/admin/storage/stats/overview'),
getAdminStorageConfigs: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/storage/${query}`);
},
getAdminStorageConfig: (id: string) => get<any>(`/admin/storage/${id}`),
createAdminStorageConfig: (data: any) => post<any>('/admin/storage/', data),
updateAdminStorageConfig: (id: string, data: any) => put<any>(`/admin/storage/${id}`, data),
deleteAdminStorageConfig: (id: string) => del<any>(`/admin/storage/${id}`),
toggleAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/toggle`),
setDefaultAdminStorageConfig: (id: string) => put<any>(`/admin/storage/${id}/set-default`),
testAdminStorageConfig: (data: any) => post<any>('/admin/storage/test', data),
// Storage Quota APIs (Phase C5)
getStorageQuotaOverview: () => get<any>('/admin/storage/quota/overview'),
getStorageQuotaUsers: (params?: any) => {
if (params) {
const query = Object.entries(params).map(([k, v]) => v !== undefined && v !== '' && v !== null ? `${k}=${encodeURIComponent(String(v))}` : '').filter(Boolean).join('&');
return get<any>(`/admin/storage/quota/users${query ? '?' + query : ''}`);
}
return get<any>('/admin/storage/quota/users');
},
getStorageQuotaUser: (userId: string) => get<any>(`/admin/storage/quota/users/${userId}`),
adjustStorageQuota: (userId: string, data: any) => put<any>(`/admin/storage/quota/users/${userId}`, data),
recalcStorageQuota: (userId: string) => post<any>(`/admin/storage/quota/recalculate/${userId}`, {}),
initAllStorageQuotas: () => post<any>('/admin/storage/quota/init-all', {}),
// 自动化任务调度 (Phase 3.2)
getAdminAutomationStats: () => get<any>('/admin/automation/stats/overview'),
getAdminAutomationTasks: (params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/automation/${query}`);
},
getAdminAutomationTask: (id: string) => get<any>(`/admin/automation/${id}`),
createAdminAutomationTask: (data: any) => post<any>('/admin/automation/', data),
updateAdminAutomationTask: (id: string, data: any) => put<any>(`/admin/automation/${id}`, data),
deleteAdminAutomationTask: (id: string) => del<any>(`/admin/automation/${id}`),
toggleAdminAutomationTask: (id: string) => put<any>(`/admin/automation/${id}/toggle`),
executeAdminAutomationTask: (id: string) => post<any>(`/admin/automation/${id}/execute`),
getAdminAutomationTaskHistory: (id: string, params?: any) => {
const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : '';
return get<any>(`/admin/automation/${id}/history${query}`);
},
// MCP Server Management
getAdminMcpStats: () => get<any>('/admin/mcp/stats/overview'),
getAdminMcpServers: (params?: any) => {
const query = new URLSearchParams(params).toString();
return get<any>(`/admin/mcp/${query ? '?' + query : ''}`);
},
getAdminMcpServer: (id: string) => get<any>(`/admin/mcp/${id}`),
createAdminMcpServer: (data: any) => post<any>('/admin/mcp/', data),
updateAdminMcpServer: (id: string, data: any) => put<any>(`/admin/mcp/${id}`, data),
deleteAdminMcpServer: (id: string) => del<any>(`/admin/mcp/${id}`),
toggleAdminMcpServer: (id: string) => put<any>(`/admin/mcp/${id}/toggle`),
testAdminMcpServer: (id: string, data?: any) => post<any>(`/admin/mcp/${id}/test`, data || {}),
getAdminMcpServerTools: (id: string) => get<any>(`/admin/mcp/${id}/tools`),
getAdminMcpServerResources: (id: string) => get<any>(`/admin/mcp/${id}/resources`),
// Developer API Management
getAdminDeveloperStats: () => get<any>('/admin/developer/stats/overview'),
getAdminApiKeys: (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/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`);
},
createAdminApiKey: (data: any) => post<any>('/admin/developer/api-keys/', data),
updateAdminApiKey: (id: string, data: any) => put<any>(`/admin/developer/api-keys/${id}`, data),
revokeAdminApiKey: (id: string) => post<any>(`/admin/developer/api-keys/${id}/revoke`),
deleteAdminApiKey: (id: string) => del<any>(`/admin/developer/api-keys/${id}`),
getAdminOpenApiClients: (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/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`);
},
createAdminOpenApiClient: (data: any) => post<any>('/admin/developer/openapi-clients/', data),
updateAdminOpenApiClient: (id: string, data: any) => put<any>(`/admin/developer/openapi-clients/${id}`, data),
resetAdminOpenApiClientSecret: (id: string) => post<any>(`/admin/developer/openapi-clients/${id}/reset-secret`),
deleteAdminOpenApiClient: (id: string) => del<any>(`/admin/developer/openapi-clients/${id}`),
getAdminCallStats: (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/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`);
},
getAdminCallStatsOverview: () => get<any>('/admin/developer/call-stats/overview'),
getAdminSlowLogs: (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/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`);
},
clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'),
getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'),
// AI App Management
getAIAppStats: () => get<any>('/admin/apps/stats/overview'),
getAIApps: (params?: string) => get<any>('/admin/apps/apps' + (params ? '?' + params : '')),
getAIApp: (id: string) => get<any>('/admin/apps/apps/' + id),
createAIApp: (data: any) => post<any>('/admin/apps/apps', data),
updateAIApp: (id: string, data: any) => put<any>('/admin/apps/apps/' + id, data),
deleteAIApp: (id: string) => del<any>('/admin/apps/apps/' + id),
aiAppLifecycle: (id: string, action: string) => post<any>('/admin/apps/apps/' + id + '/' + action, {}),
getAIAppVersions: (id: string) => get<any>('/admin/apps/apps/' + id + '/versions'),
getAIAppAnalytics: (id: string) => get<any>('/admin/apps/apps/' + id + '/analytics'),
getAIAppTemplates: () => get<any>('/admin/apps/templates'),
createAIAppTemplate: (data: any) => post<any>('/admin/apps/templates', data),
createAIAppFromTemplate: (templateId: string, data: any) => post<any>('/admin/apps/apps/from-template/' + templateId, data),
getAIAppCategories: () => get<any>('/admin/apps/categories'),
// Logo 上传
uploadLogo: (formData: FormData) => {
const token = localStorage.getItem('access_token');
return fetch(`/app/api/admin/settings/logo/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
}).then(res => res.json());
},
// MCP Market (Phase G1)
getMcpMarketStats: () =>
get<any>('/admin/mcp-market/stats'),
getMcpTools: (params?: Record<string, any>) =>
get<any>('/admin/mcp-market/?' + new URLSearchParams(params || {}).toString()),
getMcpToolDetail: (id: string) =>
get<any>('/admin/mcp-market/' + id),
createMcpTool: (data: any) =>
post<any>('/admin/mcp-market/', data),
updateMcpTool: (id: string, data: any) =>
put<any>('/admin/mcp-market/' + id, data),
deleteMcpTool: (id: string) =>
del<any>('/admin/mcp-market/' + id),
scanMcpTool: (id: string) =>
post<any>('/admin/mcp-market/' + id + '/security-scan'),
updateMcpToolSecurity: (id: string, data: any) =>
put<any>('/admin/mcp-market/' + id + '/security-status', data),
regenMcpDeepLink: (id: string) =>
post<any>('/admin/mcp-market/' + id + '/deep-link'),
getMcpManagedServers: () =>
get<any>('/admin/mcp-market/managed/servers'),
seedMcpTools: () =>
post<any>('/admin/mcp-market/seed'),
// Code Execution Sandbox (Phase G2)
getCodeExecStats: () => get<any>('/admin/code-exec/stats'),
getCodeExecRuntimes: () => get<any>('/admin/code-exec/runtimes'),
executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) =>
post<any>('/admin/code-exec/execute', data),
getCodeExecTasks: (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/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`);
},
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
deleteCodeExecTask: (id: string) => del<any>(`/admin/code-exec/tasks/${id}`),
// Distribution Builder (Phase G4)
getDistributionStats: () => get<any>('/admin/distributions/stats'),
getDistributions: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
return get<any>('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : ''));
},
getDistributionTemplates: () => get<any>('/admin/distributions/templates'),
createDistribution: (data: any) => post<any>('/admin/distributions', data),
getDistribution: (id: string) => get<any>('/admin/distributions/' + id),
updateDistribution: (id: string, data: any) => put<any>('/admin/distributions/' + id, data),
deleteDistribution: (id: string) => del<any>('/admin/distributions/' + id),
getDistributionLicenses: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
return get<any>('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
},
createDistributionLicense: (distroId: string, data: any) => post<any>('/admin/distributions/' + distroId + '/licenses', data),
getDistributionLicense: (id: string) => get<any>('/admin/distributions/licenses/' + id),
revokeDistributionLicense: (id: string) => post<any>('/admin/distributions/licenses/' + id + '/revoke'),
// ===== Ads Management =====
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}`),
// ===== Market Admin (extended) =====
getMarketOverview: () => get<any>('/admin/market/overview'),
getTopApps: (metric?: string, limit?: number) => get<any>(`/admin/market/top-apps${metric ? '?metric=' + metric : ''}${limit ? (metric ? '&' : '?') + 'limit=' + limit : ''}`),
getAdminMarketApps: (params?: string) => get<any>(`/admin/market/apps${params ? '?' + params : ''}`),
getPendingApps: () => get<any>('/admin/market/pending'),
getCoupons: () => get<any>('/admin/market/coupons'),
getRecentReviews: () => get<any>('/admin/market/reviews'),
approveApp: (id: string) => post<any>(`/admin/market/apps/${id}/approve`),
rejectApp: (id: string) => post<any>(`/admin/market/apps/${id}/reject`),
setFeaturedApp: (id: string, featured: boolean, sortOrder?: number) => put<any>(`/admin/market/apps/${id}/featured`, { featured, sort_order: sortOrder }),
deleteMarketApp: (id: string) => del<any>(`/admin/market/apps/${id}`),
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
updateMarketApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
// ===== Membership (extended) =====
getMembershipStats: () => get<any>('/admin/membership/stats'),
revokeMembership: (userId: string) => post<any>(`/admin/membership/users/${userId}/revoke`),
// ===== Logs (extended) =====
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/operation-logs${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() : ''}`);
},
// ===== Analytics =====
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview${days ? '?days=' + days : ''}`),
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls${days ? '?days=' + days : ''}`),
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens${days ? '?days=' + days : ''}`),
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users${days ? '?days=' + days : ''}`),
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue${days ? '?days=' + days : ''}`),
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage${days ? '?days=' + days : ''}`),
// ===== Billing =====
getBillingRules: () => get<any>('/admin/billing/rules'),
getBillingPackages: () => get<any>('/admin/billing/packages'),
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions${page ? '?page=' + page : ''}`),
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
// ===== Tenant =====
getTenants: (page?: number) => get<any>(`/admin/tenant/list${page ? '?page=' + page : ''}`),
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
getTenantMembers: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/members`),
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
};
// ===== 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}`),
// Workflow (Phase 3.6)
getWorkflows: (params?: Record<string, any>) =>
get<any>('/admin/workflows/?' + new URLSearchParams(params || {}).toString()),
getWorkflow: (id: string) =>
get<any>(`/admin/workflows/${id}`),
getWorkflowStats: () =>
get<any>('/admin/workflows/stats'),
getWorkflowTemplates: () =>
get<any>('/admin/workflows/templates'),
createWorkflow: (data: any) =>
post<any>('/admin/workflows/', data),
updateWorkflow: (id: string, data: any) =>
put<any>(`/admin/workflows/${id}`, data),
deleteWorkflow: (id: string) =>
del<any>(`/admin/workflows/${id}`),
toggleWorkflow: (id: string, isActive: boolean) =>
post<any>(`/admin/workflows/${id}/toggle?is_active=${isActive}`),
executeWorkflow: (id: string, inputText: string) =>
post<any>(`/admin/workflows/${id}/execute`, { input_text: inputText }),
// Privacy & Local Agent (Phase G5)
getPrivacyStats: () => get<any>('/admin/privacy/stats'),
getPrivacyConfig: () => get<any>('/admin/privacy/config'),
updatePrivacyConfig: (params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v != null) sp.append(k, String(v)); });
return put<any>('/admin/privacy/config?' + sp.toString());
},
switchPrivacyMode: (mode: string) => post<any>('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)),
getPrivacyLicenses: (params?: any) => {
const sp = new URLSearchParams();
if (params?.status) sp.append('status', params.status);
return get<any>('/admin/privacy/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
},
generateOfflineLicense: (data: any) => {
const sp = new URLSearchParams();
if (data.agent_id) sp.append('agent_id', data.agent_id);
sp.append('max_offline_days', String(data.max_offline_days || 7));
return post<any>('/admin/privacy/licenses/generate?' + sp.toString());
},
revokePrivacyLicense: (id: number) => post<any>('/admin/privacy/licenses/' + id + '/revoke'),
validatePrivacyLicense: (licenseKey: string) => {
const sp = new URLSearchParams();
sp.append('license_key', licenseKey);
return post<any>('/admin/privacy/licenses/validate?' + sp.toString());
},
getKbSyncStates: () => get<any>('/admin/privacy/kb-sync'),
triggerKbSync: (kbId: number, direction: string) => {
const sp = new URLSearchParams();
sp.append('knowledge_base_id', String(kbId));
sp.append('sync_direction', direction);
return post<any>('/admin/privacy/kb-sync/trigger?' + sp.toString());
},
getLocalAgents: () => get<any>('/admin/privacy/local-agents'),
getPrivacyUsageStats: () => get<any>('/admin/privacy/usage/stats'),
getBridgeConfig: () => get<any>('/admin/privacy/bridge-config'),
// IM Bot Management (Phase 4)
getImBotStats: () => get('/admin/im-bot/stats'),
getImBotDashboard: () => get('/admin/im-bot/dashboard'),
getImBotChannels: (params?: string) => get(`/admin/im-bot/channels${params || ''}`),
getImBotChannelDetail: (id: string) => get(`/admin/im-bot/channels/${id}`),
testImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/test`),
activateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/activate`),
deactivateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/deactivate`),
getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`),
createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`),
deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`),
getLangbotConfig: () => get('/admin/im-bot/langbot'),
updateLangbotConfig: (params: string) => post(`/admin/im-bot/langbot${params}`),
testLangbot: (params: string) => post(`/admin/im-bot/langbot/test${params}`),
getImBotPlatforms: () => get('/admin/im-bot/platforms'),
getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`),
getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`),
imBotQuickSetup: (params: string) => post(`/admin/im-bot/quick-setup${params}`),
// 推广系统
getPromotionStats: () => get<any>('/admin/promotion/stats'),
getPromotions: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return get<any>(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`);
},
createPromotion: (params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return post<any>(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`);
},
updatePromotion: (id: string, params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
return put<any>(`/admin/promotion/promotions/${id}${sp.toString() ? `?${sp.toString()}` : ''}`);
},
deletePromotion: (id: string) => del<any>(`/admin/promotion/promotions/${id}`),
togglePromotion: (id: string) => post<any>(`/admin/promotion/promotions/${id}/toggle`),
getReferralLinks: (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/promotion/referral-links${sp.toString() ? `?${sp.toString()}` : ''}`);
},
generateReferralLink: (params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return post<any>(`/admin/promotion/referral-links/generate${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getReferralRewards: (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/promotion/rewards${sp.toString() ? `?${sp.toString()}` : ''}`);
},
approveReward: (id: string) => post<any>(`/admin/promotion/rewards/${id}/approve`),
markRewardPaid: (id: string, params: any) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return post<any>(`/admin/promotion/rewards/${id}/pay${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getPromotionLeaderboard: (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/promotion/leaderboard${sp.toString() ? `?${sp.toString()}` : ''}`);
},
regenerateReferralCode: (id: string) => post<any>(`/admin/promotion/referral-links/${id}/regenerate`),
getMyReferrals: () => get<any>('/admin/promotion/my-referrals'),
getMyRewards: () => get<any>('/admin/promotion/my-rewards'),
// 成就系统
getAchievements: (params?: string) => get<any>(`/admin/achievement/achievements${params || ''}`),
getAchievementDetail: (id: string) => get<any>(`/admin/achievement/achievements/${id}`),
createAchievement: (params: string) => post<any>(`/admin/achievement/achievements${params}`),
updateAchievement: (id: string, params: string) => put<any>(`/admin/achievement/achievements/${id}${params}`),
deleteAchievement: (id: string) => del<any>(`/admin/achievement/achievements/${id}`),
getUserAchievements: () => get<any>('/admin/achievement/user/achievements'),
claimAchievementReward: (id: string) => post<any>(`/admin/achievement/user/achievements/${id}/claim`),
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`),
// 多租户管理
getTenants: (page?: number, keyword?: string, status?: string) => {
const sp = new URLSearchParams();
if (page) sp.set('page', String(page));
if (keyword) sp.set('keyword', keyword);
if (status) sp.set('status', status);
sp.set('page_size', '20');
return get<any>(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
},
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
getTenantDetail: (id: string) => get<any>(`/admin/tenant/${id}`),
updateTenant: (id: string, params: string) => put<any>(`/admin/tenant/${id}${params}`),
deleteTenant: (id: string) => del<any>(`/admin/tenant/${id}`),
getTenantMembers: (tenantId: string, page?: number) => {
const sp = new URLSearchParams();
sp.set('page', String(page || 1));
sp.set('page_size', '100');
return get<any>(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
},
addTenantMember: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/members/add${params}`),
updateTenantMember: (tenantId: string, memberId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
removeTenantMember: (tenantId: string, memberId: string) => del<any>(`/admin/tenant/${tenantId}/members/${memberId}`),
getTenantQuotas: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/quotas`),
updateTenantQuota: (tenantId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/quotas${params}`),
getTenantInvitations: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/invitations`),
createTenantInvitation: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/invitations${params}`),
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
// 计费与支付
getBillingRules: (page?: number) => get<any>(`/admin/billing/rules?page=${page || 1}&page_size=100`),
createBillingRule: (params: string) => post<any>(`/admin/billing/rules/create${params}`),
updateBillingRule: (id: string, params: string) => put<any>(`/admin/billing/rules/${id}${params}`),
deleteBillingRule: (id: string) => del<any>(`/admin/billing/rules/${id}`),
getBillingPackages: () => get<any>('/admin/billing/packages'),
createBillingPackage: (params: string) => post<any>(`/admin/billing/packages/create${params}`),
updateBillingPackage: (id: string, params: string) => put<any>(`/admin/billing/packages/${id}${params}`),
deleteBillingPackage: (id: string) => del<any>(`/admin/billing/packages/${id}`),
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
getInvoices: (page?: number) => get<any>(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
generateInvoice: (params: string) => post<any>(`/admin/billing/invoices/generate${params}`),
getPaymentConfigs: () => get<any>('/admin/billing/payment-configs'),
updatePaymentConfig: (id: string, params: string) => put<any>(`/admin/billing/payment-configs/${id}${params}`),
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
// 数据分析
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview?days=${days || 30}`),
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls?days=${days || 7}`),
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens?days=${days || 7}`),
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users?days=${days || 30}`),
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue?days=${days || 30}`),
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage?days=${days || 30}`),
getAnalyticsDashboards: () => get<any>('/admin/analytics/dashboards'),
createAnalyticsDashboard: (params: string) => post<any>(`/admin/analytics/dashboards/create${params}`),
deleteAnalyticsDashboard: (id: string) => del<any>(`/admin/analytics/dashboards/${id}`),
getAnalyticsEvents: (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/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
},
};
File diff suppressed because it is too large. Load diff
+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;
+702
View File
@@ -0,0 +1,702 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface Agent {
id: string;
name: string;
type: string;
source: string;
publisher_id: string;
publisher_name?: string;
description?: string;
icon?: string;
tutorial?: string;
price: number;
is_member_free: boolean;
join_promotion: boolean;
status: string;
rating_avg: number;
review_count: number;
install_count: number;
skill_content?: string;
agent_endpoint?: string;
config?: AgentConfig;
linked_skills?: LinkedSkill[];
created_at?: string;
updated_at?: string;
}
interface AgentConfig {
system_prompt?: string;
temperature?: number;
max_tokens?: number;
model_id?: string;
skills?: string[];
tools?: string[];
memory_enabled?: boolean;
reasoning_enabled?: boolean;
}
interface LinkedSkill {
id: string;
skill_name: string;
state: string;
version: number;
}
interface AgentStats {
total_agents: number;
published_agents: number;
pending_agents: number;
official_agents: number;
user_agents: number;
total_installs: number;
average_rating: number;
by_source: Record<string, number>;
}
interface AvailableModel {
id: string;
name: string;
provider: string;
model_type?: string;
}
const AGENT_TYPES = [
{ id: 'skill', label: '技能' },
{ id: 'agent', label: 'Agent' },
{ id: 'mcp', label: 'MCP' },
];
const Agents: React.FC = () => {
const [agents, setAgents] = useState<Agent[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<AgentStats | null>(null);
// Filters
const [keyword, setKeyword] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSource, setFilterSource] = useState('');
// Modal
const [showModal, setShowModal] = useState(false);
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
const [form, setForm] = useState({
name: '',
description: '',
icon: '',
price: '0',
is_member_free: false,
system_prompt: '',
temperature: '0.7',
max_tokens: '2048',
model_id: '',
skills: [] as string[],
memory_enabled: false,
reasoning_enabled: false,
});
// Detail drawer
const [showDetail, setShowDetail] = useState(false);
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
// Available skills for linking
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// Load agents
const loadAgents = async () => {
setLoading(true);
try {
const params: any = { page, page_size: 15 };
if (keyword) params.keyword = keyword;
if (filterStatus) params.status = filterStatus;
if (filterSource) params.source = filterSource;
const result = await adminAPI.getAdminAgents(params) as any;
const data = result.data || result;
setAgents(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(`加载失败: ${e.message || e}`, 'error');
} finally {
setLoading(false);
}
};
// Load stats
const loadStats = async () => {
try {
const result = await adminAPI.getAdminAgentsStats() as any;
const data = result.data || result;
setStats(data);
} catch {}
};
// Load available skills & models
const loadAvailable = async () => {
try {
const [skillsResult, modelsResult] = await Promise.all([
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
adminAPI.getAdminAvailableModels() as any,
]);
const skills = (skillsResult.data || skillsResult).items || [];
const models = (modelsResult.data || modelsResult) || [];
setAvailableSkills(skills);
setAvailableModels(models);
} catch {}
};
useEffect(() => {
loadAgents();
}, [page, keyword, filterStatus, filterSource]);
useEffect(() => {
loadStats();
}, []);
// Search
const handleSearch = () => {
setPage(1);
loadAgents();
};
// Open create
const openCreate = async () => {
setEditingAgent(null);
await loadAvailable();
setForm({
name: '',
description: '',
icon: '',
price: '0',
is_member_free: false,
system_prompt: '你是一个智能助手。',
temperature: '0.7',
max_tokens: '2048',
model_id: '',
skills: [],
memory_enabled: false,
reasoning_enabled: false,
});
setShowModal(true);
};
// Open edit
const openEdit = async (agent: Agent) => {
setEditingAgent(agent);
await loadAvailable();
const cfg = agent.config || {};
setForm({
name: agent.name,
description: agent.description || '',
icon: agent.icon || '',
price: String(agent.price),
is_member_free: agent.is_member_free,
system_prompt: cfg.system_prompt || '',
temperature: String(cfg.temperature || 0.7),
max_tokens: String(cfg.max_tokens || 2048),
model_id: cfg.model_id || '',
skills: cfg.skills || [],
memory_enabled: cfg.memory_enabled || false,
reasoning_enabled: cfg.reasoning_enabled || false,
});
setShowModal(true);
};
// Open detail
const openDetail = async (agent: Agent) => {
setDetailAgent(agent);
setShowDetail(true);
};
// Save agent
const handleSave = async () => {
if (!form.name.trim()) {
showToast('Agent 名称不能为空', 'error');
return;
}
const payload: any = {
name: form.name,
description: form.description,
icon: form.icon || undefined,
price: parseFloat(form.price) || 0,
is_member_free: form.is_member_free,
config: {
system_prompt: form.system_prompt,
temperature: parseFloat(form.temperature) || 0.7,
max_tokens: parseInt(form.max_tokens) || 2048,
model_id: form.model_id || undefined,
skills: form.skills,
memory_enabled: form.memory_enabled,
reasoning_enabled: form.reasoning_enabled,
},
};
try {
if (editingAgent) {
await adminAPI.updateAdminAgent(editingAgent.id, payload);
showToast('Agent 已更新');
} else {
await adminAPI.createAdminAgent(payload);
showToast('Agent 创建成功');
}
setShowModal(false);
loadAgents();
loadStats();
} catch (e: any) {
showToast(`保存失败: ${e.message || e}`, 'error');
}
};
// Toggle agent
const handleToggle = async (agent: Agent) => {
const isActive = agent.status === 'published';
try {
await adminAPI.toggleAdminAgent(agent.id, !isActive);
showToast(`Agent 已${isActive ? '禁用' : '启用'}`);
loadAgents();
loadStats();
} catch (e: any) {
showToast(`操作失败: ${e.message || e}`, 'error');
}
};
// Delete agent
const handleDelete = async (agent: Agent) => {
if (!confirm(`确定要删除 Agent "${agent.name}" 吗?此操作不可恢复。`)) return;
try {
await adminAPI.deleteAdminAgent(agent.id);
showToast('Agent 已删除');
loadAgents();
loadStats();
} catch (e: any) {
showToast(`删除失败: ${e.message || e}`, 'error');
}
};
// Toggle skill in selection
const toggleSkill = (skillId: string) => {
setForm(f => ({
...f,
skills: f.skills.includes(skillId)
? f.skills.filter(s => s !== skillId)
: [...f.skills, skillId],
}));
};
const PAGE_SIZE = 15;
const totalPages = Math.ceil(total / PAGE_SIZE);
const statusColor = (status: string) => {
if (status === 'published') return { bg: '#D1FAE5', color: '#10B981' };
if (status === 'pending') return { bg: '#FEF3C7', color: '#F59E0B' };
return { bg: '#FEE2E2', color: '#EF4444' };
};
return (
<div className="admin-page">
{/* Toast */}
{toast && (
<div className="toast" style={{
position: 'fixed', top: 20, right: 20, zIndex: 9999,
padding: '12px 20px', borderRadius: 8,
background: toast.type === 'success' ? '#10B981' : '#EF4444',
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
}}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="admin-page-header">
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Agent 管理</h2>
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
管理 AI Agent/Skill/MCP,支持配置、关联技能和模型
</p>
</div>
<button className="admin-btn primary" onClick={openCreate}>+ 创建 Agent</button>
</div>
{/* Stats */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
<div className="stat-card" style={{ borderLeft: '3px solid #6366F1' }}>
<div className="stat-value">{stats.total_agents}</div>
<div className="stat-label">Agent 总数</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #10B981' }}>
<div className="stat-value">{stats.published_agents}</div>
<div className="stat-label">已发布</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #F59E0B' }}>
<div className="stat-value">{stats.pending_agents}</div>
<div className="stat-label">待发布</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
<div className="stat-value">{stats.total_installs.toLocaleString()}</div>
<div className="stat-label">总安装量</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
<div className="stat-value">{stats.average_rating.toFixed(1)}</div>
<div className="stat-label">平均评分</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #EC4899' }}>
<div className="stat-value">{stats.official_agents} / {stats.user_agents}</div>
<div className="stat-label">官方 / 用户</div>
</div>
</div>
)}
{/* Filters */}
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
<input className="admin-form-input" placeholder="搜索名称、描述..."
value={keyword} onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
style={{ maxWidth: 280 }} />
<select className="admin-form-input" value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
style={{ maxWidth: 140 }}>
<option value="">全部状态</option>
<option value="published">已发布</option>
<option value="pending">待发布</option>
<option value="removed">已下架</option>
</select>
<select className="admin-form-input" value={filterSource} onChange={e => { setFilterSource(e.target.value); setPage(1); }}
style={{ maxWidth: 140 }}>
<option value="">全部来源</option>
<option value="official">官方</option>
<option value="user">用户</option>
</select>
<button className="admin-btn" onClick={handleSearch}>搜索</button>
{(keyword || filterStatus || filterSource) && (
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterStatus(''); setFilterSource(''); setPage(1); }}>
清空
</button>
)}
</div>
{/* Table */}
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>智能体</th>
<th>类型</th>
<th>来源</th>
<th>状态</th>
<th>价格</th>
<th>安装量</th>
<th>评分</th>
<th>更新时间</th>
<th style={{ width: 240 }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
) : agents.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无 Agent</td></tr>
) : agents.map(agent => {
const sc = statusColor(agent.status);
return (
<tr key={agent.id}>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{agent.icon ? (
<img src={agent.icon} alt="" style={{ width: 32, height: 32, borderRadius: 8, objectFit: 'cover' }} />
) : (
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#EEF2FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>
🤖
</div>
)}
<div>
<div style={{ fontWeight: 500 }}>{agent.name}</div>
{agent.description && (
<div style={{ fontSize: 12, color: '#6B7280', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{agent.description}
</div>
)}
</div>
</div>
</td>
<td>
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 10, background: '#EEF2FF', color: '#6366F1' }}>
{AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type}
</span>
</td>
<td>
<span style={{ fontSize: 12, color: agent.source === 'official' ? '#10B981' : '#6B7280' }}>
{agent.source === 'official' ? '官方' : '用户'}
</span>
</td>
<td>
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12, background: sc.bg, color: sc.color, fontWeight: 500 }}>
{agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'}
</span>
</td>
<td>{agent.price === 0 ? <span style={{ color: '#10B981' }}>免费</span> : `¥${agent.price}`}</td>
<td>{agent.install_count.toLocaleString()}</td>
<td>{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'}</td>
<td style={{ fontSize: 12, color: '#6B7280' }}>
{agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'}
</td>
<td>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="admin-btn small" onClick={() => openDetail(agent)}>详情</button>
<button className="admin-btn small" onClick={() => openEdit(agent)}>编辑</button>
<button
className={`admin-btn small ${agent.status === 'published' ? 'warning' : 'success'}`}
onClick={() => handleToggle(agent)}
>
{agent.status === 'published' ? '禁用' : '启用'}
</button>
<button className="admin-btn small danger" onClick={() => handleDelete(agent)}>删除</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="admin-pagination">
<span style={{ color: '#6B7280', fontSize: 13 }}>共 {total} 条,第 {page}/{totalPages} 页</span>
<div style={{ display: 'flex', gap: 4 }}>
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
return p <= totalPages && (
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`} onClick={() => setPage(p)}>{p}</button>
);
})}
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{editingAgent ? '编辑 Agent' : '创建 Agent'}</h3>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">名称 *</label>
<input className="admin-form-input" value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
placeholder="例如: 智能代码审查助手" />
</div>
<div className="form-group">
<label className="form-label">图标 URL</label>
<input className="admin-form-input" value={form.icon}
onChange={e => setForm(f => ({ ...f, icon: e.target.value }))}
placeholder="https://..." />
</div>
<div className="form-group">
<label className="form-label">价格(元,0=免费)</label>
<input className="admin-form-input" type="number" value={form.price}
onChange={e => setForm(f => ({ ...f, price: e.target.value }))} />
</div>
<div className="form-group" style={{ display: 'flex', alignItems: 'center', paddingTop: 24 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={form.is_member_free}
onChange={e => setForm(f => ({ ...f, is_member_free: e.target.checked }))} />
会员免费
</label>
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">描述</label>
<textarea className="admin-form-input" rows={2} value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="描述 Agent 的功能和使用场景..." />
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
<div style={{ fontWeight: 600, marginBottom: 12 }}>⚙️ Agent 配置</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">系统提示词</label>
<textarea className="admin-form-input"
value={form.system_prompt}
onChange={e => setForm(f => ({ ...f, system_prompt: e.target.value }))}
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 120 }}
placeholder="定义 Agent 的角色、行为和能力..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">模型</label>
<select className="admin-form-input" value={form.model_id}
onChange={e => setForm(f => ({ ...f, model_id: e.target.value }))}>
<option value="">默认模型</option>
{availableModels.map(m => (
<option key={m.id} value={m.id}>{m.name} ({m.provider})</option>
))}
</select>
</div>
<div className="form-group">
<label className="form-label">温度参数</label>
<input className="admin-form-input" type="number" step="0.1" min="0" max="2"
value={form.temperature}
onChange={e => setForm(f => ({ ...f, temperature: e.target.value }))} />
</div>
<div className="form-group">
<label className="form-label">最大令牌数</label>
<input className="admin-form-input" type="number"
value={form.max_tokens}
onChange={e => setForm(f => ({ ...f, max_tokens: e.target.value }))} />
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={form.memory_enabled}
onChange={e => setForm(f => ({ ...f, memory_enabled: e.target.checked }))} />
记忆能力
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={form.reasoning_enabled}
onChange={e => setForm(f => ({ ...f, reasoning_enabled: e.target.checked }))} />
推理能力
</label>
</div>
{/* Linked Skills */}
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">关联技能(可多选)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
{availableSkills.length === 0 ? (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
) : availableSkills.map(skill => (
<label key={skill.id} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
borderRadius: 12, fontSize: 12, cursor: 'pointer',
background: form.skills.includes(skill.id) ? '#EEF2FF' : '#F9FAFB',
border: `1px solid ${form.skills.includes(skill.id) ? '#6366F1' : '#E5E7EB'}`,
color: form.skills.includes(skill.id) ? '#6366F1' : '#374151',
}}>
<input type="checkbox" style={{ display: 'none' }}
checked={form.skills.includes(skill.id)}
onChange={() => toggleSkill(skill.id)} />
{skill.skill_name}
{skill.state !== 'baby' && (
<span style={{ fontSize: 10, background: '#E5E7EB', padding: '1px 4px', borderRadius: 4, marginLeft: 4 }}>
{skill.state}
</span>
)}
</label>
))}
</div>
{form.skills.length > 0 && (
<div style={{ marginTop: 6, fontSize: 12, color: '#6366F1' }}>
已选 {form.skills.length} 个技能
</div>
)}
</div>
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
<button className="admin-btn primary" onClick={handleSave}>
{editingAgent ? '保存更改' : '创建 Agent'}
</button>
</div>
</div>
</div>
)}
{/* Detail Drawer */}
{showDetail && detailAgent && (
<div className="modal-overlay" onClick={() => setShowDetail(false)}>
<div className="modal-content" style={{ maxWidth: 600 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{detailAgent.name}</h3>
<button className="modal-close" onClick={() => setShowDetail(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
<div className="detail-item"><span className="detail-label">类型</span><span>{detailAgent.type}</span></div>
<div className="detail-item"><span className="detail-label">来源</span><span>{detailAgent.source === 'official' ? '官方' : '用户'}</span></div>
<div className="detail-item"><span className="detail-label">状态</span><span>{detailAgent.status}</span></div>
<div className="detail-item"><span className="detail-label">价格</span><span>¥{detailAgent.price}</span></div>
<div className="detail-item"><span className="detail-label">安装量</span><span>{detailAgent.install_count}</span></div>
<div className="detail-item"><span className="detail-label">评分</span><span>{detailAgent.rating_avg > 0 ? `★ ${detailAgent.rating_avg}` : '-'}</span></div>
<div className="detail-item"><span className="detail-label">发布者</span><span>{detailAgent.publisher_name || detailAgent.publisher_id}</span></div>
<div className="detail-item"><span className="detail-label">创建时间</span><span>{detailAgent.created_at?.slice(0, 16).replace('T', ' ')}</span></div>
</div>
{detailAgent.description && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontWeight: 500, marginBottom: 4 }}>描述</div>
<p style={{ color: '#6B7280', fontSize: 13, margin: 0 }}>{detailAgent.description}</p>
</div>
)}
{detailAgent.config && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}>⚙️ 配置</div>
<div style={{ background: '#F9FAFB', borderRadius: 8, padding: 12, fontSize: 13 }}>
{detailAgent.config.system_prompt && (
<div style={{ marginBottom: 8 }}>
<span style={{ color: '#6B7280' }}>System Prompt:</span>
<pre style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
{detailAgent.config.system_prompt}
</pre>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
<span>Temperature: <code>{detailAgent.config.temperature}</code></span>
<span>Max Tokens: <code>{detailAgent.config.max_tokens}</code></span>
<span>Model: <code>{detailAgent.config.model_id || '默认'}</code></span>
<span>记忆: <code>{detailAgent.config.memory_enabled ? '✓' : '✗'}</code></span>
<span>推理: <code>{detailAgent.config.reasoning_enabled ? '✓' : '✗'}</code></span>
<span>关联技能: <code>{detailAgent.config.skills?.length || 0} 个</code></span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}
<style>{`
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
.modal-header h3 { margin: 0; font-size: 18px; }
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
.modal-body { padding: 24px; overflow-y: auto; }
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
.detail-item { display: flex; gap: 8px; font-size: 13px; }
.detail-label { color: #6B7280; min-width: 60px; }
`}</style>
</div>
);
};
export default Agents;
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
interface Overview {
total_users: number; active_users: number; new_users: number;
api_calls: number; avg_response_time: number; error_rate: number;
tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number;
}
interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; }
interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; }
const Analytics: React.FC = () => {
const [days, setDays] = useState(7);
const [loading, setLoading] = useState(false);
const [overview, setOverview] = useState<Overview | null>(null);
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const [ov, api, tok, usr, rev, mu] = await Promise.all([
adminAPI.getAnalyticsOverview(days),
adminAPI.getAnalyticsApiTrends(days),
adminAPI.getAnalyticsTokenTrends(days),
adminAPI.getAnalyticsUserTrends(days),
adminAPI.getAnalyticsRevenueTrends(days),
adminAPI.getAnalyticsModelUsage(days),
]);
setOverview((ov as any)?.data || ov);
setApiTrend((api as any)?.data || []);
const td = (tok as any)?.data || { daily: [], model_distribution: {} };
setTokenTrend(td);
setUserTrend((usr as any)?.data || []);
setRevenueTrend((rev as any)?.data || []);
setModelUsage((mu as any)?.data || []);
} catch (e) { console.error('fetchAnalytics:', e); }
setLoading(false);
}, [days]);
useEffect(() => { fetchAll(); }, [fetchAll]);
const renderBar = (data: TrendItem[], key: string, color: string, label: string) => {
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
const values = data.map((d: any) => d[key] || 0);
const max = Math.max(...values, 1);
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
{data.map((d: any, i) => {
const v = d[key] || 0;
const h = (v / max) * 140;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
<div style={{ fontSize: '10px', color: '#8892B0', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
<div style={{ fontSize: '10px', color: '#6B7280', marginTop: '6px' }}>{d.date?.substring(5)}</div>
</div>
);
})}
</div>
);
};
const renderLine = (data: TrendItem[], key: string, color: string) => {
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无数据</div>;
const values = data.map((d: any) => d[key] || 0);
const max = Math.max(...values, 1);
const w = 100 / (data.length - 1 || 1);
const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' ');
return (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
{data.map((d: any, i) => (
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
))}
</svg>
);
};
const statCards = overview ? [
{ label: '总用户数', value: overview.total_users, color: '#6C63FF', icon: 'U' },
{ label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' },
{ label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' },
{ label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' },
{ label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' },
{ label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' },
{ label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' },
{ label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' },
] : [];
return (
<div style={{ padding: '24px', color: '#1A1F36' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
<div style={{ display: 'flex', gap: '8px' }}>
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: days === d ? '#6C63FF' : '#F0F2F8', color: days === d ? '#fff' : '#5A6480', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
))}
</div>
</div>
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#6B7280' }}>加载中...</div> : (
<>
{/* Stats grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{statCards.map((s, i) => (
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
<div>
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{s.label}</div>
</div>
</div>
))}
</div>
{/* Charts grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
</div>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '16px', marginBottom: '16px' }}>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
</div>
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
</div>
</div>
{/* Model usage */}
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
{modelUsage.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr style={{ borderBottom: '2px solid rgba(0,0,0,0.08)' }}>
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#8892B0', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{modelUsage.map((m, i) => (
<tr key={i} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#8892B0' }}>{m.avg_tokens.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
) : <div style={{ textAlign: 'center', padding: '40px', color: '#6B7280' }}>暂无模型使用数据</div>}
</div>
{/* Model distribution pie (CSS) */}
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
{(() => {
const entries = Object.entries(tokenTrend.model_distribution);
const total = entries.reduce((s, [, v]) => s + v, 1);
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
return entries.map(([model, val], i) => {
const pct = (val / total) * 100;
return <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
});
})()}
</div>
<div style={{ flex: 1 }}>
{Object.entries(tokenTrend.model_distribution).map(([model, val], i) => {
const colors = ['#6C63FF', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444'];
const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1);
return (
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
<span style={{ fontSize: '13px', color: '#1A1F36' }}>{model}</span>
<span style={{ fontSize: '13px', color: '#8892B0', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
</div>
);
})}
</div>
</div>
</div>
)}
</>
)}
</div>
);
};
export default Analytics;
+519
View File
@@ -0,0 +1,519 @@
.admin-apps-page {
padding: 24px;
}
.admin-apps-page .admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.admin-apps-page .admin-page-header h1 {
font-size: 24px;
font-weight: 600;
margin: 0;
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 20px;
text-align: center;
transition: box-shadow 0.2s;
}
.stat-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: var(--color-text-tertiary);
margin-top: 4px;
}
/* Tabs */
.tabs-bar {
display: flex;
gap: 4px;
margin-bottom: 20px;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.tab-btn {
padding: 8px 16px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: var(--color-text-secondary);
border-bottom: 2px solid transparent;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.tab-btn:hover {
color: var(--color-text-primary);
}
.tab-btn.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
font-weight: 500;
}
.tab-badge {
background: var(--color-accent);
color: white;
font-size: 11px;
padding: 1px 7px;
border-radius: 10px;
font-weight: 500;
}
.tab-btn:not(.active) .tab-badge {
background: var(--color-text-disabled);
}
/* Filter Bar */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-input, .filter-select {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
outline: none;
transition: border-color 0.2s;
}
.filter-input {
flex: 1;
min-width: 200px;
}
.filter-input:focus, .filter-select:focus {
border-color: var(--color-accent);
}
/* Table */
.admin-table {
width: 100%;
border-collapse: collapse;
background: var(--color-surface);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--color-border);
}
.admin-table th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 500;
color: var(--color-text-tertiary);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
}
.admin-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--color-border);
font-size: 14px;
color: var(--color-text-primary);
}
.admin-table tr:hover {
background: var(--color-bg-secondary);
}
.admin-table tr:last-child td {
border-bottom: none;
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
background: var(--color-bg-secondary);
color: var(--color-text-secondary);
}
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
/* Buttons */
.btn-primary, .btn-secondary, .btn-sm, .btn-danger, .btn-success, .btn-block {
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
}
.btn-primary {
padding: 8px 16px;
background: var(--color-accent);
color: white;
}
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
padding: 8px 16px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-secondary:hover { background: var(--color-border); }
.btn-sm {
padding: 4px 10px;
font-size: 12px;
background: var(--color-bg-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.btn-sm:hover { background: var(--color-border); }
.btn-success {
padding: 4px 10px;
font-size: 12px;
background: #10b981;
color: white;
}
.btn-success:hover { opacity: 0.9; }
.btn-danger {
padding: 4px 10px;
font-size: 12px;
background: #ef4444;
color: white;
}
.btn-danger:hover { opacity: 0.9; }
.btn-block {
width: 100%;
padding: 8px 16px;
background: var(--color-accent);
color: white;
margin-top: 8px;
}
.btn-block:hover { opacity: 0.9; }
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 20px;
font-size: 14px;
color: var(--color-text-secondary);
}
.pagination button {
padding: 6px 14px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-surface);
cursor: pointer;
font-size: 14px;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Templates Grid */
.templates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.template-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 8px;
transition: box-shadow 0.2s;
}
.template-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.template-icon {
font-size: 32px;
margin-bottom: 4px;
}
.template-name {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.template-desc {
font-size: 13px;
color: var(--color-text-tertiary);
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.template-meta {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
/* Analytics */
.analytics-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 20px;
text-align: center;
min-width: 120px;
}
.analytics-value {
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary);
}
.analytics-label {
font-size: 13px;
color: var(--color-text-tertiary);
margin-top: 4px;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--color-surface);
border-radius: 12px;
width: 90%;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.modal-close {
border: none;
background: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text-tertiary);
padding: 0;
line-height: 1;
}
.modal-close:hover { color: var(--color-text-primary); }
.modal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--color-border);
}
/* Form */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 14px;
background: var(--color-surface);
color: var(--color-text-primary);
outline: none;
transition: border-color 0.2s;
font-family: inherit;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--color-accent);
}
.form-row {
display: flex;
gap: 16px;
}
.form-row .form-group {
flex: 1;
}
.form-section-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin: 20px 0 12px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
}
/* Config Section */
.config-section {
background: var(--color-bg-secondary);
border-radius: 8px;
padding: 16px;
}
.config-item {
display: flex;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-size: 13px;
color: var(--color-text-tertiary);
min-width: 100px;
flex-shrink: 0;
}
.config-value {
font-size: 13px;
color: var(--color-text-primary);
word-break: break-word;
}
/* Version List */
.version-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.version-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--color-bg-secondary);
border-radius: 8px;
font-size: 13px;
}
.version-num {
font-weight: 600;
color: var(--color-accent);
min-width: 40px;
}
.version-log {
flex: 1;
color: var(--color-text-primary);
}
.version-date {
color: var(--color-text-tertiary);
font-size: 12px;
}
+799
View File
@@ -0,0 +1,799 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import './Apps.css';
interface AIApp {
id: string;
name: string;
description: string | null;
type: string;
category: string;
tags: string | null;
icon: string | null;
status: string;
version: number;
is_template: boolean;
is_public: boolean;
config: any;
created_at: string | null;
updated_at: string | null;
published_at: string | null;
analytics_7d?: any;
versions?: any[];
}
interface AppStats {
total: number;
published: number;
draft: number;
paused: number;
archived: number;
templates: number;
by_type: Record<string, number>;
by_category: Record<string, number>;
recent_7d: number;
total_versions: number;
}
interface Template {
id: string;
name: string;
description: string | null;
type: string;
category: string;
icon: string | null;
config: any;
created_at: string | null;
}
const CATEGORY_NAMES: Record<string, string> = {
customer_service: '客服助手',
data_analysis: '数据分析',
coding: '编程辅助',
writing: '内容创作',
education: '教育培训',
marketing: '营销推广',
hr: '人力资源',
finance: '财务法务',
custom: '自定义',
};
const TYPE_NAMES: Record<string, string> = {
chatbot: '聊天机器人',
assistant: 'AI助手',
workflow: '工作流',
api_service: 'API服务',
};
const STATUS_COLORS: Record<string, string> = {
draft: '#f59e0b',
published: '#10b981',
paused: '#6b7280',
archived: '#9ca3af',
};
const STATUS_NAMES: Record<string, string> = {
draft: '草稿',
published: '已发布',
paused: '已暂停',
archived: '已归档',
};
export default function AdminApps() {
const [activeTab, setActiveTab] = useState<'apps' | 'templates' | 'analytics'>('apps');
const [stats, setStats] = useState<AppStats | null>(null);
const [apps, setApps] = useState<AIApp[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editApp, setEditApp] = useState<AIApp | null>(null);
const [detailApp, setDetailApp] = useState<AIApp | null>(null);
const [detailAnalytics, setDetailAnalytics] = useState<any>(null);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getAIAppStats();
if (res.code === 0) setStats(res.data);
} catch (e) { console.error(e); }
}, []);
const fetchApps = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('page_size', '20');
if (search) params.set('search', search);
if (filterType) params.set('app_type', filterType);
if (filterStatus) params.set('status', filterStatus);
if (filterCategory) params.set('category', filterCategory);
const res = await adminAPI.getAIApps(params.toString());
if (res.code === 0) {
setApps(res.data.items || []);
setTotal(res.data.total || 0);
setTotalPages(res.data.total_pages || 1);
}
} catch (e) { console.error(e); }
finally { setLoading(false); }
}, [page, search, filterType, filterStatus, filterCategory]);
const fetchTemplates = useCallback(async () => {
try {
const res = await adminAPI.getAIAppTemplates();
if (res.code === 0) setTemplates(res.data.items || []);
} catch (e) { console.error(e); }
}, []);
const fetchDetail = useCallback(async (id: string) => {
try {
const res = await adminAPI.getAIApp(id);
if (res.code === 0) {
setDetailApp(res.data);
// Fetch analytics
try {
const aRes = await adminAPI.getAIAppAnalytics(id);
if (aRes.code === 0) setDetailAnalytics(aRes.data);
} catch (e) { /* analytics may be empty */ }
}
} catch (e) { console.error(e); }
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
useEffect(() => {
if (activeTab === 'apps') fetchApps();
if (activeTab === 'templates') fetchTemplates();
}, [activeTab, fetchApps, fetchTemplates]);
const handleCreate = async (data: any) => {
try {
const res = await adminAPI.createAIApp(data);
if (res.code === 0) {
setShowCreateModal(false);
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleUpdate = async (data: any) => {
if (!editApp) return;
try {
const res = await adminAPI.updateAIApp(editApp.id, data);
if (res.code === 0) {
setEditApp(null);
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除此应用吗?此操作不可逆。')) return;
try {
const res = await adminAPI.deleteAIApp(id);
if (res.code === 0) {
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleLifecycle = async (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => {
try {
const res = await adminAPI.aiAppLifecycle(id, action);
if (res.code === 0) {
fetchApps();
fetchStats();
if (detailApp?.id === id) fetchDetail(id);
}
} catch (e) { console.error(e); }
};
const handleCreateTemplate = async (data: any) => {
try {
const res = await adminAPI.createAIAppTemplate(data);
if (res.code === 0) {
setShowTemplateModal(false);
fetchTemplates();
fetchStats();
}
} catch (e) { console.error(e); }
};
const handleFromTemplate = async (templateId: string, name: string) => {
try {
const res = await adminAPI.createAIAppFromTemplate(templateId, { name });
if (res.code === 0) {
setActiveTab('apps');
fetchApps();
fetchStats();
}
} catch (e) { console.error(e); }
};
const formatDate = (dt: string | null) => {
if (!dt) return '-';
try {
const d = new Date(dt);
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} catch { return dt; }
};
return (
<div className="admin-apps-page">
<div className="admin-page-header">
<h1>AI应用管理</h1>
<div style={{ display: 'flex', gap: '8px' }}>
{activeTab === 'apps' && (
<button className="btn-primary" onClick={() => setShowCreateModal(true)}>+ 创建应用</button>
)}
{activeTab === 'templates' && (
<button className="btn-primary" onClick={() => setShowTemplateModal(true)}>+ 创建模板</button>
)}
</div>
</div>
{/* Stats Cards */}
{stats && (
<div className="stats-grid">
<div className="stat-card">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">应用总数</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10b981' }}>{stats.published}</div>
<div className="stat-label">已发布</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#f59e0b' }}>{stats.draft}</div>
<div className="stat-label">草稿</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.templates}</div>
<div className="stat-label">应用模板</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#3b82f6' }}>{stats.recent_7d}</div>
<div className="stat-label">近7天新增</div>
</div>
</div>
)}
{/* Tabs */}
<div className="tabs-bar">
{([
{ key: 'apps' as const, label: '应用列表', count: stats?.total },
{ key: 'templates' as const, label: '应用模板', count: stats?.templates },
{ key: 'analytics' as const, label: '分析概览', count: undefined as number | undefined },
]).map(tab => (
<button
key={tab.key}
className={`tab-btn ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className="tab-badge">{tab.count}</span>
)} </button>
))}
</div>
{/* Apps Tab */}
{activeTab === 'apps' && (
<div>
{/* Filters */}
<div className="filter-bar">
<input
type="text"
placeholder="搜索应用名称..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
className="filter-input"
/>
<select value={filterType} onChange={e => { setFilterType(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部类型</option>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部状态</option>
{Object.entries(STATUS_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} className="filter-select">
<option value="">全部分类</option>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{/* Table */}
<table className="admin-table">
<thead>
<tr>
<th>应用名称</th>
<th>类型</th>
<th>分类</th>
<th>状态</th>
<th>版本</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px' }}>加载中...</td></tr>
) : apps.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)' }}>暂无应用</td></tr>
) : apps.map(app => (
<tr key={app.id} style={{ cursor: 'pointer' }} onClick={() => fetchDetail(app.id)}>
<td>
<div style={{ fontWeight: 500 }}>{app.name}</div>
{app.description && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{app.description}</div>}
</td>
<td><span className="badge">{TYPE_NAMES[app.type] || app.type}</span></td>
<td>{CATEGORY_NAMES[app.category] || app.category}</td>
<td>
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
{STATUS_NAMES[app.status] || app.status}
</span>
</td>
<td>v{app.version}</td>
<td style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{formatDate(app.created_at)}</td>
<td onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<button className="btn-sm" onClick={() => setEditApp(app)}>编辑</button>
{app.status === 'draft' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>发布</button>}
{app.status === 'published' && <button className="btn-sm" onClick={() => handleLifecycle(app.id, 'pause')}>暂停</button>}
{app.status === 'paused' && <button className="btn-sm btn-success" onClick={() => handleLifecycle(app.id, 'publish')}>恢复</button>}
<button className="btn-sm" onClick={() => handleLifecycle(app.id, 'duplicate')}>复制</button>
<button className="btn-sm btn-danger" onClick={() => handleDelete(app.id)}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination */}
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span>第 {page} / {totalPages} 页 (共 {total} 条)</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Templates Tab */}
{activeTab === 'templates' && (
<div className="templates-grid">
{templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
暂无模板,点击右上角"创建模板"添加
</div>
) : templates.map(tpl => (
<div key={tpl.id} className="template-card">
<div className="template-icon">{tpl.icon || '📦'}</div>
<div className="template-name">{tpl.name}</div>
<div className="template-desc">{tpl.description || '暂无描述'}</div>
<div className="template-meta">
<span className="badge">{TYPE_NAMES[tpl.type] || tpl.type}</span>
<span className="badge">{CATEGORY_NAMES[tpl.category] || tpl.category}</span>
</div>
<button className="btn-primary btn-block" onClick={() => {
const name = prompt('请输入应用名称', tpl.name + ' (Instance)');
if (name) handleFromTemplate(tpl.id, name);
}}>从模板创建</button>
</div>
))}
</div>
)}
{/* Analytics Tab */}
{activeTab === 'analytics' && stats && (
<div>
<h3 style={{ marginBottom: '16px' }}>应用类型分布</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', marginBottom: '32px' }}>
{Object.entries(stats.by_type).map(([type, count]) => (
<div key={type} className="analytics-card">
<div className="analytics-value">{count}</div>
<div className="analytics-label">{TYPE_NAMES[type] || type}</div>
</div>
))}
{Object.keys(stats.by_type).length === 0 && (
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
)}
</div>
<h3 style={{ marginBottom: '16px' }}>分类分布</h3>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{Object.entries(stats.by_category).map(([cat, count]) => (
<div key={cat} className="analytics-card">
<div className="analytics-value">{count}</div>
<div className="analytics-label">{CATEGORY_NAMES[cat] || cat}</div>
</div>
))}
{Object.keys(stats.by_category).length === 0 && (
<div style={{ color: 'var(--color-text-tertiary)' }}>暂无数据</div>
)}
</div>
<h3 style={{ marginTop: '32px', marginBottom: '16px' }}>版本统计</h3>
<div className="analytics-card" style={{ display: 'inline-block' }}>
<div className="analytics-value">{stats.total_versions}</div>
<div className="analytics-label">总版本数</div>
</div>
</div>
)}
{/* Create/Edit Modal */}
{(showCreateModal || editApp) && (
<AppFormModal
app={editApp}
onClose={() => { setShowCreateModal(false); setEditApp(null); }}
onSubmit={editApp ? handleUpdate : handleCreate}
/>
)}
{/* Template Modal */}
{showTemplateModal && (
<TemplateFormModal
onClose={() => setShowTemplateModal(false)}
onSubmit={handleCreateTemplate}
/>
)}
{/* Detail Drawer */}
{detailApp && (
<AppDetailDrawer
app={detailApp}
analytics={detailAnalytics}
onClose={() => { setDetailApp(null); setDetailAnalytics(null); }}
onAction={handleLifecycle}
/>
)}
</div>
);
}
// ============================================================
// App Form Modal
// ============================================================
function AppFormModal({ app, onClose, onSubmit }: {
app: AIApp | null;
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState(app?.name || '');
const [description, setDescription] = useState(app?.description || '');
const [type, setType] = useState(app?.type || 'chatbot');
const [category, setCategory] = useState(app?.category || 'custom');
const [tags, setTags] = useState(app?.tags || '');
const [systemPrompt, setSystemPrompt] = useState(app?.config?.system_prompt || '');
const [temperature, setTemperature] = useState(app?.config?.temperature ?? 0.7);
const [maxTokens, setMaxTokens] = useState(app?.config?.max_tokens ?? 2000);
const [welcomeMessage, setWelcomeMessage] = useState(app?.config?.welcome_message || '');
const handleSubmit = () => {
if (!name.trim()) { alert('请输入应用名称'); return; }
onSubmit({
name, description, type, category, tags,
config: {
...(app?.config || {}),
system_prompt: systemPrompt,
temperature: Number(temperature),
max_tokens: Number(maxTokens),
welcome_message: welcomeMessage,
}
});
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{app ? '编辑应用' : '创建应用'}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>应用名称 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="输入应用名称" />
</div>
<div className="form-row">
<div className="form-group">
<label>类型</label>
<select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-group">
<label>分类</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label>描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="应用描述" rows={2} />
</div>
<div className="form-group">
<label>标签(逗号分隔)</label>
<input type="text" value={tags} onChange={e => setTags(e.target.value)} placeholder="如:客服,电商,自动回复" />
</div>
<div className="form-section-title">AI配置</div>
<div className="form-group">
<label>系统提示词 (System Prompt)</label>
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="定义AI的角色和行为..." rows={4} />
</div>
<div className="form-row">
<div className="form-group">
<label>温度 (Temperature): {temperature}</label>
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div className="form-group">
<label>最大Token数</label>
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min="100" max="32000" />
</div>
</div>
<div className="form-group">
<label>欢迎消息</label>
<input type="text" value={welcomeMessage} onChange={e => setWelcomeMessage(e.target.value)} placeholder="用户打开应用时显示的欢迎语" />
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>{app ? '保存' : '创建'}</button>
</div>
</div>
</div>
);
}
// ============================================================
// Template Form Modal
// ============================================================
function TemplateFormModal({ onClose, onSubmit }: {
onClose: () => void;
onSubmit: (data: any) => void;
}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [type, setType] = useState('chatbot');
const [category, setCategory] = useState('custom');
const [systemPrompt, setSystemPrompt] = useState('');
const [temperature, setTemperature] = useState(0.7);
const [maxTokens, setMaxTokens] = useState(2000);
const handleSubmit = () => {
if (!name.trim()) { alert('请输入模板名称'); return; }
onSubmit({
name, description, type, category,
config: {
system_prompt: systemPrompt,
temperature: Number(temperature),
max_tokens: Number(maxTokens),
skill_ids: [],
knowledge_base_ids: [],
tools: [],
welcome_message: ''
}
});
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" style={{ maxWidth: '500px' }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>创建应用模板</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>模板名称 *</label>
<input type="text" value={name} onChange={e => setName(e.target.value)} placeholder="如:电商客服机器人" />
</div>
<div className="form-row">
<div className="form-group">
<label>类型</label>
<select value={type} onChange={e => setType(e.target.value)}>
{Object.entries(TYPE_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div className="form-group">
<label>分类</label>
<select value={category} onChange={e => setCategory(e.target.value)}>
{Object.entries(CATEGORY_NAMES).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="form-group">
<label>描述</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} placeholder="模板描述" rows={2} />
</div>
<div className="form-group">
<label>系统提示词</label>
<textarea value={systemPrompt} onChange={e => setSystemPrompt(e.target.value)} placeholder="预置的系统提示词..." rows={4} />
</div>
<div className="form-row">
<div className="form-group">
<label>温度: {temperature}</label>
<input type="range" min="0" max="2" step="0.1" value={temperature} onChange={e => setTemperature(Number(e.target.value))} style={{ width: '100%' }} />
</div>
<div className="form-group">
<label>最大Token数</label>
<input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} />
</div>
</div>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>取消</button>
<button className="btn-primary" onClick={handleSubmit}>创建模板</button>
</div>
</div>
</div>
);
}
// ============================================================
// App Detail Drawer
// ============================================================
function AppDetailDrawer({ app, analytics, onClose, onAction }: {
app: AIApp;
analytics: any;
onClose: () => void;
onAction: (id: string, action: 'publish' | 'pause' | 'archive' | 'duplicate') => void;
}) {
const a7 = app.analytics_7d as any;
const summary = analytics?.summary;
return (
<div className="modal-overlay" onClick={onClose} style={{ justifyContent: 'flex-end' }}>
<div className="modal-content" style={{ maxWidth: '560px', height: '100vh', borderRadius: 0 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{app.name}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body" style={{ overflow: 'auto' }}>
{/* Status & Meta */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<span className="status-badge" style={{ background: STATUS_COLORS[app.status] + '20', color: STATUS_COLORS[app.status] }}>
{STATUS_NAMES[app.status] || app.status}
</span>
<span className="badge">{TYPE_NAMES[app.type] || app.type}</span>
<span className="badge">{CATEGORY_NAMES[app.category] || app.category}</span>
<span className="badge">v{app.version}</span>
</div>
{app.description && <p style={{ color: 'var(--color-text-secondary)', marginBottom: '16px' }}>{app.description}</p>}
{/* Quick Stats */}
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(3, 1fr)', marginBottom: '24px' }}>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.conversations || 0}</div>
<div className="stat-label">7天对话</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.tokens || 0}</div>
<div className="stat-label">7天Token</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '20px' }}>{a7?.errors || 0}</div>
<div className="stat-label">7天错误</div>
</div>
</div>
{/* Config */}
<h3 style={{ marginBottom: '12px' }}>应用配置</h3>
<div className="config-section">
{app.config?.system_prompt && (
<div className="config-item">
<span className="config-label">系统提示词</span>
<div className="config-value">{app.config.system_prompt}</div>
</div>
)}
<div className="config-item">
<span className="config-label">温度</span>
<div className="config-value">{app.config?.temperature ?? 'N/A'}</div>
</div>
<div className="config-item">
<span className="config-label">最大Token</span>
<div className="config-value">{app.config?.max_tokens ?? 'N/A'}</div>
</div>
{app.config?.skill_ids?.length > 0 && (
<div className="config-item">
<span className="config-label">绑定技能</span>
<div className="config-value">{app.config.skill_ids.length} 个技能</div>
</div>
)}
{app.config?.knowledge_base_ids?.length > 0 && (
<div className="config-item">
<span className="config-label">绑定知识库</span>
<div className="config-value">{app.config.knowledge_base_ids.length} 个知识库</div>
</div>
)}
</div>
{/* Versions */}
{app.versions && app.versions.length > 0 && (
<>
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>版本历史</h3>
<div className="version-list">
{app.versions.map((v: any) => (
<div key={v.id} className="version-item">
<span className="version-num">v{v.version}</span>
<span className="version-log">{v.changelog}</span>
<span className="version-date">{v.created_at ? new Date(v.created_at).toLocaleDateString('zh-CN') : '-'}</span>
</div>
))}
</div>
</>
)}
{/* Analytics Detail */}
{summary && (
<>
<h3 style={{ marginTop: '24px', marginBottom: '12px' }}>分析数据 ({analytics.days}天)</h3>
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(2, 1fr)', marginBottom: '16px' }}>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.conversations}</div>
<div className="stat-label">总对话数</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.messages}</div>
<div className="stat-label">总消息数</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.tokens_input + summary.tokens_output}</div>
<div className="stat-label">总Token</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.active_users}</div>
<div className="stat-label">活跃用户</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.avg_response_ms}ms</div>
<div className="stat-label">平均响应</div>
</div>
<div className="stat-card" style={{ padding: '12px' }}>
<div className="stat-value" style={{ fontSize: '18px' }}>{summary.error_rate}%</div>
<div className="stat-label">错误率</div>
</div>
</div>
</>
)}
{/* Timestamps */}
<div style={{ marginTop: '24px', fontSize: '13px', color: 'var(--color-text-tertiary)' }}>
<div>创建时间: {app.created_at ? new Date(app.created_at).toLocaleString('zh-CN') : '-'}</div>
<div>更新时间: {app.updated_at ? new Date(app.updated_at).toLocaleString('zh-CN') : '-'}</div>
{app.published_at && <div>发布时间: {new Date(app.published_at).toLocaleString('zh-CN')}</div>}
</div>
</div>
{/* Actions */}
<div className="modal-footer" style={{ justifyContent: 'flex-start' }}>
{app.status === 'draft' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>发布</button>}
{app.status === 'published' && <button className="btn-secondary" onClick={() => onAction(app.id, 'pause')}>暂停</button>}
{app.status === 'paused' && <button className="btn-primary" onClick={() => onAction(app.id, 'publish')}>恢复发布</button>}
<button className="btn-secondary" onClick={() => onAction(app.id, 'duplicate')}>复制应用</button>
<button className="btn-secondary" onClick={() => onAction(app.id, 'archive')}>归档</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,451 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
// ============================================================
// Types
// ============================================================
interface AssetItem {
id: string;
user_id: string;
name: string;
category: string;
file_type: string | null;
size: number;
size_formatted: string;
description: string | null;
url: string | null;
cover_url: string | null;
status: string;
username: string;
avatar: string | null;
created_at: string | null;
updated_at: string | null;
}
interface StatsData {
total_assets: number;
total_size: number;
total_size_formatted: string;
by_category: Array<{ category: string; label: string; count: number; size: number; size_formatted: string }>;
by_file_type: Array<{ file_type: string; count: number; size: number; size_formatted: string }>;
top_users: Array<{ user_id: string; username: string; avatar: string | null; asset_count: number; total_size: number; total_size_formatted: string }>;
quota_summary: {
total_users: number;
total_quota: number;
total_used: number;
total_quota_formatted: string;
total_used_formatted: string;
usage_percent: number;
};
trend_7d: Array<{ date: string; new_uploads: number }>;
}
interface ActivityItem {
id: string;
name: string;
category: string;
category_label: string;
file_type: string | null;
size: number;
size_formatted: string;
status: string;
username: string;
avatar: string | null;
created_at: string | null;
updated_at: string | null;
}
const CAT_LABELS: Record<string, string> = {
file: '文件', novel: '小说', image: '图片', video: '视频', audio: '音频', custom: '自定义', doc: '文档',
};
const CAT_COLORS: Record<string, string> = {
file: '#3B82F6', novel: '#8B5CF6', image: '#10B981', video: '#F59E0B', audio: '#EF4444', custom: '#6B7280', doc: '#06B6D4',
};
// ============================================================
// Icon
// ============================================================
const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => {
const paths: Record<string, string> = {
chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', folder: 'M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z',
activity: 'M22 12h-4l-3 9L9 3l-3 9H2', trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2',
user: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z',
database: 'M12 2C6.48 2 2 3.79 2 6s4.48 4 10 4 10-1.79 10-4-4.48-4-10-4zM2 6v6c0 2.21 4.48 4 10 4s10-1.79 10-4V6M2 12v6c0 2.21 4.48 4 10 4s10-1.79 10-4v-6',
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<path d={paths[name] || ''} />
</svg>
);
};
// ============================================================
// Main Component
// ============================================================
const AssetCenter: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'assets' | 'activity'>('overview');
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<StatsData | null>(null);
const [assets, setAssets] = useState<AssetItem[]>([]);
const [assetsTotal, setAssetsTotal] = useState(0);
const [assetsPage, setAssetsPage] = useState(1);
const [activities, setActivities] = useState<ActivityItem[]>([]);
// Filters
const [filterCategory, setFilterCategory] = useState('');
const [filterFileType, setFilterFileType] = useState('');
const [filterKeyword, setFilterKeyword] = useState('');
const loadStats = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getAssetStats();
if (res?.code === 0) setStats(res.data as any);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
const loadAssets = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterCategory) params.set('category', filterCategory);
if (filterFileType) params.set('file_type', filterFileType);
if (filterKeyword) params.set('keyword', filterKeyword);
params.set('page', String(assetsPage));
params.set('page_size', '20');
const res = await adminAPI.getAdminAssets(params.toString());
if (res?.code === 0) {
const d = res.data as any;
setAssets(d?.items || []);
setAssetsTotal(d?.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [filterCategory, filterFileType, filterKeyword, assetsPage]);
const loadActivities = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getAssetActivity();
if (res?.code === 0) setActivities(res.data || []);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
useEffect(() => {
if (activeTab === 'overview') loadStats();
else if (activeTab === 'assets') loadAssets();
else if (activeTab === 'activity') loadActivities();
}, [activeTab, loadStats, loadAssets, loadActivities]);
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确认删除资产「${name}」?`)) return;
try {
await adminAPI.deleteAdminAsset(id);
loadAssets();
loadStats();
} catch (e) { alert('删除失败'); }
};
const tabs = [
{ key: 'overview', label: '总览', icon: 'chart' },
{ key: 'assets', label: '资产管理', icon: 'folder' },
{ key: 'activity', label: '活动记录', icon: 'activity' },
] as const;
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px', color: 'var(--color-text-primary)' }}>资产中心</h1>
<p style={{ fontSize: '14px', color: 'var(--color-text-tertiary)', margin: 0 }}>管理用户资产、存储分析、活动追踪</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--color-border)' }}>
{tabs.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} style={{
padding: '10px 20px', border: 'none',
background: activeTab === tab.key ? 'var(--color-primary)' : 'transparent',
color: activeTab === tab.key ? '#fff' : 'var(--color-text-secondary)',
borderRadius: '8px 8px 0 0', cursor: 'pointer', fontSize: '14px', fontWeight: 500,
display: 'flex', alignItems: 'center', gap: '6px', transition: 'all 0.2s',
}}>
<Icon name={tab.icon} size={16} /> {tab.label}
</button>
))}
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<div className="audit-spinner" style={{ margin: '0 auto 12px' }} />
<span>加载中...</span>
</div>
)}
{/* Overview */}
{!loading && activeTab === 'overview' && stats && <OverviewTab stats={stats} />}
{/* Assets */}
{!loading && activeTab === 'assets' && (
<AssetsTab
assets={assets} total={assetsTotal} page={assetsPage} onPageChange={setAssetsPage}
filterCategory={filterCategory} setFilterCategory={setFilterCategory}
filterFileType={filterFileType} setFilterFileType={setFilterFileType}
filterKeyword={filterKeyword} setFilterKeyword={setFilterKeyword}
onDelete={handleDelete}
/>
)}
{/* Activity */}
{!loading && activeTab === 'activity' && <ActivityTab activities={activities} />}
</div>
);
};
// ============================================================
// Overview Tab
// ============================================================
const OverviewTab: React.FC<{ stats: StatsData }> = ({ stats }) => {
const maxTrend = Math.max(...stats.trend_7d.map(t => t.new_uploads), 1);
return (
<div>
{/* Stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{[
{ label: '总资产数', value: stats.total_assets, color: 'var(--color-primary)' },
{ label: '总存储量', value: stats.total_size_formatted, color: 'var(--color-success)' },
{ label: '配额使用', value: `${stats.quota_summary.usage_percent}%`, sub: `${stats.quota_summary.total_used_formatted} / ${stats.quota_summary.total_quota_formatted}`, color: 'var(--color-warning)' },
{ label: '配额用户', value: stats.quota_summary.total_users, color: 'var(--color-danger)' },
].map((s, i) => (
<div key={i} style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', marginBottom: '8px' }}>{s.label}</div>
<div style={{ fontSize: '28px', fontWeight: 700, color: s.color }}>{s.value}</div>
{s.sub && <div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '4px' }}>{s.sub}</div>}
</div>
))}
</div>
{/* 7-day trend + Category distribution */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '24px' }}>
{/* Trend */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 20px' }}>7日上传趋势</h3>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', height: '140px' }}>
{stats.trend_7d.map((t, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div style={{
width: '24px', height: `${(t.new_uploads / maxTrend) * 110 + 4}px`,
background: 'var(--color-primary)', borderRadius: '4px 4px 0 0', minHeight: '4px',
}} title={`${t.new_uploads} uploads`} />
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{t.date.slice(5)}</span>
</div>
))}
</div>
</div>
{/* Category distribution */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>分类分布</h3>
{stats.by_category.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
) : (
stats.by_category.map(cat => (
<div key={cat.category} style={{ marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '8px', height: '8px', borderRadius: '2px', background: CAT_COLORS[cat.category] || '#6B7280' }} />
{cat.label} ({cat.count})
</span>
<span style={{ fontSize: '12px', color: 'var(--color-text-tertiary)' }}>{cat.size_formatted}</span>
</div>
<div style={{ height: '6px', background: 'var(--color-bg-tertiary)', borderRadius: '3px', overflow: 'hidden' }}>
<div style={{
height: '100%', width: `${stats.total_assets > 0 ? (cat.count / stats.total_assets * 100) : 0}%`,
background: CAT_COLORS[cat.category] || '#6B7280', borderRadius: '3px', transition: 'width 0.3s',
}} />
</div>
</div>
))
)}
</div>
</div>
{/* Top users + File types */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '16px' }}>
{/* Top users */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>存储量 Top 10 用户</h3>
{stats.top_users.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
) : (
stats.top_users.map((u, i) => (
<div key={u.user_id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '14px', fontWeight: 700, color: i < 3 ? 'var(--color-warning)' : 'var(--color-text-tertiary)', width: '24px' }}>#{i + 1}</span>
{u.avatar ? <img src={u.avatar} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} /> : <div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px' }}>{u.username?.charAt(0) || 'U'}</div>}
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500 }}>{u.username}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{u.asset_count} 个资产</div>
</div>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{u.total_size_formatted}</span>
</div>
))
)}
</div>
{/* File types */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px', border: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>文件类型</h3>
{stats.by_file_type.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px', color: 'var(--color-text-tertiary)', fontSize: '13px' }}>暂无数据</div>
) : (
stats.by_file_type.map(ft => (
<div key={ft.file_type} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '13px', fontFamily: 'monospace' }}>{ft.file_type}</span>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '13px', fontWeight: 600 }}>{ft.count}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{ft.size_formatted}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
);
};
// ============================================================
// Assets Tab
// ============================================================
const AssetsTab: React.FC<{
assets: AssetItem[]; total: number; page: number; onPageChange: (p: number) => void;
filterCategory: string; setFilterCategory: (v: string) => void;
filterFileType: string; setFilterFileType: (v: string) => void;
filterKeyword: string; setFilterKeyword: (v: string) => void;
onDelete: (id: string, name: string) => void;
}> = ({ assets, total, page, onPageChange, filterCategory, setFilterCategory, filterFileType, setFilterFileType, filterKeyword, setFilterKeyword, onDelete }) => {
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Filters */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<input placeholder="搜索资产..." value={filterKeyword} onChange={e => setFilterKeyword(e.target.value)} style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部分类</option>
{Object.entries(CAT_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<select value={filterFileType} onChange={e => setFilterFileType(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部类型</option>
<option value="pdf">PDF</option>
<option value="docx">Word</option>
<option value="xlsx">Excel</option>
<option value="pptx">PPT</option>
<option value="txt">TXT</option>
<option value="md">Markdown</option>
<option value="csv">CSV</option>
</select>
</div>
{/* Table */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['资产名称', '用户', '分类', '类型', '大小', '创建时间', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{assets.map(a => (
<tr key={a.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{a.cover_url ? <img src={a.cover_url} alt="" style={{ width: '28px', height: '28px', borderRadius: '4px' }} /> : <div style={{ width: '28px', height: '28px', borderRadius: '4px', background: CAT_COLORS[a.category] || '#6B7280', opacity: 0.2, display: 'flex', alignItems: 'center', justifyContent: 'center' }}><Icon name="folder" size={14} color={CAT_COLORS[a.category] || '#6B7280'} /></div>}
<span style={{ fontSize: '13px', fontWeight: 500 }}>{a.name}</span>
</div>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{a.avatar ? <img src={a.avatar} alt="" style={{ width: '20px', height: '20px', borderRadius: '50%' }} /> : null}
{a.username}
</div>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '4px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', color: CAT_COLORS[a.category] || '#6B7280' }}>{CAT_LABELS[a.category] || a.category}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', fontFamily: 'monospace' }}>{a.file_type || '—'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{a.size_formatted}</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>{a.created_at?.slice(0, 16) || '—'}</td>
<td style={{ padding: '12px 16px' }}>
<button onClick={() => onDelete(a.id, a.name)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
{assets.length === 0 && <div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无资产</div>}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>上一页</button>
<span style={{ padding: '6px 12px', fontSize: '13px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page >= totalPages ? 'not-allowed' : 'pointer', opacity: page >= totalPages ? 0.5 : 1 }}>下一页</button>
</div>
)}
</div>
);
};
// ============================================================
// Activity Tab
// ============================================================
const ActivityTab: React.FC<{ activities: ActivityItem[] }> = ({ activities }) => (
<div>
{activities.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<Icon name="activity" size={48} color="var(--color-text-tertiary)" />
<p style={{ marginTop: '16px', fontSize: '14px' }}>暂无活动记录</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{activities.map(a => (
<div key={a.id} style={{ background: 'var(--color-bg-secondary)', borderRadius: '10px', padding: '14px 16px', border: '1px solid var(--color-border)', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ width: '36px', height: '36px', borderRadius: '8px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="folder" size={18} color={CAT_COLORS[a.category] || '#6B7280'} />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 500 }}>{a.name}</span>
<span style={{ fontSize: '11px', padding: '1px 6px', borderRadius: '3px', background: (CAT_COLORS[a.category] || '#6B7280') + '22', color: CAT_COLORS[a.category] || '#6B7280' }}>{a.category_label}</span>
{a.file_type && <span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)', fontFamily: 'monospace' }}>{a.file_type}</span>}
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{a.size_formatted}</span>
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '2px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{a.avatar ? <img src={a.avatar} alt="" style={{ width: '16px', height: '16px', borderRadius: '50%' }} /> : null}
{a.username}
</span>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', color: a.status === 'active' ? 'var(--color-success)' : 'var(--color-danger)' }}>
{a.status === 'active' ? '活跃' : '已删除'}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{a.updated_at?.slice(0, 16) || '—'}</div>
</div>
</div>
))}
</div>
)}
</div>
);
export default AssetCenter;
+599
View File
@@ -0,0 +1,599 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface AutomationTask {
id: string;
name: string;
description: string;
trigger_type: string;
trigger_type_label: string;
cron_expression: string;
action_type: string;
action_type_label: string;
action_config: Record<string, any>;
event_condition: Record<string, any>;
is_enabled: boolean;
status: string;
max_retries: number;
timeout_seconds: number;
last_run_at: string;
next_run_at: string;
total_runs: number;
success_runs: number;
created_at: string;
updated_at: string;
}
interface ExecLog {
id: string;
task_id: string;
task_name: string;
status: string;
trigger_type: string;
started_at: string;
finished_at: string;
duration_ms: number;
result_summary: string;
error_message: string;
created_at: string;
}
interface AutomationStats {
total_tasks: number;
enabled_tasks: number;
running_tasks: number;
by_trigger: Record<string, number>;
recent_executions: number;
recent_success: number;
}
const TRIGGER_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
cron: { label: '定时触发', color: '#6C63FF', bg: '#EDE9FE' },
webhook: { label: 'Webhook', color: '#059669', bg: '#D1FAE5' },
event: { label: '事件触发', color: '#D97706', bg: '#FEF3C7' },
};
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
idle: { label: '空闲', color: '#6B7280', bg: '#F3F4F6' },
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
success: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
failed: { label: '失败', color: '#EF4444', bg: '#FEE2E2' },
};
const PAGE_SIZE = 15;
const Automation: React.FC = () => {
// Data state
const [tasks, setTasks] = useState<AutomationTask[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<AutomationStats | null>(null);
// Filter state
const [filterTrigger, setFilterTrigger] = useState('');
const [keyword, setKeyword] = useState('');
// Modal state
const [modalVisible, setModalVisible] = useState(false);
const [editingTask, setEditingTask] = useState<AutomationTask | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
trigger_type: 'cron',
cron_expression: '',
event_condition_str: '',
action_type: 'ai_pipeline',
action_config_str: '{"pipeline_name": "default"}',
is_enabled: true,
max_retries: 3,
timeout_seconds: 300,
});
const [submitting, setSubmitting] = useState(false);
// Execution history
const [historyVisible, setHistoryVisible] = useState(false);
const [historyTaskId, setHistoryTaskId] = useState('');
const [historyTaskName, setHistoryTaskName] = useState('');
const [historyLogs, setHistoryLogs] = useState<ExecLog[]>([]);
const [historyPage, setHistoryPage] = useState(1);
const [historyTotal, setHistoryTotal] = useState(0);
const [executingId, setExecutingId] = useState<string | null>(null);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// Load stats
const loadStats = async () => {
try {
const result = await adminAPI.getAdminAutomationStats() as any;
setStats(result.data || null);
} catch (e) { /* ignore */ }
};
// Load tasks
const loadTasks = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (filterTrigger) params.trigger_type = filterTrigger;
if (keyword) params.keyword = keyword;
const result = await adminAPI.getAdminAutomationTasks(params) as any;
const data = result.data || result;
setTasks(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast('加载失败: ' + (e.message || e), 'error');
} finally {
setLoading(false);
}
};
useEffect(() => { loadStats(); }, []);
useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]);
// Open create modal
const openCreateModal = () => {
setEditingTask(null);
setFormData({
name: '',
description: '',
trigger_type: 'cron',
cron_expression: '*/5 * * * *',
event_condition_str: '',
action_type: 'ai_pipeline',
action_config_str: '{"pipeline_name": "default"}',
is_enabled: true,
max_retries: 3,
timeout_seconds: 300,
});
setModalVisible(true);
};
// Open edit modal
const openEditModal = async (task: AutomationTask) => {
try {
const result = await adminAPI.getAdminAutomationTask(task.id) as any;
const detail = result.data;
setEditingTask(task);
setFormData({
name: detail.name || '',
description: detail.description || '',
trigger_type: detail.trigger_type || 'cron',
cron_expression: detail.cron_expression || '',
event_condition_str: detail.event_condition ? JSON.stringify(detail.event_condition, null, 2) : '',
action_type: detail.action_type || 'ai_pipeline',
action_config_str: detail.action_config ? JSON.stringify(detail.action_config, null, 2) : '{}',
is_enabled: detail.is_enabled !== false,
max_retries: detail.max_retries || 3,
timeout_seconds: detail.timeout_seconds || 300,
});
setModalVisible(true);
} catch (e: any) {
showToast('获取任务详情失败', 'error');
}
};
// Submit
const handleSubmit = async () => {
if (!formData.name.trim()) { showToast('请输入任务名称', 'error'); return; }
if (formData.trigger_type === 'cron' && !formData.cron_expression.trim()) {
showToast('请输入Cron表达式', 'error'); return;
}
let actionConfig: any = {};
if (formData.action_config_str.trim()) {
try { actionConfig = JSON.parse(formData.action_config_str); }
catch { showToast('操作配置JSON格式不正确', 'error'); return; }
}
let eventCondition: any = undefined;
if (formData.event_condition_str.trim()) {
try { eventCondition = JSON.parse(formData.event_condition_str); }
catch { showToast('事件条件JSON格式不正确', 'error'); return; }
}
setSubmitting(true);
try {
const data: any = {
name: formData.name.trim(),
description: formData.description.trim(),
trigger_type: formData.trigger_type,
cron_expression: formData.cron_expression.trim(),
action_type: formData.action_type,
action_config: actionConfig,
is_enabled: formData.is_enabled,
max_retries: formData.max_retries,
timeout_seconds: formData.timeout_seconds,
};
if (eventCondition) data.event_condition = eventCondition;
if (editingTask) {
await adminAPI.updateAdminAutomationTask(editingTask.id, data);
showToast('更新成功');
} else {
await adminAPI.createAdminAutomationTask(data);
showToast('创建成功');
}
setModalVisible(false);
loadTasks();
loadStats();
} catch (e: any) {
showToast('操作失败: ' + (e.message || e), 'error');
} finally {
setSubmitting(false);
}
};
// Delete
const handleDelete = async (task: AutomationTask) => {
if (!window.confirm(`确定要删除任务 "${task.name}" 吗?`)) return;
try {
await adminAPI.deleteAdminAutomationTask(task.id);
showToast('删除成功');
loadTasks();
loadStats();
} catch (e: any) {
showToast('删除失败: ' + (e.message || e), 'error');
}
};
// Toggle
const handleToggle = async (task: AutomationTask) => {
try {
await adminAPI.toggleAdminAutomationTask(task.id);
showToast(task.is_enabled ? '已禁用' : '已启用');
loadTasks();
loadStats();
} catch (e: any) {
showToast('操作失败: ' + (e.message || e), 'error');
}
};
// Execute now
const handleExecuteNow = async (task: AutomationTask) => {
setExecutingId(task.id);
try {
const result = await adminAPI.executeAdminAutomationTask(task.id) as any;
showToast(result.message || '执行完成');
loadTasks();
loadStats();
} catch (e: any) {
showToast('执行失败: ' + (e.message || e), 'error');
} finally {
setExecutingId(null);
}
};
// Show history
const showHistory = async (task: AutomationTask, pageNum: number = 1) => {
setHistoryTaskId(task.id);
setHistoryTaskName(task.name);
setHistoryPage(pageNum);
setHistoryVisible(true);
try {
const result = await adminAPI.getAdminAutomationTaskHistory(task.id, { page: pageNum, page_size: 20 }) as any;
const data = result.data || result;
setHistoryLogs(data.items || []);
setHistoryTotal(data.total || 0);
} catch (e) { /* ignore */ }
};
// Format time
const formatTime = (ts: string) => {
if (!ts) return '-';
try { return new Date(ts).toLocaleString('zh-CN'); }
catch { return ts; }
};
// Format duration
const formatDuration = (ms: number) => {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '4px', fontSize: '13px', color: '#374151', fontWeight: 500 };
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', boxSizing: 'border-box' };
const btnStyle = (bg: string, disabled = false): React.CSSProperties => ({
padding: '8px 20px', borderRadius: '6px', border: 'none', background: bg,
cursor: disabled ? 'not-allowed' : 'pointer', fontSize: '14px', color: '#fff',
opacity: disabled ? 0.7 : 1,
});
return (
<div className="admin-page">
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
<h2>自动化任务调度</h2>
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 创建任务</button>
</div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '16px', marginBottom: '24px' }}>
<div className="stat-card">
<div className="stat-value">{stats.total_tasks}</div>
<div className="stat-label">总任务数</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.enabled_tasks}</div>
<div className="stat-label">已启用</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#3B82F6' }}>{stats.running_tasks}</div>
<div className="stat-label">运行中</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.recent_executions}</div>
<div className="stat-label">24小时执行</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.recent_success}</div>
<div className="stat-label">24小时成功</div>
</div>
</div>
)}
{/* Filter Bar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
<select value={filterTrigger} onChange={(e) => { setFilterTrigger(e.target.value); setPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px' }}>
<option value="">全部类型</option>
{Object.entries(TRIGGER_CONFIG).map(([k, v]) => (<option key={k} value={k}>{v.label}</option>))}
</select>
<input type="text" placeholder="搜索任务名称..."
value={keyword} onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
</div>
{/* Table */}
<div style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
<th style={thStyle}>任务名称</th>
<th style={thStyle}>触发方式</th>
<th style={thStyle}>操作类型</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>上次执行</th>
<th style={thStyle}>下次执行</th>
<th style={thStyle}>成功率</th>
<th style={{ ...thStyle, textAlign: 'right', width: '320px' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} style={emptyStyle}>加载中...</td></tr>
) : tasks.length === 0 ? (
<tr><td colSpan={8} style={emptyStyle}>暂无自动化任务</td></tr>
) : (
tasks.map(task => (
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={tdStyle}>
<div style={{ fontWeight: 500 }}>{task.name}</div>
{task.description && <div style={{ fontSize: '12px', color: '#9CA3AF', marginTop: '2px' }}>{task.description}</div>}
</td>
<td style={tdStyle}>
<span style={badgeStyle(TRIGGER_CONFIG[task.trigger_type] || TRIGGER_CONFIG.cron)}>
{(TRIGGER_CONFIG[task.trigger_type] || {}).label || task.trigger_type}
</span>
{task.cron_expression && <div style={{ fontSize: '12px', fontFamily: 'monospace', marginTop: '2px', color: '#6B7280' }}>{task.cron_expression}</div>}
</td>
<td style={tdStyle}>{task.action_type_label}</td>
<td style={{ ...tdStyle, textAlign: 'center' }}>
<span style={badgeStyle(STATUS_CONFIG[task.status] || STATUS_CONFIG.idle)}>
{(STATUS_CONFIG[task.status] || {}).label || task.status}
</span>
</td>
<td style={tdStyle}>{formatTime(task.last_run_at)}</td>
<td style={tdStyle}>{formatTime(task.next_run_at)}</td>
<td style={tdStyle}>
{task.total_runs > 0
? <span style={{ color: task.success_runs / task.total_runs >= 0.9 ? '#10B981' : '#D97706' }}>
{Math.round(task.success_runs / task.total_runs * 100)}%
<span style={{ fontSize: '11px', color: '#9CA3AF' }}> ({task.success_runs}/{task.total_runs})</span>
</span>
: <span style={{ color: '#9CA3AF' }}>-</span>}
</td>
<td style={{ ...tdStyle, textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '6px', justifyContent: 'flex-end' }}>
<button onClick={() => handleExecuteNow(task)} disabled={executingId === task.id || task.status === 'running'}
style={actionBtnStyle('#6C63FF', executingId === task.id)}>
{executingId === task.id ? '执行中...' : '执行'}
</button>
<button onClick={() => showHistory(task)}
style={actionBtnStyle('#374151')}>历史</button>
<button onClick={() => handleToggle(task)}
style={actionBtnStyle(task.is_enabled ? '#EF4444' : '#10B981')}>
{task.is_enabled ? '禁用' : '启用'}
</button>
<button onClick={() => openEditModal(task)}
style={actionBtnStyle('#374151')}>编辑</button>
<button onClick={() => handleDelete(task)}
style={actionBtnStyle('#EF4444', false, true)}>删除</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>
上一页
</button>
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)</span>
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}>
下一页
</button>
</div>
)}
{/* Create/Edit Modal */}
{modalVisible && (
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>{editingTask ? '编辑任务' : '创建自动化任务'}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div><label style={labelStyle}>任务名称 *</label>
<input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div>
<div><label style={labelStyle}>描述</label>
<input type="text" value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} style={inputStyle} placeholder="可选描述" /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div><label style={labelStyle}>触发方式</label>
<select value={formData.trigger_type} onChange={e => setFormData({ ...formData, trigger_type: e.target.value })} style={inputStyle}>
<option value="cron">定时触发 (Cron)</option>
<option value="webhook">Webhook回调</option>
<option value="event">事件触发</option>
</select></div>
<div><label style={labelStyle}>操作类型</label>
<select value={formData.action_type} onChange={e => setFormData({ ...formData, action_type: e.target.value })} style={inputStyle}>
<option value="ai_pipeline">AI流水线</option>
<option value="webhook">Webhook回调</option>
<option value="notification">发送通知</option>
<option value="skill_execute">技能执行</option>
</select></div>
</div>
{formData.trigger_type === 'cron' && (
<div><label style={labelStyle}>Cron 表达式 *</label>
<input type="text" value={formData.cron_expression} onChange={e => setFormData({ ...formData, cron_expression: e.target.value })}
style={{ ...inputStyle, fontFamily: 'monospace' }} placeholder="*/5 * * * * (每5分钟)" />
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '4px' }}>
格式: 分 时 日 月 周 | 示例: 0 9 * * * (每天9点)
</div></div>
)}
{formData.trigger_type !== 'cron' && (
<div><label style={labelStyle}>事件条件 (JSON)</label>
<textarea value={formData.event_condition_str} onChange={e => setFormData({ ...formData, event_condition_str: e.target.value })}
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
placeholder={formData.trigger_type === 'webhook' ? '{"method": "POST", "secret": "xxx"}' : '{"event_type": "user.created"}'} /></div>
)}
<div><label style={labelStyle}>操作配置 (JSON)</label>
<textarea value={formData.action_config_str} onChange={e => setFormData({ ...formData, action_config_str: e.target.value })}
rows={3} style={{ ...inputStyle, fontFamily: 'monospace', resize: 'vertical' }}
placeholder='{"pipeline_name": "default"}' /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '12px' }}>
<div><label style={labelStyle}>重试次数</label>
<input type="number" value={formData.max_retries} onChange={e => setFormData({ ...formData, max_retries: parseInt(e.target.value) || 0 })}
min={0} max={10} style={inputStyle} /></div>
<div><label style={labelStyle}>超时(秒)</label>
<input type="number" value={formData.timeout_seconds} onChange={e => setFormData({ ...formData, timeout_seconds: parseInt(e.target.value) || 30 })}
min={10} max={3600} style={inputStyle} /></div>
<div style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer' }}>
<input type="checkbox" checked={formData.is_enabled} onChange={e => setFormData({ ...formData, is_enabled: e.target.checked })} /> 启用</label></div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
<button onClick={() => setModalVisible(false)} style={{ padding: '8px 20px', borderRadius: '6px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer', fontSize: '14px', color: '#374151' }}>取消</button>
<button onClick={handleSubmit} disabled={submitting} style={btnStyle('#6C63FF', submitting)}>
{submitting ? '保存中...' : (editingTask ? '保存修改' : '创建任务')}
</button>
</div>
</div>
</div>
)}
{/* History Modal */}
{historyVisible && (
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '700px', maxHeight: '85vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '18px' }}>执行历史: {historyTaskName}</h3>
<button onClick={() => setHistoryVisible(false)} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', color: '#6B7280' }}>✕</button>
</div>
{historyLogs.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB' }}>
<th style={thStyle}>时间</th>
<th style={thStyle}>触发方式</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>耗时</th>
<th style={thStyle}>结果</th>
</tr>
</thead>
<tbody>
{historyLogs.map(log => (
<tr key={log.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={tdStyle}>{formatTime(log.started_at)}</td>
<td style={tdStyle}>{log.trigger_type}</td>
<td style={tdStyle}>
<span style={badgeStyle(STATUS_CONFIG[log.status] || STATUS_CONFIG.idle)}>
{(STATUS_CONFIG[log.status] || {}).label || log.status}
</span>
</td>
<td style={tdStyle}>{formatDuration(log.duration_ms)}</td>
<td style={{ ...tdStyle, maxWidth: '250px' }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{log.result_summary || log.error_message || '-'}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* History pagination */}
{historyTotal > 20 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button disabled={historyPage <= 1} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage - 1)}
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>上一页</button>
<span style={{ padding: '4px 8px', color: '#6B7280', fontSize: '13px' }}>{historyPage} / {Math.ceil(historyTotal / 20)}</span>
<button disabled={historyPage >= Math.ceil(historyTotal / 20)} onClick={() => showHistory({ id: historyTaskId, name: historyTaskName } as AutomationTask, historyPage + 1)}
style={{ padding: '4px 12px', borderRadius: '4px', border: '1px solid #D1D5DB', background: '#fff', cursor: 'pointer' }}>下一页</button>
</div>
)}
</div>
</div>
)}
{/* Toast */}
{toast && (
<div style={{ position: 'fixed', top: '20px', right: '20px', zIndex: 2000, padding: '12px 24px', borderRadius: '8px', color: '#fff',
background: toast.type === 'success' ? '#10B981' : '#EF4444', boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px' }}>
{toast.msg}
</div>
)}
</div>
);
};
// Inline styles
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 };
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px' };
const emptyStyle: React.CSSProperties = { padding: '40px', textAlign: 'center', color: '#9CA3AF' };
const badgeStyle = (cfg: { color: string; bg: string }) => ({
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: cfg.color, background: cfg.bg,
} as React.CSSProperties);
const actionBtnStyle = (color: string, disabled = false, outline = false): React.CSSProperties => ({
padding: '5px 12px', borderRadius: '6px', border: `1px solid ${outline ? '#FEE2E2' : '#D1D5DB'}`,
background: outline ? '#FEF2F2' : '#fff', cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: '12px', color, opacity: disabled ? 0.5 : 1,
});
export default Automation;
+209
View File
@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
interface Rule { id: string; name: string; billing_type: string; model_name: string; rate: number; free_quota: number; status: string; }
interface Package { id: string; name: string; description: string; tokens: number; bonus_tokens: number; price: number; original_price: number; is_popular: number; status: string; }
interface Transaction { id: string; user_id: string; username: string; transaction_type: string; amount: number; tokens: number; status: string; created_at: string; description: string; }
interface Stats { total_revenue: number; total_consumption: number; total_transactions: number; pending_invoices: number; active_rules: number; active_packages: number; daily_revenue_7d: { date: string; revenue: number }[]; }
const Billing: React.FC = () => {
const [tab, setTab] = useState<'overview' | 'rules' | 'packages' | 'transactions'>('overview');
const [rules, setRules] = useState<Rule[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getBillingRules();
const d = (res as any)?.data || res;
setRules(d?.items || []);
} catch (e) { console.error('fetchRules:', e); }
setLoading(false);
}, []);
const fetchPackages = useCallback(async () => {
try {
const res = await adminAPI.getBillingPackages();
const d = (res as any)?.data || res;
setPackages(Array.isArray(d) ? d : d?.items || []);
} catch (e) { console.error('fetchPackages:', e); }
}, []);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getBillingTransactions(page);
const d = (res as any)?.data || res;
setTransactions(d?.items || []);
setTotal(d?.total || 0);
} catch (e) { console.error('fetchTransactions:', e); }
setLoading(false);
}, [page]);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getBillingStats();
setStats((res as any)?.data || res);
} catch (e) { console.error('fetchStats:', e); }
}, []);
useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => { if (tab === 'rules') fetchRules(); }, [tab, fetchRules]);
useEffect(() => { if (tab === 'packages') fetchPackages(); }, [tab, fetchPackages]);
useEffect(() => { if (tab === 'transactions') fetchTransactions(); }, [tab, fetchTransactions, page]);
const typeColors: Record<string, string> = { per_token: '#6C63FF', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' };
const txColors: Record<string, string> = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' };
return (
<div style={{ padding: '24px', color: 'var(--color-text-primary)' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>计费与支付管理</h1>
{/* Stats */}
{stats && (
<div className="responsive-grid-4" style={{ gap: '16px', marginBottom: '24px' }}>
{[
{ label: '总收入(元)', value: stats.total_revenue?.toFixed(2) || '0.00', color: '#10b981' },
{ label: '总消费(元)', value: stats.total_consumption?.toFixed(2) || '0.00', color: '#ef4444' },
{ label: '交易笔数', value: stats.total_transactions || 0, color: '#3b82f6' },
{ label: '待付账单', value: stats.pending_invoices || 0, color: '#f59e0b' },
].map((s, i) => (
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: '28px', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => (
<button key={k} onClick={() => setTab(k)}
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: tab === k ? '#6C63FF' : '#F0F2F8', color: tab === k ? '#fff' : '#6B7280', fontWeight: 600, fontSize: '14px' }}>{label}</button>
))}
</div>
{/* Overview */}
{tab === 'overview' && stats && (
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '24px', border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px', color: 'var(--color-text-primary)' }}>近7天收入趋势</h3>
{stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '12px', height: '200px', padding: '0 20px' }}>
{stats.daily_revenue_7d.map((d, i) => {
const max = Math.max(...stats.daily_revenue_7d.map(r => r.revenue), 1);
const h = (d.revenue / max) * 160;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ fontSize: '11px', color: '#6B7280', marginBottom: '4px' }}>¥{d.revenue.toFixed(0)}</div>
<div style={{ width: '100%', height: `${h}px`, background: 'linear-gradient(180deg, #6C63FF, #818cf8)', borderRadius: '4px 4px 0 0' }} />
<div style={{ fontSize: '11px', color: '#9CA3AF', marginTop: '6px' }}>{d.date.substring(5)}</div>
</div>
);
})}
</div>
) : <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>暂无收入数据</div>}
</div>
)}
{/* Rules */}
{tab === 'rules' && (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead><tr style={{ background: '#F8F9FC' }}>
{['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{rules.map(r => (
<tr key={r.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{r.name}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${typeColors[r.billing_type]}22`, color: typeColors[r.billing_type] }}>{r.billing_type}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{r.model_name || '通用'}</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>¥{r.rate.toFixed(6)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{r.free_quota?.toLocaleString() || 0}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: r.status === 'active' ? '#10b981' : '#9CA3AF', fontSize: '13px' }}>{r.status === 'active' ? '启用' : '停用'}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Packages */}
{tab === 'packages' && (
<div className="responsive-grid-4" style={{ gap: '16px' }}>
{packages.map(p => (
<div key={p.id} style={{ background: '#FFFFFF', borderRadius: '16px', padding: '24px', position: 'relative', border: p.is_popular ? '2px solid #6C63FF' : '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
{p.is_popular === 1 && (
<div style={{ position: 'absolute', top: '-10px', right: '16px', background: '#6C63FF', color: '#fff', padding: '2px 12px', borderRadius: '12px', fontSize: '11px', fontWeight: 700 }}>推荐</div>
)}
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '8px' }}>{p.name}</h3>
<p style={{ fontSize: '13px', color: '#6B7280', marginBottom: '16px' }}>{p.description || ''}</p>
<div style={{ fontSize: '32px', fontWeight: 800, color: '#6C63FF' }}>¥{p.price}</div>
{p.original_price && <div style={{ fontSize: '14px', color: '#9CA3AF', textDecoration: 'line-through' }}>¥{p.original_price}</div>}
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid rgba(0,0,0,0.08)', fontSize: '13px', color: '#6B7280' }}>
<div>主额度: <span style={{ color: '#1A1F36', fontWeight: 600 }}>{p.tokens.toLocaleString()}</span> tokens</div>
{p.bonus_tokens > 0 && <div>赠送: <span style={{ color: '#10b981', fontWeight: 600 }}>{p.bonus_tokens.toLocaleString()}</span> tokens</div>}
</div>
<div style={{ marginTop: '12px', fontSize: '12px' }}>
<span style={{ color: p.status === 'active' ? '#10b981' : '#9CA3AF' }}>{p.status === 'active' ? '● 在售' : '○ 已停用'}</span>
</div>
</div>
))}
</div>
)}
{/* Transactions */}
{tab === 'transactions' && (
<>
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div> : (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead><tr style={{ background: '#F8F9FC' }}>
{['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{transactions.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.created_at?.substring(0, 16)}</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.username || t.user_id.substring(0, 8)}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${txColors[t.transaction_type]}22`, color: txColors[t.transaction_type] }}>{t.transaction_type}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600, color: t.transaction_type === 'recharge' ? '#10b981' : '#ef4444' }}>{t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.tokens?.toLocaleString() || '-'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: t.status === 'completed' ? '#10b981' : '#f59e0b', fontSize: '13px' }}>{t.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.description || '-'}</td>
</tr>
))}
{transactions.length === 0 && <tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无交易记录</td></tr>}
</tbody>
</table>
)}
{total > 20 && (
<div className="pagination-bar" style={{ marginTop: '20px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ padding: '6px 16px', color: '#6B7280' }}>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</>
)}
</div>
);
};
export default Billing;
+747
View File
@@ -0,0 +1,747 @@
import React, { useState, useEffect, useRef } from 'react';
import { adminAPI } from '@/api';
interface ExecStats {
total: number;
today: number;
success: number;
failed: number;
timeout: number;
avg_execution_time_ms: number;
by_language: Record<string, number>;
}
interface RuntimeInfo {
python?: string;
nodejs?: string;
max_code_length?: number;
max_timeout?: number;
}
interface TaskItem {
id: string;
language: string;
code_preview: string;
status: string;
exit_code: number | null;
execution_time_ms: number | null;
error_msg: string | null;
truncated: boolean;
created_at: string;
completed_at: string | null;
}
interface ExecResult {
task_id: string;
success: boolean;
stdout: string;
stderr: string;
exit_code: number;
execution_time_ms: number;
error: string | null;
truncated: boolean;
created_at: string;
}
const LANG_CONFIG: Record<string, { label: string; color: string; bg: string; placeholder: string }> = {
python: { label: 'Python', color: '#306998', bg: '#E8F4FD', placeholder: '# Python 代码示例\nprint("Hello, sandbox!")\n\nfor i in range(5):\n print(f"Line {i}")' },
javascript: { label: 'JavaScript', color: '#F7DF1E', bg: '#FFFDE8', placeholder: '// JavaScript 代码示例\nconsole.log("Hello, sandbox!");\n\nfor (let i = 0; i < 5; i++) {\n console.log(`Line ${i}`);\n}' },
};
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
completed: { label: '成功', color: '#10B981', bg: '#D1FAE5' },
error: { label: '错误', color: '#EF4444', bg: '#FEE2E2' },
failed: { label: '失败', color: '#F59E0B', bg: '#FEF3C7' },
running: { label: '运行中', color: '#3B82F6', bg: '#DBEAFE' },
};
const PAGE_SIZE = 15;
const CodeExec: React.FC = () => {
// Stats
const [stats, setStats] = useState<ExecStats | null>(null);
const [runtimes, setRuntimes] = useState<RuntimeInfo | null>(null);
// Editor
const [language, setLanguage] = useState('python');
const [code, setCode] = useState(LANG_CONFIG.python.placeholder);
const [timeout, setTimeout_] = useState(30);
// Execution
const [executing, setExecuting] = useState(false);
const [result, setResult] = useState<ExecResult | null>(null);
const [activeTab, setActiveTab] = useState<'stdout' | 'stderr'>('stdout');
// Task History
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [filterLang, setFilterLang] = useState('');
const [filterStatus, setFilterStatus] = useState('');
// Task Detail Modal
const [detailTask, setDetailTask] = useState<TaskItem | null>(null);
const [detailResult, setDetailResult] = useState<ExecResult | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Toast
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
const outputRef = useRef<HTMLDivElement>(null);
// Load stats & runtimes on mount
useEffect(() => {
loadStats();
loadRuntimes();
}, []);
// Load tasks
useEffect(() => {
loadTasks();
}, [page, filterLang, filterStatus]);
const loadStats = async () => {
try {
const res = await adminAPI.getCodeExecStats() as any;
setStats(res.data || null);
} catch (e) { /* ignore */ }
};
const loadRuntimes = async () => {
try {
const res = await adminAPI.getCodeExecRuntimes() as any;
setRuntimes(res.data || null);
} catch (e) { /* ignore */ }
};
const loadTasks = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (filterLang) params.language = filterLang;
if (filterStatus) params.status = filterStatus;
const res = await adminAPI.getCodeExecTasks(params) as any;
const data = res.data || res;
setTasks(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast('加载任务列表失败: ' + (e.message || e), 'error');
} finally {
setLoading(false);
}
};
// Execute code
const handleExecute = async () => {
if (!code.trim()) {
showToast('请输入代码', 'error');
return;
}
setExecuting(true);
setResult(null);
setActiveTab('stdout');
try {
const res = await adminAPI.executeCode({ language, code: code.trim(), timeout }) as any;
setResult(res.data || res);
} catch (e: any) {
setResult({
task_id: '',
success: false,
stdout: '',
stderr: '',
exit_code: -1,
execution_time_ms: 0,
error: e.message || '执行失败',
truncated: false,
created_at: '',
});
showToast('执行失败: ' + (e.message || e), 'error');
} finally {
setExecuting(false);
loadStats();
loadTasks();
}
};
// View task detail
const viewTaskDetail = async (task: TaskItem) => {
setDetailTask(task);
setDetailResult(null);
setDetailLoading(true);
try {
const res = await adminAPI.getCodeExecTask(task.id) as any;
setDetailResult(res.data || null);
} catch (e: any) {
showToast('获取任务详情失败', 'error');
} finally {
setDetailLoading(false);
}
};
// Delete task
const handleDeleteTask = async (task: TaskItem) => {
if (!window.confirm(`确定要删除任务 ${task.id} 吗?`)) return;
try {
await adminAPI.deleteCodeExecTask(task.id);
showToast('删除成功');
if (detailTask?.id === task.id) {
setDetailTask(null);
setDetailResult(null);
}
loadTasks();
loadStats();
} catch (e: any) {
showToast('删除失败: ' + (e.message || e), 'error');
}
};
// Keyboard shortcut: Ctrl/Cmd+Enter to execute
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
handleExecute();
}
};
return (
<div className="admin-page">
<div className="admin-page-header">
<h2>代码执行沙箱</h2>
<span style={{ fontSize: '13px', color: '#6B7280', marginLeft: '12px' }}>
安全隔离的 Python / JavaScript 代码执行环境
</span>
</div>
{/* Stats Cards */}
{stats && (
<div className="stats-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '12px', marginBottom: '20px' }}>
<div className="stat-card">
<div className="stat-value">{stats.today}</div>
<div className="stat-label">今日执行</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#10B981' }}>{stats.success}</div>
<div className="stat-label">成功</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#EF4444' }}>{stats.failed + stats.timeout}</div>
<div className="stat-label">失败/超时</div>
</div>
<div className="stat-card">
<div className="stat-value" style={{ color: '#6C63FF' }}>{stats.avg_execution_time_ms.toFixed(0)}ms</div>
<div className="stat-label">平均耗时</div>
</div>
<div className="stat-card">
<div className="stat-value">{stats.total}</div>
<div className="stat-label">总执行次数</div>
</div>
{/* Language breakdown */}
<div className="stat-card" style={{ gridColumn: 'span 2' }}>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap', marginTop: '4px' }}>
{Object.entries(stats.by_language || {}).map(([lang, count]) => (
<span key={lang} style={{
padding: '2px 12px', borderRadius: '12px', fontSize: '12px',
color: (LANG_CONFIG[lang] || {}).color || '#666',
background: (LANG_CONFIG[lang] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[lang] || {}).label || lang}: {count}
</span>
))}
{Object.keys(stats.by_language || {}).length === 0 && (
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>暂无数据</span>
)}
</div>
<div className="stat-label">语言分布</div>
</div>
</div>
)}
{/* Main grid: Editor + Output */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px', marginBottom: '24px' }}>
{/* Left: Code Editor */}
<div style={{
background: '#F8F9FC', borderRadius: '12px', border: '1px solid #313244',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* Editor header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid #313244',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FF5F57' }} />
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#FFBD2E' }} />
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: '#27CA40' }} />
</div>
<select
value={language}
onChange={(e) => {
setLanguage(e.target.value);
setCode(LANG_CONFIG[e.target.value]?.placeholder || '');
setResult(null);
}}
style={{
padding: '4px 12px', borderRadius: '6px', border: '1px solid #45475A',
background: '#313244', color: '#CDD6F4', fontSize: '13px', cursor: 'pointer',
}}
>
<option value="python">Python</option>
<option value="javascript">JavaScript</option>
</select>
<span style={{ fontSize: '12px', color: '#6C7086' }}>
{runtimes && (language === 'python' ? runtimes.python : runtimes.nodejs)}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '12px', color: '#6C7086' }}>超时:</span>
<input
type="range"
min={1}
max={120}
value={timeout}
onChange={(e) => setTimeout_(parseInt(e.target.value))}
style={{ width: '80px', accentColor: '#6C63FF' }}
/>
<span style={{ fontSize: '12px', color: '#CDD6F4', minWidth: '28px' }}>{timeout}s</span>
<button
onClick={handleExecute}
disabled={executing}
style={{
padding: '6px 16px', borderRadius: '6px', border: 'none',
background: executing ? '#45475A' : '#6C63FF',
color: '#1A1F36', cursor: executing ? 'not-allowed' : 'pointer',
fontSize: '13px', fontWeight: 600, whiteSpace: 'nowrap',
}}
>
{executing ? '执行中...' : '▶ 运行'}
</button>
</div>
</div>
{/* Code textarea */}
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={LANG_CONFIG[language]?.placeholder || '输入代码...'}
spellCheck={false}
style={{
flex: 1, minHeight: '320px', padding: '16px', border: 'none',
background: '#F8F9FC', color: '#CDD6F4', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
fontSize: '13px', lineHeight: 1.6, resize: 'vertical',
outline: 'none', tabSize: 4,
}}
/>
{/* Footer hint */}
<div style={{
padding: '6px 16px', borderTop: '1px solid #313244',
fontSize: '11px', color: '#585B70',
}}>
Ctrl+Enter 运行 · {language === 'python' ? 'Python 3.11' : 'Node.js'} · 最大 {runtimes?.max_code_length ? (runtimes.max_code_length / 1024).toFixed(0) + 'KB' : '50KB'}
</div>
</div>
{/* Right: Output */}
<div style={{
background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
}}>
{/* Output header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 16px', borderBottom: '1px solid #E5E7EB',
background: '#F9FAFB',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#374151' }}>执行结果</span>
{result && (
<span style={{
padding: '2px 8px', borderRadius: '4px', fontSize: '11px',
color: result.success ? '#fff' : '#fff',
background: result.success ? '#10B981' : (result.error ? '#EF4444' : '#F59E0B'),
}}>
{result.success ? '成功' : (result.error?.includes('timeout') ? '超时' : '失败')}
</span>
)}
{result && !result.success && result.exit_code !== 0 && (
<span style={{ fontSize: '12px', color: '#6B7280' }}>
exit_code={result.exit_code}
</span>
)}
{result && (
<span style={{ fontSize: '12px', color: '#6B7280' }}>
{result.execution_time_ms.toFixed(0)}ms
</span>
)}
</div>
{result && (
<div style={{ display: 'flex', gap: '4px' }}>
<button
onClick={() => setActiveTab('stdout')}
style={{
padding: '4px 10px', borderRadius: '4px', border: 'none',
background: activeTab === 'stdout' ? '#6C63FF' : '#F3F4F6',
color: activeTab === 'stdout' ? '#fff' : '#6B7280',
cursor: 'pointer', fontSize: '12px',
}}
>
stdout
</button>
<button
onClick={() => setActiveTab('stderr')}
style={{
padding: '4px 10px', borderRadius: '4px', border: 'none',
background: activeTab === 'stderr' ? '#EF4444' : '#F3F4F6',
color: activeTab === 'stderr' ? '#fff' : '#6B7280',
cursor: 'pointer', fontSize: '12px',
}}
>
stderr{result.stderr ? ` (${result.stderr.length})` : ''}
</button>
</div>
)}
</div>
{/* Output content */}
<div ref={outputRef} style={{
flex: 1, minHeight: '320px', padding: '16px', overflow: 'auto',
background: '#F8F9FC',
}}>
{executing ? (
<div style={{ color: '#6C7086', fontSize: '13px', fontFamily: 'monospace' }}>
<span style={{ animation: 'blink 1s infinite' }}>⏳</span> 正在执行...
</div>
) : !result ? (
<div style={{ color: '#585B70', fontSize: '13px', fontFamily: 'monospace', textAlign: 'center', paddingTop: '60px' }}>
<div style={{ fontSize: '36px', marginBottom: '12px' }}>{'</>'}</div>
<div>点击「运行」按钮执行代码</div>
<div style={{ marginTop: '8px', fontSize: '11px' }}>或按 Ctrl+Enter 快捷运行</div>
</div>
) : (
<>
{result.error && (
<div style={{
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
background: '#FEE2E2', color: '#DC2626', fontSize: '12px',
fontFamily: "'Fira Code', monospace",
}}>
{result.error}
</div>
)}
{result.truncated && (
<div style={{
padding: '4px 12px', marginBottom: '8px', borderRadius: '4px',
background: '#FEF3C7', color: '#92400E', fontSize: '11px', fontFamily: 'monospace',
}}>
输出已截断 (超过 500KB)
</div>
)}
<pre style={{
margin: 0, padding: 0, background: 'transparent', color: '#CDD6F4',
fontSize: '13px', fontFamily: "'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Consolas', monospace",
lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-all',
minHeight: activeTab === 'stdout' && !result.stdout.trim() ? 'auto' : undefined,
}}>
{activeTab === 'stdout'
? (result.stdout.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
: (result.stderr.trim() || <span style={{ color: '#585B70' }}>(无输出)</span>)
}
</pre>
{result.task_id && (
<div style={{ marginTop: '12px', padding: '6px 0', borderTop: '1px solid #313244', fontSize: '11px', color: '#585B70', fontFamily: 'monospace' }}>
Task ID: {result.task_id}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Task History */}
<div style={{ marginTop: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '12px' }}>
<h3 style={{ margin: 0, fontSize: '16px', color: '#374151' }}>执行历史</h3>
<div style={{ display: 'flex', gap: '8px' }}>
<select
value={filterLang}
onChange={(e) => { setFilterLang(e.target.value); setPage(1); }}
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
>
<option value="">全部语言</option>
<option value="python">Python</option>
<option value="javascript">JavaScript</option>
</select>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }}
style={{ padding: '6px 10px', borderRadius: '6px', border: '1px solid #D1D5DB', fontSize: '13px' }}
>
<option value="">全部状态</option>
<option value="completed">成功</option>
<option value="error">错误</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
</select>
<button
onClick={loadTasks}
style={{
padding: '6px 12px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#374151',
}}
>
刷新
</button>
</div>
</div>
<div className="table-container" style={{ background: '#fff', borderRadius: '12px', border: '1px solid #E5E7EB', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F9FAFB', borderBottom: '1px solid #E5E7EB' }}>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '100px' }}>任务ID</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>语言</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>代码预览</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>状态</th>
<th style={{ padding: '10px 16px', textAlign: 'center', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '80px' }}>耗时</th>
<th style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '160px' }}>创建时间</th>
<th style={{ padding: '10px 16px', textAlign: 'right', fontSize: '13px', color: '#6B7280', fontWeight: 600, width: '120px' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>加载中...</td></tr>
) : tasks.length === 0 ? (
<tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#9CA3AF' }}>暂无执行记录</td></tr>
) : (
tasks.map(task => (
<tr key={task.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={{ padding: '10px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6C63FF' }}>
{task.id}
</td>
<td style={{ padding: '10px 16px' }}>
<span style={{
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
color: (LANG_CONFIG[task.language] || {}).color || '#666',
background: (LANG_CONFIG[task.language] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[task.language] || {}).label || task.language}
</span>
</td>
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace', maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.code_preview || '-'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: '12px', fontSize: '11px',
color: (STATUS_CONFIG[task.status] || {}).color || '#666',
background: (STATUS_CONFIG[task.status] || {}).bg || '#f0f0f0',
}}>
{(STATUS_CONFIG[task.status] || {}).label || task.status}
</span>
</td>
<td style={{ padding: '10px 16px', textAlign: 'center', fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
{task.execution_time_ms != null ? `${task.execution_time_ms}ms` : '-'}
</td>
<td style={{ padding: '10px 16px', fontSize: '12px', color: '#6B7280' }}>
{task.created_at || '-'}
</td>
<td style={{ padding: '10px 16px', textAlign: 'right' }}>
<div style={{ display: 'flex', gap: '4px', justifyContent: 'flex-end' }}>
<button
onClick={() => viewTaskDetail(task)}
style={{
padding: '4px 10px', borderRadius: '4px', border: '1px solid #D1D5DB',
background: '#fff', cursor: 'pointer', fontSize: '11px', color: '#374151',
}}
>
详情
</button>
<button
onClick={() => handleDeleteTask(task)}
style={{
padding: '4px 10px', borderRadius: '4px', border: '1px solid #FEE2E2',
background: '#FEF2F2', cursor: 'pointer', fontSize: '11px', color: '#EF4444',
}}
>
删除
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
style={{
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: page <= 1 ? 'not-allowed' : 'pointer',
opacity: page <= 1 ? 0.5 : 1,
}}
>
上一页
</button>
<span style={{ padding: '6px 12px', color: '#6B7280', fontSize: '14px' }}>
第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 (共 {total} 条)
</span>
<button
disabled={page >= Math.ceil(total / PAGE_SIZE)}
onClick={() => setPage(p => p + 1)}
style={{
padding: '6px 16px', borderRadius: '6px', border: '1px solid #D1D5DB',
background: '#fff', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer',
opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1,
}}
>
下一页
</button>
</div>
)}
</div>
{/* Task Detail Modal */}
{detailTask && (
<div className="modal-overlay" style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center',
justifyContent: 'center', zIndex: 1000,
}} onClick={() => { setDetailTask(null); setDetailResult(null); }}>
<div className="modal-content" style={{
background: '#fff', borderRadius: '12px', padding: '24px',
width: '700px', maxHeight: '85vh', overflow: 'auto',
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ margin: 0, fontSize: '16px' }}>任务详情 - {detailTask.id}</h3>
<button
onClick={() => { setDetailTask(null); setDetailResult(null); }}
style={{ padding: '4px 8px', borderRadius: '4px', border: 'none', background: '#F3F4F6', cursor: 'pointer', fontSize: '16px', color: '#6B7280' }}
>
✕
</button>
</div>
{/* Task info bar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<span style={{
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: (LANG_CONFIG[detailTask.language] || {}).color || '#666',
background: (LANG_CONFIG[detailTask.language] || {}).bg || '#f0f0f0',
}}>
{(LANG_CONFIG[detailTask.language] || {}).label || detailTask.language}
</span>
<span style={{
padding: '2px 10px', borderRadius: '12px', fontSize: '12px',
color: (STATUS_CONFIG[detailTask.status] || {}).color || '#666',
background: (STATUS_CONFIG[detailTask.status] || {}).bg || '#f0f0f0',
}}>
{(STATUS_CONFIG[detailTask.status] || {}).label || detailTask.status}
</span>
{detailTask.execution_time_ms != null && (
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
耗时: {detailTask.execution_time_ms}ms
</span>
)}
{detailTask.exit_code != null && (
<span style={{ fontSize: '12px', color: '#6B7280', fontFamily: 'monospace' }}>
exit_code: {detailTask.exit_code}
</span>
)}
<span style={{ fontSize: '12px', color: '#9CA3AF' }}>
{detailTask.created_at}
</span>
</div>
{/* Code preview */}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>代码预览</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#F8F9FC', color: '#CDD6F4', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '150px', overflow: 'auto',
}}>
{detailTask.code_preview || '(无)'}
</pre>
</div>
{/* Full output */}
{detailLoading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div>
) : detailResult ? (
<>
{detailResult.error && (
<div style={{
padding: '8px 12px', marginBottom: '12px', borderRadius: '6px',
background: '#FEE2E2', color: '#DC2626', fontSize: '12px', fontFamily: 'monospace',
}}>
{detailResult.error}
</div>
)}
{detailResult.stdout && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#374151', fontWeight: 500 }}>stdout</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#F8F9FC', color: '#CDD6F4', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{detailResult.stdout}
</pre>
</div>
)}
{detailResult.stderr && (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '13px', color: '#EF4444', fontWeight: 500 }}>stderr</label>
<pre style={{
margin: 0, padding: '12px', borderRadius: '6px',
background: '#F8F9FC', color: '#F38BA8', fontSize: '12px',
fontFamily: 'monospace', lineHeight: 1.5, maxHeight: '300px', overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{detailResult.stderr}
</pre>
</div>
)}
{!detailResult.stdout.trim() && !detailResult.stderr.trim() && !detailResult.error && (
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>(无输出)</div>
)}
</>
) : (
<div style={{ textAlign: 'center', padding: '20px', color: '#9CA3AF', fontSize: '13px' }}>无法获取详细信息</div>
)}
</div>
</div>
)}
{/* Toast */}
{toast && (
<div style={{
position: 'fixed', top: '20px', right: '20px', zIndex: 2000,
padding: '12px 24px', borderRadius: '8px', color: '#1A1F36',
background: toast.type === 'success' ? '#10B981' : '#EF4444',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', fontSize: '14px',
}}>
{toast.msg}
</div>
)}
</div>
);
};
export default CodeExec;
+385
View File
@@ -0,0 +1,385 @@
import React, { useState, useEffect } 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>
);
interface DashboardStats {
total_users: number;
active_users: number;
total_models: number;
enabled_models: number;
total_conversations: number;
active_conversations: number;
total_knowledge_bases: number;
total_documents: number;
today_orders: number;
today_tokens: number;
week_tokens: number;
total_tokens: number;
today_conversations: number;
server_time: string;
}
interface DailyStat {
date: string;
tokens: number;
}
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> },
];
// SVG Bar Chart Component
const BarChart: React.FC<{ data: DailyStat[] }> = ({ data }) => {
if (!data || data.length === 0) return <div style={{ color: '#6B7280', textAlign: 'center', padding: 40 }}>暂无数据</div>;
const maxVal = Math.max(...data.map(d => d.tokens), 1);
const w = 600, h = 200, padLeft = 55, padRight = 10, padTop = 10, padBottom = 25;
const barAreaW = w - padLeft - padRight;
const barAreaH = h - padTop - padBottom;
const barGap = 2;
const barW = Math.max(2, (barAreaW / data.length) - barGap);
// Y axis ticks
const yTicks = [0, Math.round(maxVal * 0.25), Math.round(maxVal * 0.5), Math.round(maxVal * 0.75), maxVal];
const formatK = (n: number) => n >= 10000 ? (n / 1000).toFixed(0) + 'k' : String(n);
const formatDate = (d: string) => {
const parts = d.split('-');
return parts.length >= 2 ? `${parts[1]}/${parts[2]}` : d;
};
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} style={{ width: '100%', height: 'auto', maxWidth: 600 }}>
{/* Grid lines */}
{yTicks.map((tick, i) => {
const y = padTop + barAreaH - (tick / maxVal) * barAreaH;
return (
<g key={i}>
<line x1={padLeft} y1={y} x2={w - padRight} y2={y} stroke="rgba(0,0,0,0.06)" strokeWidth="0.5" />
<text x={padLeft - 8} y={y + 4} textAnchor="end" fontSize="10" fill="#6B7280">{formatK(tick)}</text>
</g>
);
})}
{/* Bars */}
{data.map((d, i) => {
const barH = (d.tokens / maxVal) * barAreaH;
const x = padLeft + i * (barAreaW / data.length);
const y = padTop + barAreaH - barH;
return (
<g key={i}>
<rect x={x + barGap / 2} y={y} width={barW} height={Math.max(barH, 1)} rx="2" fill="#6C63FF" opacity="0.85">
<title>{`${d.date}: ${d.tokens.toLocaleString()} tokens`}</title>
</rect>
{/* X label - every 7th bar */}
{i % Math.max(1, Math.floor(data.length / 8)) === 0 && (
<text x={x + barW / 2} y={h - 6} textAnchor="middle" fontSize="9" fill="#6B7280">{formatDate(d.date)}</text>
)}
</g>
);
})}
</svg>
);
};
// Mini Donut for stat cards
const MiniDonut: React.FC<{ value: number; max: number; color: string }> = ({ value, max, color }) => {
const pct = max > 0 ? Math.min(value / max, 1) : 0;
const r = 16, c = 20, circumference = 2 * Math.PI * r;
const dash = circumference * pct;
return (
<svg width={c * 2} height={c * 2} viewBox={`0 0 ${c * 2} ${c * 2}`}>
<circle cx={c} cy={c} r={r} fill="none" stroke="rgba(0,0,0,0.06)" strokeWidth="3" />
<circle cx={c} cy={c} r={r} fill="none" stroke={color} strokeWidth="3"
strokeDasharray={`${dash} ${circumference - dash}`} strokeLinecap="round"
transform={`rotate(-90 ${c} ${c})`} style={{ transition: 'stroke-dasharray 0.6s ease' }} />
<text x={c} y={c + 4} textAnchor="middle" fontSize="9" fontWeight="600" fill={color}>
{Math.round(pct * 100)}%
</text>
</svg>
);
};
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState<DashboardStats>({
total_users: 0, active_users: 0, total_models: 0, enabled_models: 0,
total_conversations: 0, active_conversations: 0, total_knowledge_bases: 0, total_documents: 0,
today_orders: 0, today_tokens: 0, week_tokens: 0, total_tokens: 0,
today_conversations: 0, server_time: '',
});
const [dailyStats, setDailyStats] = useState<DailyStat[]>([]);
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, dailyRes, logsRes, auditRes] = await Promise.all([
adminAPI.getStats() as any,
(adminAPI as any).getDailyStats?.(30) || Promise.resolve({ data: [] }),
adminAPI.getRecentLogs() as any,
adminAPI.getRecentAudit() as any,
]);
// Stats: backend returns raw dict (no {code,data} wrapper) — use .data or the response itself
const statsData = statsRes?.data || statsRes;
if (statsData && typeof statsData === 'object') setStats(statsData);
// Daily stats: backend returns {code, data: {days, data: [...]}} — access nested .data.data
const dailyData = dailyRes?.data?.data || (Array.isArray(dailyRes?.data) ? dailyRes.data : []) || [];
setDailyStats(Array.isArray(dailyData) ? dailyData : []);
// Recent logs: backend returns raw list (no wrapper) — use the response directly if it's an array
const logsData = Array.isArray(logsRes) ? logsRes : (Array.isArray(logsRes?.data) ? logsRes.data : []);
setOperationLogs(logsData);
// Recent audit: same as logs
const auditData = Array.isArray(auditRes) ? auditRes : (Array.isArray(auditRes?.data) ? auditRes.data : []);
setAuditLogs(auditData);
} 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 >= 100000000) return (n / 100000000).toFixed(2) + '亿';
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCardDefs = [
{ label: '总用户', value: stats.total_users, sub: `活跃 ${stats.active_users}`, color: '#6C63FF', bg: 'rgba(108,99,255,0.12)', subBg: 'rgba(108,99,255,0.08)',
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: 'AI模型', value: stats.total_models, sub: `启用 ${stats.enabled_models}`, color: '#219B5E', bg: 'rgba(33,155,94,0.12)', subBg: 'rgba(33,155,94,0.08)',
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.total_conversations, sub: `活跃 ${stats.active_conversations}`, color: '#E8922C', bg: 'rgba(232,146,44,0.12)', subBg: 'rgba(232,146,44,0.08)',
icon: <SVGIcon size={22}><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> },
{ label: '知识库', value: stats.total_knowledge_bases, sub: `文档 ${stats.total_documents}`, color: '#3B82F6', bg: 'rgba(59,130,246,0.12)', subBg: 'rgba(59,130,246,0.08)',
icon: <SVGIcon size={22}><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> },
{ label: '今日对话', value: stats.today_conversations, sub: `订单 ${stats.today_orders}`, color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)', subBg: 'rgba(139,92,246,0.08)',
icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
{ label: '今日Token', value: stats.today_tokens, sub: `本周 ${formatTokens(stats.week_tokens)}`, color: '#EC4899', bg: 'rgba(236,72,153,0.12)', subBg: 'rgba(236,72,153,0.08)', format: true,
icon: <SVGIcon size={22}><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></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 Row */}
<div className="admin-stats" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginBottom: '24px' }}>
{statCardDefs.map((s, i) => (
<div key={i} className="admin-stat-card" style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)',
padding: 'var(--space-lg) var(--space-xl)',
}}>
<div style={{
width: 48, height: 48, borderRadius: 14,
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>
{s.format ? formatTokens(s.value as number) : (s.value as number).toLocaleString()}
</div>
<div style={{
fontSize: 11, color: s.color, marginTop: 2,
background: s.subBg || s.bg, padding: '1px 6px', borderRadius: 4,
display: 'inline-block',
}}>{s.sub}</div>
</div>
</div>
))}
</div>
{/* Charts Row */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-xl)' }}>
{/* Token Trend Chart */}
<div className="admin-card">
<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消耗趋势(近30天)
</h3>
</div>
<div style={{ padding: 'var(--space-lg)', display: 'flex', justifyContent: 'center' }}>
<BarChart data={dailyStats} />
</div>
</div>
{/* System Health */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
{' '}平台概览
</h3>
</div>
<div style={{ padding: 'var(--space-lg)' }}>
{[
{ label: '累计Token消耗', value: formatTokens(stats.total_tokens), color: '#6C63FF', pct: 100 },
{ label: '模型利用率', value: `${stats.enabled_models}/${stats.total_models}`, color: '#219B5E', pct: stats.total_models > 0 ? stats.enabled_models / stats.total_models : 0 },
{ label: '用户活跃率', value: `${stats.active_users}/${stats.total_users}`, color: '#E8922C', pct: stats.total_users > 0 ? stats.active_users / stats.total_users : 0 },
{ label: '对话活跃率', value: `${stats.active_conversations}/${stats.total_conversations}`, color: '#3B82F6', pct: stats.total_conversations > 0 ? stats.active_conversations / stats.total_conversations : 0 },
].map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 0', borderBottom: i < 3 ? '1px solid rgba(0,0,0,0.06)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MiniDonut value={item.pct * 100} max={100} color={item.color} />
<span style={{ fontSize: 13, color: '#1A1F36' }}>{item.label}</span>
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>{item.value}</span>
</div>
))}
<div style={{ marginTop: 12, fontSize: 11, color: '#6B7280', textAlign: 'center' }}>
服务器时间: {stats.server_time ? new Date(stats.server_time).toLocaleString('zh-CN') : '-'}
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ marginBottom: 'var(--space-xl)' }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>快捷入口</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--space-md)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-lg)', border: '1px solid rgba(0,0,0,0.08)',
padding: 'var(--space-md) var(--space-lg)', cursor: 'pointer', transition: 'all 0.2s',
display: 'flex', alignItems: 'center', gap: 'var(--space-md)',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6C63FF';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(108,99,255,0.15)';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
e.currentTarget.style.transform = 'none';
}}
>
<div style={{ color: '#6C63FF', flexShrink: 0 }}>{entry.icon}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36' }}>{entry.label}</div>
<div style={{ fontSize: 11, color: '#6B7280' }}>{entry.desc}</div>
</div>
</div>
))}
</div>
</div>
{/* Recent Logs */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
</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>
<span style={{ fontSize: 12, color: '#6B7280' }}>最近10条</span>
</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;
+155
View File
@@ -0,0 +1,155 @@
.admin-developer { padding: 20px; max-width: 1400px; margin: 0 auto; }
.dev-header { margin-bottom: 24px; }
.dev-header h2 { font-size: 22px; margin: 0 0 6px 0; }
.dev-header p { color: var(--text-secondary, #666); font-size: 14px; margin: 0; }
/* Stats Grid */
.dev-stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
.dev-stat-card {
display: flex; align-items: center; gap: 14px;
padding: 18px; border-radius: 10px;
background: var(--card-bg, #fff);
border: 1px solid var(--border-color, #e8e8e8);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.stat-icon { font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; border-radius: 10px; flex-shrink: 0; }
.icon-key { background: rgba(24,144,255,0.1); }
.icon-client { background: rgba(82,196,26,0.1); }
.icon-call { background: rgba(250,173,20,0.1); }
.icon-error { background: rgba(245,34,45,0.1); }
.icon-time { background: rgba(114,46,209,0.1); }
.stat-info { display: flex; flex-direction: column; }
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--text-secondary, #666); margin-top: 2px; }
.stat-sub { font-size: 11px; color: var(--text-tertiary, #999); margin-top: 2px; }
/* Tabs */
.dev-tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border-color, #e8e8e8); margin-bottom: 20px; }
.dev-tab {
padding: 10px 20px; border: none; background: none; cursor: pointer;
font-size: 14px; color: var(--text-secondary, #666);
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
}
.dev-tab.active { color: var(--primary, #1890ff); border-bottom-color: var(--primary, #1890ff); font-weight: 600; }
.dev-tab:hover { color: var(--primary, #1890ff); }
/* Toolbar */
.dev-toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
.dev-input {
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; flex: 1; min-width: 200px; max-width: 400px;
background: var(--input-bg, #fff); color: var(--text-primary, #333);
}
.dev-input:focus { outline: none; border-color: var(--primary, #1890ff); box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
.dev-select {
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; background: var(--input-bg, #fff); color: var(--text-primary, #333);
}
/* Buttons */
.dev-btn {
padding: 8px 16px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
font-size: 14px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
transition: all 0.2s; white-space: nowrap;
}
.dev-btn:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
.dev-btn.primary { background: var(--primary, #1890ff); color: #fff; border-color: var(--primary, #1890ff); }
.dev-btn.primary:hover { background: var(--primary-dark, #096dd9); color: #fff; }
.dev-btn.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
.dev-btn.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
.dev-btn-sm {
padding: 4px 10px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
font-size: 12px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
margin-right: 4px; transition: all 0.2s;
}
.dev-btn-sm:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
.dev-btn-sm.warn { color: var(--warning, #faad14); border-color: var(--warning, #faad14); }
.dev-btn-sm.warn:hover { background: var(--warning, #faad14); color: #fff; }
.dev-btn-sm.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
.dev-btn-sm.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
/* Table */
.dev-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.dev-table th {
text-align: left; padding: 10px 12px; background: var(--table-header-bg, #fafafa);
border-bottom: 2px solid var(--border-color, #e8e8e8); font-weight: 600; white-space: nowrap;
color: var(--text-primary, #333);
}
.dev-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f0f0); }
.dev-table tbody tr:hover { background: var(--table-hover-bg, #f5f5f5); }
.mono-text { font-family: 'Courier New', monospace; font-size: 12px; }
.dev-actions { white-space: nowrap; }
.dev-empty { text-align: center; color: var(--text-tertiary, #999); padding: 40px 12px !important; }
.dev-loading { text-align: center; padding: 40px; color: var(--text-secondary, #666); }
/* Badges */
.dev-badge {
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500;
}
.badge-active { background: rgba(82,196,26,0.1); color: #52c41a; }
.badge-revoked { background: rgba(245,34,45,0.1); color: #f5222d; }
.badge-disabled { background: rgba(250,173,20,0.1); color: #faad14; }
/* Method badges */
.method-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
.method-get { background: rgba(82,196,26,0.1); color: #52c41a; }
.method-post { background: rgba(24,144,255,0.1); color: #1890ff; }
.method-put { background: rgba(250,173,20,0.1); color: #faad14; }
.method-delete { background: rgba(245,34,45,0.1); color: #f5222d; }
.method-patch { background: rgba(114,46,209,0.1); color: #722ed1; }
/* Text colors */
.text-danger { color: var(--danger, #f5222d); }
.text-warn { color: var(--warning, #faad14); }
/* Pagination */
.dev-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; }
.dev-pagination button {
padding: 6px 14px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333); font-size: 13px;
}
.dev-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.dev-pagination span { font-size: 13px; color: var(--text-secondary, #666); }
/* Modal */
.dev-modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.dev-modal {
background: var(--modal-bg, #fff); border-radius: 10px; padding: 24px;
width: 460px; max-width: 90vw; max-height: 90vh; overflow-y: auto;
box-shadow: 0 6px 30px rgba(0,0,0,0.15);
}
.dev-modal h3 { margin: 0 0 20px 0; font-size: 18px; }
.dev-form-group { margin-bottom: 16px; }
.dev-form-group label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--text-secondary, #666); }
.dev-form-group .dev-input { width: 100%; max-width: none; box-sizing: border-box; }
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
@media (max-width: 1200px) {
.dev-stats-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
.dev-table { font-size: 12px; }
}
/* Phase 3.9: Developer Tools styles */
.dev-method { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; font-family: monospace; }
.method-get { background: rgba(82,196,26,0.2); color: #52c41a; }
.method-post { background: rgba(22,119,255,0.2); color: #1677ff; }
.method-put { background: rgba(250,173,20,0.2); color: #faad14; }
.method-delete { background: rgba(255,77,79,0.2); color: #ff4d4f; }
.dev-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.dev-modal { background: #FFFFFF; border: 1px solid rgba(0,0,0,0.10); border-radius: 12px; padding: 24px; width: 500px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.dev-modal h3 { margin: 0 0 16px; color: #1A1F36; }
.dev-form-group { margin-bottom: 14px; }
.dev-form-group label { display: block; font-size: 13px; color: #5A6480; margin-bottom: 4px; }
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
.dev-muted { color: #8892B0; font-size: 12px; }
.dev-test-result { margin: 12px 0; padding: 12px; border-radius: 8px; font-size: 14px; }
+515
View File
@@ -0,0 +1,515 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
const API_BASE = '/app/api';
interface Stats {
api_keys: { total: number; active: number; revoked: number };
openapi_clients: { total: number; active: number };
call_stats: { total_calls: number; today_calls: number; total_errors: number; error_rate: number; avg_response_time: number };
slow_logs: { total: number; today: number };
top_endpoints: { endpoint: string; method: string; call_count: number; error_count: number }[];
server_time: string;
}
interface ApiKey {
id: string; user_id: string; username: string | null; api_key: string; name: string;
description: string; rate_limit: number; total_calls: number; status: string;
last_used_at: string | null; created_at: string; expired_at: string | null;
}
interface OpenApiClient {
id: string; user_id: string; username: string | null; name: string; client_id: string;
allowed_models: string[]; rate_limit: number; total_calls: number;
last_used_at: string | null; status: string; created_at: string;
}
interface CallStat {
id: string; endpoint: string; method: string; call_count: number; error_count: number;
error_rate: number; avg_response_time: number; total_response_time: number;
last_called_at: string | null; created_at: string; updated_at: string;
}
interface SlowLog {
id: string; request_id: string | null; method: string; endpoint: string;
status_code: number; response_time: number; threshold: number; created_at: string;
}
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
async function api(path: string, options?: RequestInit) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options?.headers || {}),
},
});
if (res.status === 307) {
const location = res.headers.get('Location');
if (location) return api(location.replace(API_BASE, ''), options);
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
return data;
}
export default function AdminDeveloper() {
const [activeTab, setActiveTab] = useState<'keys' | 'clients' | 'stats' | 'logs'>('keys');
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(false);
// API Keys state
const [keys, setKeys] = useState<PageData<ApiKey>>({ total: 0, page: 1, page_size: 20, items: [] });
const [keySearch, setKeySearch] = useState('');
const [keyStatusFilter, setKeyStatusFilter] = useState('');
const [showKeyModal, setShowKeyModal] = useState(false);
const [keyForm, setKeyForm] = useState({ user_id: '', name: '', description: '', rate_limit: 60 });
// OpenAPI Clients state
const [clients, setClients] = useState<PageData<OpenApiClient>>({ total: 0, page: 1, page_size: 20, items: [] });
const [clientSearch, setClientSearch] = useState('');
const [showClientModal, setShowClientModal] = useState(false);
const [clientForm, setClientForm] = useState({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
// Call Stats state
const [callStats, setCallStats] = useState<PageData<CallStat>>({ total: 0, page: 1, page_size: 20, items: [] });
const [statSearch, setStatSearch] = useState('');
const [statSortBy, setStatSortBy] = useState('call_count');
// Slow Logs state
const [slowLogs, setSlowLogs] = useState<PageData<SlowLog>>({ total: 0, page: 1, page_size: 20, items: [] });
const [logSearch, setLogSearch] = useState('');
const loadStats = useCallback(async () => {
try {
const res = await api('/admin/developer/stats/overview');
if (res.code === 0) setStats(res.data);
} catch (e) { console.error('Stats error:', e); }
}, []);
const loadKeys = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (keyStatusFilter) params.set('status', keyStatusFilter);
if (keySearch) params.set('keyword', keySearch);
const res = await api(`/admin/developer/api-keys?${params}`);
if (res.code === 0) setKeys(res.data);
} catch (e) { console.error('Keys error:', e); }
setLoading(false);
}, [keySearch, keyStatusFilter]);
const loadClients = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (clientSearch) params.set('keyword', clientSearch);
const res = await api(`/admin/developer/openapi-clients?${params}`);
if (res.code === 0) setClients(res.data);
} catch (e) { console.error('Clients error:', e); }
setLoading(false);
}, [clientSearch]);
const loadCallStats = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20', sort_by: statSortBy });
if (statSearch) params.set('keyword', statSearch);
const res = await api(`/admin/developer/call-stats?${params}`);
if (res.code === 0) setCallStats(res.data);
} catch (e) { console.error('Stats error:', e); }
setLoading(false);
}, [statSearch, statSortBy]);
const loadSlowLogs = useCallback(async (page = 1) => {
setLoading(true);
try {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (logSearch) params.set('keyword', logSearch);
const res = await api(`/admin/developer/slow-logs?${params}`);
if (res.code === 0) setSlowLogs(res.data);
} catch (e) { console.error('Logs error:', e); }
setLoading(false);
}, [logSearch]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { if (activeTab === 'keys') loadKeys(); }, [activeTab, loadKeys]);
useEffect(() => { if (activeTab === 'clients') loadClients(); }, [activeTab, loadClients]);
useEffect(() => { if (activeTab === 'stats') loadCallStats(); }, [activeTab, loadCallStats]);
useEffect(() => { if (activeTab === 'logs') loadSlowLogs(); }, [activeTab, loadSlowLogs]);
const handleCreateKey = async () => {
try {
const body: any = { user_id: keyForm.user_id, name: keyForm.name, description: keyForm.description, rate_limit: keyForm.rate_limit };
await api('/admin/developer/api-keys/', { method: 'POST', body: JSON.stringify(body) });
setShowKeyModal(false);
setKeyForm({ user_id: '', name: '', description: '', rate_limit: 60 });
loadKeys();
loadStats();
} catch (e: any) { alert(e.message); }
};
const handleRevokeKey = async (id: string) => {
if (!confirm('Revoke this API key?')) return;
try {
await api(`/admin/developer/api-keys/${id}/revoke`, { method: 'POST' });
loadKeys(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleDeleteKey = async (id: string) => {
if (!confirm('Permanently delete this API key?')) return;
try {
await api(`/admin/developer/api-keys/${id}`, { method: 'DELETE' });
loadKeys(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleCreateClient = async () => {
try {
const models = clientForm.allowed_models ? clientForm.allowed_models.split(',').map(s => s.trim()).filter(Boolean) : null;
const body: any = { user_id: clientForm.user_id, name: clientForm.name, allowed_models: models, rate_limit: clientForm.rate_limit };
const res = await api('/admin/developer/openapi-clients/', { method: 'POST', body: JSON.stringify(body) });
setShowClientModal(false);
setClientForm({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
loadClients(); loadStats();
if (res.data?.client_secret) {
alert(`Client created! Secret: ${res.data.client_secret}\nSave this secret - it won't be shown again.`);
}
} catch (e: any) { alert(e.message); }
};
const handleToggleClient = async (client: OpenApiClient) => {
const newStatus = client.status === 'active' ? 'disabled' : 'active';
try {
await api(`/admin/developer/openapi-clients/${client.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
loadClients();
} catch (e: any) { alert(e.message); }
};
const handleDeleteClient = async (id: string) => {
if (!confirm('Delete this OpenAPI client?')) return;
try {
await api(`/admin/developer/openapi-clients/${id}`, { method: 'DELETE' });
loadClients(); loadStats();
} catch (e: any) { alert(e.message); }
};
const handleResetSecret = async (id: string) => {
if (!confirm('Reset client secret? The old secret will be invalidated.')) return;
try {
const res = await api(`/admin/developer/openapi-clients/${id}/reset-secret`, { method: 'POST' });
if (res.data?.client_secret) alert(`New secret: ${res.data.client_secret}`);
} catch (e: any) { alert(e.message); }
};
const handleClearLogs = async () => {
if (!confirm('Clear ALL slow request logs?')) return;
try {
await api('/admin/developer/slow-logs', { method: 'DELETE' });
loadSlowLogs(); loadStats();
} catch (e: any) { alert(e.message); }
};
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
};
const statusBadge = (status: string) => {
const cls = status === 'active' ? 'badge-active' : status === 'revoked' ? 'badge-revoked' : 'badge-disabled';
return <span className={`dev-badge ${cls}`}>{status}</span>;
};
return (
<div className="admin-developer">
<div className="dev-header">
<h2>开发者API管理</h2>
<p>管理API密钥、OpenAPI客户端、调用统计和慢请求日志</p>
</div>
{/* Stats Cards */}
{stats && (
<div className="dev-stats-grid">
<div className="dev-stat-card">
<div className="stat-icon icon-key">🔑</div>
<div className="stat-info">
<span className="stat-value">{stats.api_keys.total}</span>
<span className="stat-label">API密钥总数</span>
<span className="stat-sub">活跃 {stats.api_keys.active} / 已撤销 {stats.api_keys.revoked}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-client">🔌</div>
<div className="stat-info">
<span className="stat-value">{stats.openapi_clients.total}</span>
<span className="stat-label">OpenAPI客户端</span>
<span className="stat-sub">活跃 {stats.openapi_clients.active}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-call">📞</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.total_calls.toLocaleString()}</span>
<span className="stat-label">API总调用</span>
<span className="stat-sub">今日 {stats.call_stats.today_calls.toLocaleString()}</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-error">⚠️</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.error_rate}%</span>
<span className="stat-label">错误率</span>
<span className="stat-sub">错误 {stats.call_stats.total_errors.toLocaleString()} 次</span>
</div>
</div>
<div className="dev-stat-card">
<div className="stat-icon icon-time">⏱️</div>
<div className="stat-info">
<span className="stat-value">{stats.call_stats.avg_response_time}s</span>
<span className="stat-label">平均响应时间</span>
<span className="stat-sub">慢请求 {stats.slow_logs.total} 条</span>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="dev-tabs">
<button className={`dev-tab ${activeTab === 'keys' ? 'active' : ''}`} onClick={() => setActiveTab('keys')}>API密钥</button>
<button className={`dev-tab ${activeTab === 'clients' ? 'active' : ''}`} onClick={() => setActiveTab('clients')}>OpenAPI客户端</button>
<button className={`dev-tab ${activeTab === 'stats' ? 'active' : ''}`} onClick={() => setActiveTab('stats')}>调用统计</button>
<button className={`dev-tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>慢请求日志</button>
</div>
{/* Tab Content */}
<div className="dev-tab-content">
{loading && <div className="dev-loading">加载中...</div>}
{/* API Keys Tab */}
{activeTab === 'keys' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索密钥名称/用户..." value={keySearch} onChange={e => setKeySearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadKeys()} />
<select className="dev-select" value={keyStatusFilter} onChange={e => setKeyStatusFilter(e.target.value)}>
<option value="">全部状态</option>
<option value="active">活跃</option>
<option value="revoked">已撤销</option>
</select>
<button className="dev-btn primary" onClick={() => setShowKeyModal(true)}>+ 新建密钥</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>API Key</th><th>用户</th><th>速率限制</th><th>状态</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody>
{keys.items.map(k => (
<tr key={k.id}>
<td>{k.name || '-'}</td>
<td className="mono-text">{k.api_key.slice(0, 12)}...</td>
<td>{k.username || k.user_id.slice(0, 8)}</td>
<td>{k.rate_limit}/min</td>
<td>{statusBadge(k.status)}</td>
<td>{fmtTime(k.last_used_at)}</td>
<td>{fmtTime(k.created_at)}</td>
<td className="dev-actions">
{k.status === 'active' && <button className="dev-btn-sm warn" onClick={() => handleRevokeKey(k.id)}>撤销</button>}
<button className="dev-btn-sm danger" onClick={() => handleDeleteKey(k.id)}>删除</button>
</td>
</tr>
))}
{keys.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无API密钥</td></tr>}
</tbody>
</table>
{keys.total > 20 && (
<div className="dev-pagination">
<button disabled={keys.page <= 1} onClick={() => loadKeys(keys.page - 1)}>上一页</button>
<span>第 {keys.page} 页 / 共 {Math.ceil(keys.total / 20)} 页</span>
<button disabled={keys.page >= Math.ceil(keys.total / 20)} onClick={() => loadKeys(keys.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* OpenAPI Clients Tab */}
{activeTab === 'clients' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索客户端名称/ID..." value={clientSearch} onChange={e => setClientSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadClients()} />
<button className="dev-btn primary" onClick={() => setShowClientModal(true)}>+ 新建客户端</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>Client ID</th><th>用户</th><th>允许模型</th><th>速率限制</th><th>状态</th><th>创建时间</th><th>操作</th></tr>
</thead>
<tbody>
{clients.items.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td className="mono-text">{c.client_id}</td>
<td>{c.username || c.user_id.slice(0, 8)}</td>
<td>{c.allowed_models?.length > 0 ? `${c.allowed_models.length} 个模型` : '全部'}</td>
<td>{c.rate_limit}/min</td>
<td>{statusBadge(c.status)}</td>
<td>{fmtTime(c.created_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleToggleClient(c)}>{c.status === 'active' ? '禁用' : '启用'}</button>
<button className="dev-btn-sm warn" onClick={() => handleResetSecret(c.id)}>重置密钥</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteClient(c.id)}>删除</button>
</td>
</tr>
))}
{clients.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无OpenAPI客户端</td></tr>}
</tbody>
</table>
{clients.total > 20 && (
<div className="dev-pagination">
<button disabled={clients.page <= 1} onClick={() => loadClients(clients.page - 1)}>上一页</button>
<span>第 {clients.page} 页 / 共 {Math.ceil(clients.total / 20)} 页</span>
<button disabled={clients.page >= Math.ceil(clients.total / 20)} onClick={() => loadClients(clients.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Call Stats Tab */}
{activeTab === 'stats' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索端点..." value={statSearch} onChange={e => setStatSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadCallStats()} />
<select className="dev-select" value={statSortBy} onChange={e => setStatSortBy(e.target.value)}>
<option value="call_count">按调用次数</option>
<option value="error_count">按错误数</option>
<option value="total_response_time">按总耗时</option>
<option value="last_called_at">按最后调用</option>
</select>
</div>
<table className="dev-table">
<thead>
<tr><th>端点</th><th>方法</th><th>调用次数</th><th>错误数</th><th>错误率</th><th>平均耗时</th><th>最后调用</th></tr>
</thead>
<tbody>
{callStats.items.map(s => (
<tr key={s.id}>
<td className="mono-text">{s.endpoint}</td>
<td><span className={`method-badge method-${s.method.toLowerCase()}`}>{s.method}</span></td>
<td>{s.call_count.toLocaleString()}</td>
<td className={s.error_count > 0 ? 'text-danger' : ''}>{s.error_count}</td>
<td className={s.error_rate > 5 ? 'text-danger' : ''}>{s.error_rate}%</td>
<td>{s.avg_response_time}s</td>
<td>{fmtTime(s.last_called_at)}</td>
</tr>
))}
{callStats.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无调用统计</td></tr>}
</tbody>
</table>
{callStats.total > 20 && (
<div className="dev-pagination">
<button disabled={callStats.page <= 1} onClick={() => loadCallStats(callStats.page - 1)}>上一页</button>
<span>第 {callStats.page} 页 / 共 {Math.ceil(callStats.total / 20)} 页</span>
<button disabled={callStats.page >= Math.ceil(callStats.total / 20)} onClick={() => loadCallStats(callStats.page + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* Slow Logs Tab */}
{activeTab === 'logs' && !loading && (
<div>
<div className="dev-toolbar">
<input className="dev-input" placeholder="搜索端点..." value={logSearch} onChange={e => setLogSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadSlowLogs()} />
<button className="dev-btn danger" onClick={handleClearLogs}>清空日志</button>
</div>
<table className="dev-table">
<thead>
<tr><th>请求ID</th><th>方法</th><th>端点</th><th>状态码</th><th>响应时间</th><th>阈值</th><th>时间</th></tr>
</thead>
<tbody>
{slowLogs.items.map(l => (
<tr key={l.id}>
<td className="mono-text">{l.request_id?.slice(0, 12) || '-'}</td>
<td><span className={`method-badge method-${l.method.toLowerCase()}`}>{l.method}</span></td>
<td className="mono-text">{l.endpoint.length > 60 ? l.endpoint.slice(0, 60) + '...' : l.endpoint}</td>
<td className={l.status_code >= 400 ? 'text-danger' : ''}>{l.status_code}</td>
<td className="text-warn">{l.response_time}s</td>
<td>{l.threshold}s</td>
<td>{fmtTime(l.created_at)}</td>
</tr>
))}
{slowLogs.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无慢请求日志</td></tr>}
</tbody>
</table>
{slowLogs.total > 20 && (
<div className="dev-pagination">
<button disabled={slowLogs.page <= 1} onClick={() => loadSlowLogs(slowLogs.page - 1)}>上一页</button>
<span>第 {slowLogs.page} 页 / 共 {Math.ceil(slowLogs.total / 20)} 页</span>
<button disabled={slowLogs.page >= Math.ceil(slowLogs.total / 20)} onClick={() => loadSlowLogs(slowLogs.page + 1)}>下一页</button>
</div>
)}
</div>
)}
</div>
{/* Create API Key Modal */}
{showKeyModal && (
<div className="dev-modal-overlay" onClick={() => setShowKeyModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建API密钥</h3>
<div className="dev-form-group">
<label>用户ID *</label>
<input className="dev-input" value={keyForm.user_id} onChange={e => setKeyForm({ ...keyForm, user_id: e.target.value })} placeholder="输入用户ID" />
</div>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="给密钥起个名字" />
</div>
<div className="dev-form-group">
<label>描述</label>
<input className="dev-input" value={keyForm.description} onChange={e => setKeyForm({ ...keyForm, description: e.target.value })} placeholder="密钥用途描述" />
</div>
<div className="dev-form-group">
<label>速率限制 (请求/分钟)</label>
<input type="number" className="dev-input" value={keyForm.rate_limit} onChange={e => setKeyForm({ ...keyForm, rate_limit: parseInt(e.target.value) || 60 })} />
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowKeyModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateKey} disabled={!keyForm.user_id}>创建</button>
</div>
</div>
</div>
)}
{/* Create Client Modal */}
{showClientModal && (
<div className="dev-modal-overlay" onClick={() => setShowClientModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建OpenAPI客户端</h3>
<div className="dev-form-group">
<label>用户ID *</label>
<input className="dev-input" value={clientForm.user_id} onChange={e => setClientForm({ ...clientForm, user_id: e.target.value })} placeholder="输入用户ID" />
</div>
<div className="dev-form-group">
<label>名称 *</label>
<input className="dev-input" value={clientForm.name} onChange={e => setClientForm({ ...clientForm, name: e.target.value })} placeholder="客户端名称" />
</div>
<div className="dev-form-group">
<label>允许的模型 (逗号分隔,留空=全部)</label>
<input className="dev-input" value={clientForm.allowed_models} onChange={e => setClientForm({ ...clientForm, allowed_models: e.target.value })} placeholder="deepseek-v3, gpt-4o" />
</div>
<div className="dev-form-group">
<label>速率限制 (请求/分钟)</label>
<input type="number" className="dev-input" value={clientForm.rate_limit} onChange={e => setClientForm({ ...clientForm, rate_limit: parseInt(e.target.value) || 60 })} />
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowClientModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateClient} disabled={!clientForm.user_id || !clientForm.name}>创建</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,537 @@
import { useState, useEffect, useCallback } from 'react';
import './Developer.css';
const API_BASE = '/app/api';
interface Webhook {
id: string; name: string; url: string; events: string[];
has_secret: boolean; headers: Record<string, string>; status: string;
last_triggered_at: string | null; last_status: string | null;
total_deliveries: number; success_count: number; fail_count: number;
success_rate: number; created_at: string; updated_at: string;
}
interface WebhookEvent {
value: string; label: string; desc: string;
}
interface WebhookDelivery {
id: string; event: string; status: string; response_code: number | null;
response_time_ms: number | null; attempts: number; error_message: string | null;
payload: any; response_body: string; created_at: string;
}
interface SDKPackage {
language: string; name: string; version: string; description: string;
install_cmd: string; download_url: string; github_url: string;
min_version: string; features: string[]; code_example: string;
}
interface ApiDocSection {
section: string; icon: string; description: string;
endpoints: Array<{
method: string; path: string; title: string; description: string;
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
example_request: string; example_response: string;
}>;
}
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
async function api(path: string, options?: RequestInit) {
const token = localStorage.getItem('token');
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(options?.headers || {}),
},
});
if (res.status === 307) {
const location = res.headers.get('Location');
if (location) return api(location.replace(API_BASE, ''), options);
}
const data = await res.json();
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
return data;
}
export default function AdminDeveloperTools() {
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
const [loading, setLoading] = useState(false);
// Webhooks state
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
const [showWebhookModal, setShowWebhookModal] = useState(false);
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
const [testResult, setTestResult] = useState<any>(null);
// SDK state
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
const [selectedSdk, setSelectedSdk] = useState<string>('python');
// API Docs state
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
const [expandedSection, setExpandedSection] = useState<string | null>(null);
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
const loadWebhooks = useCallback(async (page = 1) => {
setLoading(true);
try {
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
if (res.code === 0) setWebhooks(res.data);
} catch (e) { console.error('Webhooks error:', e); }
setLoading(false);
}, []);
const loadWebhookEvents = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/webhooks/events');
if (res.code === 0) setWebhookEvents(res.data.events);
} catch (e) { console.error('Events error:', e); }
}, []);
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
try {
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
if (res.code === 0) setDeliveries(res.data);
} catch (e) { console.error('Deliveries error:', e); }
}, []);
const loadSdkPackages = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/sdk/packages');
if (res.code === 0) setSdkPackages(res.data.packages);
} catch (e) { console.error('SDK error:', e); }
}, []);
const loadApiDocs = useCallback(async () => {
try {
const res = await api('/admin/developer-tools/api-docs');
if (res.code === 0) setDocSections(res.data.sections);
} catch (e) { console.error('Docs error:', e); }
}, []);
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
const handleCreateWebhook = async () => {
try {
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
setShowWebhookModal(false);
setWebhookForm({ name: '', url: '', events: [] });
loadWebhooks();
if (res.data?.secret) {
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
}
} catch (e: any) { alert(e.message); }
};
const handleDeleteWebhook = async (id: string) => {
if (!confirm('确定删除此 Webhook?')) return;
try {
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
loadWebhooks();
setSelectedWebhook(null);
} catch (e: any) { alert(e.message); }
};
const handleToggleWebhook = async (wh: Webhook) => {
const newStatus = wh.status === 'active' ? 'paused' : 'active';
try {
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
loadWebhooks();
} catch (e: any) { alert(e.message); }
};
const handleTestWebhook = async (id: string) => {
setTestResult(null);
try {
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
});
setTestResult(res.data);
loadWebhooks();
} catch (e: any) { alert(e.message); }
};
const handleViewDeliveries = (wh: Webhook) => {
setSelectedWebhook(wh);
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
loadDeliveries(wh.id);
};
const fmtTime = (t: string | null) => {
if (!t) return '-';
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
};
const statusBadge = (status: string) => {
const cls = status === 'active' ? 'badge-active' : status === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
return <span className={`dev-badge ${cls}`}>{status}</span>;
};
const methodBadge = (method: string) => {
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text).then(() => {
alert('Copied to clipboard');
}).catch(() => alert('Copy failed'));
};
return (
<div className="admin-developer">
<div className="dev-header">
<h2>开发者工具</h2>
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
</div>
{/* Tab Navigation */}
<div className="dev-tabs">
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
</div>
{/* Tab Content */}
<div className="dev-tab-content">
{loading && <div className="dev-loading">加载中...</div>}
{/* ===== Webhooks Tab ===== */}
{activeTab === 'webhooks' && !loading && (
<div>
{selectedWebhook ? (
/* Webhook Detail / Deliveries View */
<div>
<div className="dev-toolbar">
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
&larr; Back to list
</button>
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
</div>
{testResult && (
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(0,0,0,0.04)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
<strong>Test Result: </strong>
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
</div>
)}
<table className="dev-table">
<thead>
<tr><th>事件</th><th>状态</th><th>响应码</th><th>响应时间</th><th>尝试次数</th><th>时间</th><th>错误</th></tr>
</thead>
<tbody>
{deliveries.items.map(d => (
<tr key={d.id}>
<td className="mono-text">{d.event}</td>
<td>{statusBadge(d.status)}</td>
<td>{d.response_code || '-'}</td>
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
<td>{d.attempts}</td>
<td>{fmtTime(d.created_at)}</td>
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
</tr>
))}
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
</tbody>
</table>
{deliveries.total > 20 && (
<div className="dev-pagination">
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
</div>
)}
</div>
) : (
/* Webhook List View */
<div>
<div className="dev-toolbar">
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
</div>
<table className="dev-table">
<thead>
<tr><th>名称</th><th>地址</th><th>事件</th><th>状态</th><th>投递数</th><th>成功率</th><th>最后触发</th><th>操作</th></tr>
</thead>
<tbody>
{webhooks.items.map(w => (
<tr key={w.id}>
<td>{w.name}</td>
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
<td>
{w.events.length > 0 ? (
<span>{w.events.length} events</span>
) : <span className="dev-muted">All events</span>}
</td>
<td>{statusBadge(w.status)}</td>
<td>{w.total_deliveries}</td>
<td>
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
{w.success_rate}%
</span>
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
</td>
<td>{fmtTime(w.last_triggered_at)}</td>
<td className="dev-actions">
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>测试</button>
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>删除</button>
</td>
</tr>
))}
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
</tbody>
</table>
{webhooks.total > 20 && (
<div className="dev-pagination">
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
</div>
)}
{/* 支持的事件 Info */}
{webhookEvents.length > 0 && (
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(0,0,0,0.02)', borderRadius: '8px' }}>
<h4 style={{ margin: '0 0 12px', color: '#1A1F36' }}>支持的事件</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
{webhookEvents.map(ev => (
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(0,0,0,0.04)', borderRadius: '6px' }}>
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
<div style={{ fontSize: '12px', color: '#8892B0', marginTop: '2px' }}>{ev.desc}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Create Webhook Modal */}
{showWebhookModal && (
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
<div className="dev-modal" onClick={e => e.stopPropagation()}>
<h3>新建 Webhook</h3>
<div className="dev-form-group">
<label>名称</label>
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
</div>
<div className="dev-form-group">
<label>URL</label>
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
</div>
<div className="dev-form-group">
<label>Events (leave empty for all)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
{webhookEvents.map(ev => (
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(0,0,0,0.04)', borderRadius: '6px', fontSize: '13px' }}>
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
}} />
{ev.label}
</label>
))}
</div>
</div>
<div className="dev-modal-actions">
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>取消</button>
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>创建</button>
</div>
</div>
</div>
)}
</div>
)}
{/* ===== SDK Download Center Tab ===== */}
{activeTab === 'sdk' && !loading && (
<div>
{/* Language Selector */}
<div className="dev-toolbar">
{sdkPackages.map(pkg => (
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
{pkg.name}
</button>
))}
</div>
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(0,0,0,0.02)', borderRadius: '12px' }}>
{/* Package Info */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
<div>
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
<p style={{ margin: '0', color: '#5A6480' }}>{pkg.description}</p>
<p style={{ margin: '4px 0 0', fontSize: '13px', color: '#8892B0' }}>Requires: {pkg.min_version}</p>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
</div>
</div>
{/* Install Command */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>安装方式</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
{pkg.install_cmd}
</code>
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>复制</button>
</div>
</div>
{/* 功能特性 */}
<div style={{ marginBottom: '16px' }}>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '8px', display: 'block' }}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{pkg.features.map(f => (
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
{f}
</span>
))}
</div>
</div>
{/* Code Example */}
<div>
<label style={{ fontSize: '13px', color: '#5A6480', marginBottom: '4px', display: 'block' }}>快速开始示例</label>
<div style={{ position: 'relative' }}>
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
<code>{pkg.code_example}</code>
</pre>
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>复制</button>
</div>
</div>
</div>
))}
{sdkPackages.length === 0 && <div className="dev-empty">正在加载 SDK 包...</div>}
</div>
)}
{/* ===== Interactive API Docs Tab ===== */}
{activeTab === 'docs' && !loading && (
<div>
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
<code style={{ marginLeft: '8px', color: '#1A1F36' }}>http://210.16.161.218:18080</code>
<span style={{ marginLeft: '16px', color: '#8892B0' }}>API Version: v1</span>
</div>
{docSections.map(section => {
const isExpanded = expandedSection === section.section;
return (
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
{/* Section Header */}
<div
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
style={{ padding: '14px 16px', background: 'rgba(0,0,0,0.04)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '18px' }}>
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
</span>
<strong style={{ color: '#1A1F36' }}>{section.section}</strong>
<span style={{ color: '#8892B0', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
</div>
<span style={{ color: '#8892B0' }}>{isExpanded ? '▼' : '▶'}</span>
</div>
{/* Section Content */}
{isExpanded && (
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
<p style={{ padding: '8px 16px', margin: '0', color: '#5A6480', fontSize: '13px' }}>{section.description}</p>
{section.endpoints.map(ep => {
const epKey = `${section.section}-${ep.path}`;
const isEpExpanded = expandedEndpoint === epKey;
return (
<div key={epKey} style={{ borderTop: '1px solid rgba(0,0,0,0.04)' }}>
<div
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
>
{methodBadge(ep.method)}
<code style={{ color: '#5A6480', fontSize: '13px' }}>{ep.path}</code>
<span style={{ color: '#5A6480', fontSize: '13px' }}>{ep.title}</span>
<span style={{ marginLeft: 'auto', color: '#8892B0' }}>{isEpExpanded ? '▼' : '▶'}</span>
</div>
{isEpExpanded && (
<div style={{ padding: '0 16px 16px 16px' }}>
<p style={{ color: '#5A6480', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
{/* 参数 */}
{ep.params.length > 0 && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '6px' }}>参数</label>
<table className="dev-table" style={{ fontSize: '13px' }}>
<thead>
<tr><th>名称</th><th>类型</th><th>必填</th><th>描述</th></tr>
</thead>
<tbody>
{ep.params.map(p => (
<tr key={p.name}>
<td className="mono-text">{p.name}</td>
<td>{p.type}</td>
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>是</span> : <span style={{ color: '#8892B0' }}>否</span>}</td>
<td>{p.desc}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 请求示例 */}
{ep.example_request && (
<div style={{ marginBottom: '12px' }}>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>请求示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_request}</code>
</pre>
</div>
)}
{/* 响应示例 */}
{ep.example_response && (
<div>
<label style={{ fontSize: '12px', color: '#8892B0', display: 'block', marginBottom: '4px' }}>响应示例</label>
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
<code>{ep.example_response}</code>
</pre>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
{docSections.length === 0 && <div className="dev-empty">正在加载 API 文档...</div>}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,986 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface Distribution {
id: string;
name: string;
description: string;
brand_name: string;
brand_logo_url: string;
brand_color: string;
system_prompt: string;
provider: string;
model: string;
mcp_extensions: any[];
workflow_ids: string[];
knowledge_base_ids: string[];
agent_ids: string[];
features: string[];
status: string;
version: string;
download_count: number;
size_bytes: number;
license_count?: number;
created_at: string;
updated_at: string;
}
interface License {
id: string;
distribution_id: string;
distribution_name: string;
org_name: string;
licensee_email: string;
license_key: string;
features: string[];
max_users: number;
max_executions: number;
usage_count: number;
expiry_date: string | null;
status: string;
created_at: string;
last_used_at: string | null;
}
interface Stats {
total: number;
draft: number;
active: number;
archived: number;
total_downloads: number;
total_licenses: number;
active_licenses: number;
}
interface Templates {
workflows: any[];
agents: any[];
knowledge_bases: any[];
mcp_tools: any[];
}
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '已发布', archived: '已归档' };
const STATUS_COLORS: Record<string, string> = { draft: '#8b949e', active: '#3fb950', archived: '#f85149' };
const FEATURE_LABELS: Record<string, string> = {
chat: 'AI 对话', code_exec: '代码执行', knowledge_search: '知识库检索',
agent_collab: 'Agent 协作', automation: '自动化', webhook: 'Webhook',
};
export default function DistributionBuilder() {
const [distributions, setDistributions] = useState<Distribution[]>([]);
const [stats, setStats] = useState<Stats>({ total: 0, draft: 0, active: 0, archived: 0, total_downloads: 0, total_licenses: 0, active_licenses: 0 });
const [templates, setTemplates] = useState<Templates>({ workflows: [], agents: [], knowledge_bases: [], mcp_tools: [] });
const [licenses, setLicenses] = useState<License[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'distributions' | 'licenses'>('distributions');
const [showCreate, setShowCreate] = useState(false);
const [showLicense, setShowLicense] = useState(false);
const [selectedDistro, setSelectedDistro] = useState<string>('');
const [editDistro, setEditDistro] = useState<Distribution | null>(null);
const [page, setPage] = useState(1);
const [licPage, setLicPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [licTotalPages, setLicTotalPages] = useState(1);
// Form state
const [form, setForm] = useState({
name: '', description: '', brand_name: '', brand_color: '#6C63FF',
system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash',
features: ['chat'] as string[], version: '1.0.0',
workflow_ids: [] as string[], agent_ids: [] as string[], knowledge_base_ids: [] as string[],
});
// License form
const [licForm, setLicForm] = useState({
org_name: '', licensee_email: '', max_users: 10, max_executions: 10000,
expiry_date: '', features: ['chat'] as string[],
});
const loadData = async () => {
setLoading(true);
try {
const [s, d, t] = await Promise.all([
adminAPI.getDistributionStats(),
adminAPI.getDistributions({ page, page_size: 20 }),
adminAPI.getDistributionTemplates(),
]);
if (s.code === 0) setStats(s.data);
if (d.code === 0) { setDistributions(d.data.items); setTotalPages(Math.ceil(d.data.total / 20)); }
if (t.code === 0) setTemplates(t.data);
} catch (e) { console.error(e); }
setLoading(false);
};
const loadLicenses = async () => {
try {
const res = await adminAPI.getDistributionLicenses({ page: licPage, page_size: 20 });
if (res.code === 0) { setLicenses(res.data.items); setLicTotalPages(Math.ceil(res.data.total / 20)); }
} catch (e) { console.error(e); }
};
useEffect(() => { loadData(); }, [page]);
useEffect(() => { if (tab === 'licenses') loadLicenses(); }, [tab, licPage]);
const handleCreate = async () => {
try {
const res = await adminAPI.createDistribution(form);
if (res.code === 0) { setShowCreate(false); resetForm(); loadData(); }
else alert('创建失败: ' + (res.message || ''));
} catch (e) { alert('创建失败'); }
};
const handleUpdate = async () => {
if (!editDistro) return;
try {
const res = await adminAPI.updateDistribution(editDistro.id, form);
if (res.code === 0) { setEditDistro(null); resetForm(); loadData(); }
else alert('更新失败');
} catch (e) { alert('更新失败'); }
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此发行版?关联的许可证也将被删除。')) return;
try {
await adminAPI.deleteDistribution(id);
loadData();
} catch (e) { alert('删除失败'); }
};
const handleBuild = async (id: string) => {
try {
const token = localStorage.getItem('token') || '';
window.open(`/app/api/admin/distributions/${id}/build`, '_blank');
} catch (e) { alert('下载失败'); }
};
const handleGenerateLicense = async () => {
if (!selectedDistro) return;
try {
const res = await adminAPI.createDistributionLicense(selectedDistro, licForm);
if (res.code === 0) { setShowLicense(false); setLicForm({ org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, expiry_date: '', features: ['chat'] }); loadLicenses(); }
else alert('生成失败: ' + (res.message || ''));
} catch (e) { alert('生成失败'); }
};
const handleRevokeLicense = async (id: string) => {
if (!confirm('确定吊销此许可证?')) return;
try {
await adminAPI.revokeDistributionLicense(id);
loadLicenses();
} catch (e) { alert('吊销失败'); }
};
const resetForm = () => {
setForm({ name: '', description: '', brand_name: '', brand_color: '#6C63FF', system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', features: ['chat'], version: '1.0.0', workflow_ids: [], agent_ids: [], knowledge_base_ids: [] });
};
const openEdit = (d: Distribution) => {
setEditDistro(d);
setForm({
name: d.name, description: d.description || '', brand_name: d.brand_name || '',
brand_color: d.brand_color || '#6C63FF', system_prompt: d.system_prompt || '',
provider: d.provider || 'siliconflow', model: d.model || 'deepseek-ai/DeepSeek-V4-Flash',
features: d.features || ['chat'], version: d.version || '1.0.0',
workflow_ids: d.workflow_ids || [], agent_ids: d.agent_ids || [], knowledge_base_ids: d.knowledge_base_ids || [],
});
};
const toggleFeature = (feat: string) => {
setForm(prev => ({
...prev,
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
}));
};
const toggleLicFeature = (feat: string) => {
setLicForm(prev => ({
...prev,
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
}));
};
const isEditing = !!editDistro;
// Styles
const dark = { accent: '#6C63FF', red: '#EF4444', green: '#10B981', yellow: '#F59E0B', bg: '#F5F7FC', card: '#FFFFFF', cardBg: '#FFFFFF', border: 'rgba(0,0,0,0.08)', text: '#1A1F36', sub: '#8892B0', textSecondary: '#5A6480', textTertiary: '#8892B0' };
const cardStyle: React.CSSProperties = { background: dark.card, border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 12, padding: 20 };
const btnPrimary: React.CSSProperties = { background: '#6C63FF', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
const btnDanger: React.CSSProperties = { background: '#EF4444', color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
const btnGhost: React.CSSProperties = { background: 'transparent', color: '#1A1F36', border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14 };
const inputStyle: React.CSSProperties = { background: '#FFFFFF', color: '#1A1F36', border: `1px solid ${'rgba(0,0,0,0.08)'}`, borderRadius: 6, padding: '8px 12px', fontSize: 14, width: '100%', boxSizing: 'border-box', outline: 'none' };
const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'monospace' };
const labelStyle: React.CSSProperties = { color: dark.sub, fontSize: 13, marginBottom: 4, display: 'block' };
return (
<div style={{ padding: 24, background: '#FFFFFF', minHeight: '100vh', color: '#1A1F36', fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700 }}>发行版构建器</h1>
<p style={{ margin: '4px 0 0', color: dark.sub, fontSize: 13 }}>SaaS 产品线管理 · 自定义发行版配置包生成 · 许可证管理</p>
</div>
<button onClick={() => { setShowCreate(true); setEditDistro(null); resetForm(); }} style={btnPrimary}>
+ 新建发行版
</button>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{[
{ label: '发行版总数', value: stats.total },
{ label: '已发布', value: stats.active, color: dark.green },
{ label: '累计下载', value: stats.total_downloads, color: '#6C63FF' },
{ label: '有效许可证', value: stats.active_licenses, color: dark.yellow },
].map((s, i) => (
<div key={i} style={cardStyle}>
<div style={{ color: dark.sub, fontSize: 13 }}>{s.label}</div>
<div style={{ fontSize: 32, fontWeight: 700, color: s.color || '#1A1F36', marginTop: 4 }}>{s.value}</div>
</div>
))}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: `2px solid ${'rgba(0,0,0,0.08)'}` }}>
{[
{ key: 'distributions', label: '发行版管理' },
{ key: 'licenses', label: '许可证管理' },
].map(t => (
<button key={t.key}
onClick={() => setTab(t.key as any)}
style={{
padding: '10px 24px', background: 'transparent', border: 'none',
color: tab === t.key ? '#6C63FF' : dark.sub,
borderBottom: tab === t.key ? `2px solid ${'#6C63FF'}` : '2px solid transparent',
cursor: 'pointer', fontSize: 15, fontWeight: tab === t.key ? 600 : 400,
}}>
{t.label}
</button>
))}
</div>
{/* Distributions Tab */}
{tab === 'distributions' && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{distributions.map(d => (
<div key={d.id} style={{ ...cardStyle, display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 48, height: 48, borderRadius: 10, background: d.brand_color || '#6C63FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
{(d.brand_name || d.name)[0]}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16, fontWeight: 600 }}>{d.name}</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: STATUS_COLORS[d.status] + '22', color: STATUS_COLORS[d.status], fontWeight: 600 }}>
{STATUS_LABELS[d.status] || d.status}
</span>
<span style={{ color: dark.sub, fontSize: 12 }}>v{d.version}</span>
</div>
<div style={{ color: dark.sub, fontSize: 13, marginTop: 2 }}>{d.description || '无描述'}</div>
<div style={{ display: 'flex', gap: 12, marginTop: 6, fontSize: 12, color: dark.sub }}>
{d.features?.map(f => (
<span key={f}>✓ {FEATURE_LABELS[f] || f}</span>
))}
<span>下载 {d.download_count || 0} 次</span>
<span>{d.license_count || 0} 个许可证</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={() => handleBuild(d.id)} style={btnGhost}>下载配置包</button>
<button onClick={() => { setSelectedDistro(d.id); setShowLicense(true); }} style={btnGhost}>生成许可证</button>
<button onClick={() => openEdit(d)} style={btnGhost}>编辑</button>
<button onClick={() => handleDelete(d.id)} style={btnDanger}>删除</button>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={btnGhost}>上一页</button>
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {page}/{totalPages} 页</span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} style={btnGhost}>下一页</button>
</div>
)}
</>
)}
{/* Licenses Tab */}
{tab === 'licenses' && (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button onClick={() => setShowLicense(true)} style={btnPrimary}>+ 生成许可证</button>
</div>
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: `1px solid ${'rgba(0,0,0,0.08)'}` }}>
{['许可证密钥', '组织', '发行版', '用量', '状态', '过期', '操作'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', color: dark.sub, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.id} style={{ borderBottom: `1px solid ${'rgba(0,0,0,0.08)'}` }}>
<td style={{ padding: '10px 12px', fontFamily: 'monospace', fontSize: 12, color: '#6C63FF' }}>{lic.license_key}</td>
<td style={{ padding: '10px 12px' }}>{lic.org_name}</td>
<td style={{ padding: '10px 12px' }}>{lic.distribution_name}</td>
<td style={{ padding: '10px 12px', color: dark.sub }}>{lic.usage_count}/{lic.max_executions}</td>
<td style={{ padding: '10px 12px' }}>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: lic.status === 'active' ? '#3fb95022' : '#f8514922', color: lic.status === 'active' ? dark.green : '#EF4444' }}>{lic.status}</span>
</td>
<td style={{ padding: '10px 12px', color: dark.sub, fontSize: 12 }}>{lic.expiry_date || '永久'}</td>
<td style={{ padding: '10px 12px' }}>
{lic.status === 'active' && (
<button onClick={() => handleRevokeLicense(lic.id)} style={{ ...btnDanger, padding: '4px 12px', fontSize: 12 }}>吊销</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{licTotalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button onClick={() => setLicPage(p => Math.max(1, p - 1))} disabled={licPage <= 1} style={btnGhost}>上一页</button>
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {licPage}/{licTotalPages} 页</span>
<button onClick={() => setLicPage(p => Math.min(licTotalPages, p + 1))} disabled={licPage >= licTotalPages} style={btnGhost}>下一页</button>
</div>
)}
</>
)}
{/* Create/Edit Modal */}
{(showCreate || isEditing) && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ ...cardStyle, width: 640, maxHeight: '85vh', overflow: 'auto', padding: 28 }}>
<h2 style={{ margin: '0 0 20px' }}>{isEditing ? '编辑发行版' : '新建发行版'}</h2>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>名称 *</label>
<input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如: Enterprise AI Suite" />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>描述</label>
<textarea style={textareaStyle} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="发行版描述..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>品牌名称</label>
<input style={inputStyle} value={form.brand_name} onChange={e => setForm({ ...form, brand_name: e.target.value })} placeholder="例如: MyAI" />
</div>
<div>
<label style={labelStyle}>品牌主色</label>
<input type="color" style={{ ...inputStyle, height: 38, padding: 4, width: 60 }} value={form.brand_color} onChange={e => setForm({ ...form, brand_color: e.target.value })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>系统提示词</label>
<textarea style={textareaStyle} value={form.system_prompt} onChange={e => setForm({ ...form, system_prompt: e.target.value })} placeholder="You are a helpful AI assistant..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>LLM 提供商</label>
<select style={inputStyle} value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<option value="siliconflow">SiliconFlow</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div>
<label style={labelStyle}>默认模型</label>
<input style={inputStyle} value={form.model} onChange={e => setForm({ ...form, model: e.target.value })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>版本号</label>
<input style={inputStyle} value={form.version} onChange={e => setForm({ ...form, version: e.target.value })} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
<button key={key}
onClick={() => toggleFeature(key)}
style={{
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
background: form.features.includes(key) ? '#6C63FF' : 'transparent',
color: form.features.includes(key) ? '#fff' : dark.sub,
border: form.features.includes(key) ? `1px solid ${'#6C63FF'}` : `1px solid ${'rgba(0,0,0,0.08)'}`,
}}>
{label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowCreate(false); setEditDistro(null); }} style={btnGhost}>取消</button>
<button onClick={isEditing ? handleUpdate : handleCreate} style={btnPrimary}>{isEditing ? '保存修改' : '创建'}</button>
</div>
</div>
</div>
)}
{/* License Modal */}
{showLicense && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ ...cardStyle, width: 500, padding: 28 }}>
<h2 style={{ margin: '0 0 20px' }}>生成许可证</h2>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>发行版 *</label>
<select style={inputStyle} value={selectedDistro} onChange={e => setSelectedDistro(e.target.value)}>
<option value="">-- 选择发行版 --</option>
{distributions.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>组织名称 *</label>
<input style={inputStyle} value={licForm.org_name} onChange={e => setLicForm({ ...licForm, org_name: e.target.value })} placeholder="例如: Acme Corp" />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>被授权邮箱</label>
<input style={inputStyle} value={licForm.licensee_email} onChange={e => setLicForm({ ...licForm, licensee_email: e.target.value })} placeholder="admin@example.com" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>最大用户数</label>
<input type="number" style={inputStyle} value={licForm.max_users} onChange={e => setLicForm({ ...licForm, max_users: Number(e.target.value) })} />
</div>
<div>
<label style={labelStyle}>最大执行次数</label>
<input type="number" style={inputStyle} value={licForm.max_executions} onChange={e => setLicForm({ ...licForm, max_executions: Number(e.target.value) })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>过期日期 (留空为永久)</label>
<input type="date" style={inputStyle} value={licForm.expiry_date} onChange={e => setLicForm({ ...licForm, expiry_date: e.target.value })} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>授权功能</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
<button key={key}
onClick={() => toggleLicFeature(key)}
style={{
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
background: licForm.features.includes(key) ? '#6C63FF' : 'transparent',
color: licForm.features.includes(key) ? '#fff' : dark.sub,
border: licForm.features.includes(key) ? `1px solid ${'#6C63FF'}` : `1px solid ${'rgba(0,0,0,0.08)'}`,
}}>
{label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button onClick={() => setShowLicense(false)} style={btnGhost}>取消</button>
<button onClick={handleGenerateLicense} style={btnPrimary}>生成</button>
</div>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+538
View File
@@ -0,0 +1,538 @@
import React, { useState, useEffect, useRef } from 'react';
import { adminAPI } from '@/api';
interface KnowledgeBaseItem {
id: string;
name: string;
user_id: string;
owner_name?: string;
description?: string;
directory_path: string;
document_count: number;
knowledge_graph_count: number;
is_official: boolean;
status: string;
external_service_id?: string;
external_id?: string;
external_sync_status: string;
external_config?: any;
total_storage_bytes?: number;
total_storage_mb?: number;
created_at: string;
updated_at: string;
doc_stats?: Record<string, { count: number; size: number }>;
graph_stats?: { nodes: number; edges: number };
}
interface ExternalService {
id: string; name: string; service_type: string; api_url: string;
api_key?: string; config?: any; is_enabled: boolean; status: string;
created_at?: string; updated_at?: string;
}
interface Stats {
total_knowledge_bases: number; total_documents: number;
total_storage_bytes: number; total_storage_display: string;
official_kb: number; user_kb: number; active_kb: number;
kb_with_graph: number; external_kb: number; recent_kb_7d: number;
total_graph_nodes: number; total_graph_edges: number;
doc_status_distribution: Record<string, number>;
file_type_distribution: Record<string, number>;
}
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 formatSize = (bytes: number) => {
if (bytes >= 1024 * 1024 * 1024) return (bytes / 1024 / 1024 / 1024).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return bytes + ' B';
};
const Knowledge: React.FC = () => {
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseItem[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(15);
const [keyword, setKeyword] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterOfficial, setFilterOfficial] = useState('');
// External Services
const [extServices, setExtServices] = useState<ExternalService[]>([]);
const [showExtModal, setShowExtModal] = useState(false);
const [extForm, setExtForm] = useState({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' });
// KB Modal
const [showModal, setShowModal] = useState(false);
const [editingKB, setEditingKB] = useState<KnowledgeBaseItem | null>(null);
const [kbForm, setKBForm] = useState({ name: '', description: '', is_official: false });
// Detail drawer
const [showDetail, setShowDetail] = useState(false);
const [selectedKB, setSelectedKB] = useState<KnowledgeBaseItem | null>(null);
const [detailDocs, setDetailDocs] = useState<any[]>([]);
const [detailDocTotal, setDetailDocTotal] = useState(0);
// Document preview
const [previewDoc, setPreviewDoc] = useState<any | null>(null);
const [previewContent, setPreviewContent] = useState<string>('');
// File upload
const [uploading, setUploading] = useState(false);
const [uploadMsg, setUploadMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchStats(); fetchKBs(); fetchExtServices();
}, []);
useEffect(() => { fetchKBs(); }, [page, keyword, filterStatus, filterOfficial]);
const fetchStats = async () => {
try { const res = await adminAPI.getAdminKnowledgeStats(); if (res.code === 0) setStats(res.data); } catch (e) {}
};
const fetchKBs = async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (keyword) params.keyword = keyword;
if (filterStatus) params.status = filterStatus;
if (filterOfficial) params.is_official = filterOfficial === 'true';
const res = await adminAPI.getAdminKnowledge(params);
if (res.code === 0) {
setKnowledgeBases(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (e) {}
setLoading(false);
};
const fetchExtServices = async () => {
try { const res = await adminAPI.getAdminKnowledgeExtServices(); if (res.code === 0) setExtServices(res.data.items || []); } catch (e) {}
};
const handleCreateKB = async () => {
try {
const res = await adminAPI.createAdminKnowledge(kbForm);
if (res.code === 0) { setShowModal(false); setKBForm({ name: '', description: '', is_official: false }); fetchKBs(); fetchStats(); }
} catch (e) {}
};
const handleUpdateKB = async () => {
if (!editingKB) return;
try {
const res = await adminAPI.updateAdminKnowledge(editingKB.id, kbForm);
if (res.code === 0) { setShowModal(false); fetchKBs(); fetchStats(); }
} catch (e) {}
};
const handleDeleteKB = async (id: string) => {
if (!confirm('确认删除该知识库及其所有文档?此操作不可恢复!')) return;
try { const res = await adminAPI.deleteAdminKnowledge(id); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
};
const handleToggleKB = async (id: string, isActive: boolean) => {
try { const res = await adminAPI.toggleAdminKnowledge(id, isActive); if (res.code === 0) { fetchKBs(); fetchStats(); } } catch (e) {}
};
const handleViewDetail = async (kb: KnowledgeBaseItem) => {
setSelectedKB(kb); setShowDetail(true);
try {
const res = await adminAPI.getAdminKnowledgeDetail(kb.id);
if (res.code === 0) setSelectedKB(res.data);
const docRes = await adminAPI.getAdminKnowledgeDocs(kb.id, { page: 1, page_size: 50 });
if (docRes.code === 0) { setDetailDocs(docRes.data.items || []); setDetailDocTotal(docRes.data.total || 0); }
} catch (e) {}
};
// Document upload
const handleFileUpload = async (kbId: string, file: File) => {
setUploading(true);
setUploadMsg(`正在上传 ${file.name}...`);
try {
const formData = new FormData();
formData.append('file', file);
// Use raw fetch since adminAPI may not support FormData
const token = localStorage.getItem('aiflow_token');
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/upload`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
const data = await res.json();
if (data.code === 0) {
setUploadMsg(`✅ ${file.name} 上传成功!`);
handleViewDetail(selectedKB!);
fetchStats();
} else {
setUploadMsg(`❌ 上传失败: ${data.message || '未知错误'}`);
}
} catch (e: any) {
setUploadMsg(`❌ 上传失败: ${e.message || '网络错误'}`);
} finally {
setUploading(false);
setTimeout(() => setUploadMsg(''), 5000);
}
};
// Document preview
const handlePreviewDoc = async (kbId: string, doc: any) => {
setPreviewDoc(doc);
setPreviewContent('加载中...');
try {
const token = localStorage.getItem('aiflow_token');
const res = await fetch(`/app/api/admin/knowledge/${kbId}/documents/${doc.id}/content`, {
headers: { 'Authorization': `Bearer ${token}` },
});
const data = await res.json();
if (data.code === 0) {
setPreviewContent(data.data?.content || data.data?.text || '(文档内容为空)');
} else {
setPreviewContent(data.message || '无法加载文档内容');
}
} catch (e: any) {
setPreviewContent(`加载失败: ${e.message}`);
}
};
const handleDeleteDoc = async (kbId: string, docId: string) => {
if (!confirm('确认删除该文档?')) return;
try { const res = await adminAPI.deleteAdminKnowledgeDoc(kbId, docId); if (res.code === 0) { handleViewDetail(selectedKB!); fetchStats(); } } catch (e) {}
};
const handleCreateExtService = async () => {
try { const res = await adminAPI.createAdminKnowledgeExtService(extForm); if (res.code === 0) { setShowExtModal(false); setExtForm({ name: '', service_type: 'fastgpt', api_url: '', api_key: '' }); fetchExtServices(); } } catch (e) {}
};
const handleDeleteExtService = async (id: string) => {
if (!confirm('确认删除该外部知识库服务配置?')) return;
try { const res = await adminAPI.deleteAdminKnowledgeExtService(id); if (res.code === 0) fetchExtServices(); } catch (e) {}
};
const handleCheckExtStatus = async (id: string) => {
try { const res = await adminAPI.checkAdminKnowledgeExtStatus(id); if (res.code === 0) { alert(`连接状态: ${res.data.connected ? '已连接' : '未连接'}\n详情: ${res.data.detail || '无'}`); fetchExtServices(); } } catch (e) {}
};
const statusBadge = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
active: { color: '#16A34A', text: '活跃' }, inactive: { color: '#6B7280', text: '禁用' },
archived: { color: '#F59E0B', text: '归档' }, published: { color: '#16A34A', text: '已发布' },
pending: { color: '#F59E0B', text: '待审核' }, completed: { color: '#16A34A', text: '完成' },
processing: { color: '#3B82F6', text: '处理中' }, failed: { color: '#DC2626', text: '失败' },
uploading: { color: '#3B82F6', text: '上传中' },
};
const s = map[status] || { color: '#6B7280', text: status };
return <span style={{ fontSize: 12, padding: '2px 8px', background: s.color + '18', color: s.color, borderRadius: 4, fontWeight: 500 }}>{s.text}</span>;
};
const syncStatusBadge = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
none: { color: '#9CA3AF', text: '未同步' }, syncing: { color: '#3B82F6', text: '同步中' },
synced: { color: '#16A34A', text: '已同步' }, error: { color: '#DC2626', text: '同步错误' },
};
const s = map[status] || { color: '#9CA3AF', text: status };
return <span style={{ fontSize: 11, color: s.color, fontWeight: 500 }}>{s.text}</span>;
};
const totalPages = Math.ceil(total / pageSize);
const statCardDefs = stats ? [
{ label: '知识库总数', value: stats.total_knowledge_bases, color: '#6C63FF' },
{ label: '文档总数', value: stats.total_documents, color: '#219B5E' },
{ label: '存储占用', value: stats.total_storage_display, color: '#E8922C' },
{ label: '官方知识库', value: stats.official_kb, color: '#8B5CF6' },
{ label: '含图谱KB', value: stats.kb_with_graph, color: '#3B82F6' },
{ label: '外部集成', value: stats.external_kb, color: '#EC4899' },
] : [];
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">知识库管理</h1>
{/* Stats */}
{stats && (
<div className="admin-stats" style={{ marginBottom: 'var(--space-xl)' }}>
{statCardDefs.map((s, i) => (
<div key={i} className="admin-stat-card" style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color, fontFamily: 'var(--font-mono)' }}>
{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 4 }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Toolbar */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<input type="text" placeholder="搜索知识库..." value={keyword} onChange={e => setKeyword(e.target.value)}
style={inputStyle} />
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} style={selectStyle}>
<option value="">全部状态</option>
<option value="active">活跃</option>
<option value="inactive">禁用</option>
</select>
<select value={filterOfficial} onChange={e => setFilterOfficial(e.target.value)} style={selectStyle}>
<option value="">全部来源</option>
<option value="true">官方</option>
<option value="false">用户</option>
</select>
<button onClick={() => { setEditingKB(null); setKBForm({ name: '', description: '', is_official: false }); setShowModal(true); }}
style={btnStyle('#6C63FF')}>+ 创建知识库</button>
<button onClick={() => setShowExtModal(true)} style={btnStyle('#8B5CF6')}>外部服务配置</button>
</div>
{/* Table */}
<div className="admin-card" style={{ padding: 0, overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table" style={{ marginBottom: 0 }}>
<thead>
<tr>
<th>名称</th><th>来源</th><th>文档数</th><th>存储</th><th>状态</th><th>同步</th><th>创建时间</th><th style={{ textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40 }}>加载中...</td></tr>
) : knowledgeBases.length === 0 ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 40, color: '#9CA3AF' }}>暂无知识库数据</td></tr>
) : knowledgeBases.map(kb => (
<tr key={kb.id}>
<td>
<div style={{ fontWeight: 600 }}>{kb.name}</div>
{kb.description && <div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 2 }}>{kb.description.substring(0, 60)}</div>}
</td>
<td>{kb.is_official ? <span style={{ color: '#8B5CF6', fontWeight: 600 }}>官方</span> : <span style={{ color: '#6B7280' }}>{kb.owner_name || '用户'}</span>}</td>
<td style={{ fontWeight: 600 }}>{kb.document_count}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 12 }}>{kb.total_storage_mb || 0} MB</td>
<td>{statusBadge(kb.status)}</td>
<td>{syncStatusBadge(kb.external_sync_status)}</td>
<td style={{ fontSize: 12, color: '#9CA3AF' }}>{kb.created_at?.substring(0, 10)}</td>
<td style={{ textAlign: 'center' }}>
<button onClick={() => handleViewDetail(kb)} style={btnStyle('#3B82F6', 'small')}>详情</button>
<button onClick={() => { setEditingKB(kb); setKBForm({ name: kb.name, description: kb.description || '', is_official: kb.is_official }); setShowModal(true); }} style={{ ...btnStyle('#F59E0B', 'small'), marginLeft: 4 }}>编辑</button>
<button onClick={() => handleToggleKB(kb.id, kb.status !== 'active')} style={{ ...btnStyle(kb.status === 'active' ? '#DC2626' : '#16A34A', 'small'), marginLeft: 4 }}>{kb.status === 'active' ? '禁用' : '启用'}</button>
<button onClick={() => handleDeleteKB(kb.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 16, alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={btnStyle(page <= 1 ? '#D1D5DB' : '#6C63FF', 'small')}>上一页</button>
<span style={{ fontSize: 13, color: '#6B7280' }}>第 {page}/{totalPages} 页 (共 {total} 条)</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={btnStyle(page >= totalPages ? '#D1D5DB' : '#6C63FF', 'small')}>下一页</button>
</div>
)}
{/* KB Modal */}
{showModal && (
<div style={overlayStyle} onClick={() => setShowModal(false)}>
<div style={modalStyle} onClick={e => e.stopPropagation()}>
<h3>{editingKB ? '编辑知识库' : '创建知识库'}</h3>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>名称</label>
<input value={kbForm.name} onChange={e => setKBForm({ ...kbForm, name: e.target.value })} style={fullInput} />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>描述</label>
<textarea value={kbForm.description} onChange={e => setKBForm({ ...kbForm, description: e.target.value })} rows={3} style={fullInput} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={kbForm.is_official} onChange={e => setKBForm({ ...kbForm, is_official: e.target.checked })} />
官方平台知识库
</label>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={editingKB ? handleUpdateKB : handleCreateKB} style={btnStyle('#6C63FF')}>{editingKB ? '保存' : '创建'}</button>
<button onClick={() => setShowModal(false)} style={btnStyle('#6B7280')}>取消</button>
</div>
</div>
</div>
)}
{/* Detail Drawer */}
{showDetail && selectedKB && (
<div style={overlayStyle} onClick={() => setShowDetail(false)}>
<div style={{ ...drawerStyle }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0 }}>{selectedKB.name}</h3>
<button onClick={() => setShowDetail(false)} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
</div>
<div style={{ marginBottom: 16, padding: 12, background: '#F9FAFB', borderRadius: 8, fontSize: 13 }}>
<div>来源: {selectedKB.is_official ? '官方' : selectedKB.owner_name || '用户'}</div>
{selectedKB.description && <div style={{ marginTop: 4 }}>描述: {selectedKB.description}</div>}
<div style={{ marginTop: 4 }}>状态: {statusBadge(selectedKB.status)} | 文档: {selectedKB.document_count} | 存储: {selectedKB.total_storage_mb || 0} MB</div>
</div>
{/* Upload */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="file" ref={fileInputRef} style={{ display: 'none' }}
onChange={e => { const f = e.target.files?.[0]; if (f) handleFileUpload(selectedKB.id, f); e.target.value = ''; }}
accept=".pdf,.docx,.doc,.txt,.md,.csv,.json,.xml" />
<button onClick={() => fileInputRef.current?.click()} disabled={uploading}
style={btnStyle(uploading ? '#D1D5DB' : '#219B5E')}>
<SVGIcon><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 ? '上传中...' : '上传文档'}
</button>
<span style={{ fontSize: 12, color: '#6B7280' }}>支持 PDF / DOCX / TXT / MD / CSV / JSON</span>
</div>
{uploadMsg && (
<div style={{ marginTop: 8, fontSize: 13, color: uploadMsg.startsWith('✅') ? '#16A34A' : '#DC2626', padding: '6px 12px', background: uploadMsg.startsWith('✅') ? '#DCFCE7' : '#FEE2E2', borderRadius: 6 }}>
{uploadMsg}
</div>
)}
</div>
{/* Graph Stats */}
{selectedKB.graph_stats && (selectedKB.graph_stats.nodes > 0 || selectedKB.graph_stats.edges > 0) && (
<div style={{ marginBottom: 16, padding: 12, background: '#EEF2FF', borderRadius: 8 }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>知识图谱</h4>
<div style={{ fontSize: 13 }}>节点数: {selectedKB.graph_stats.nodes} | 边数: {selectedKB.graph_stats.edges}</div>
</div>
)}
{/* External Integration */}
{selectedKB.external_service_id && (
<div style={{ marginBottom: 16, padding: 12, background: '#F5F3FF', borderRadius: 8, fontSize: 13 }}>
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>外部集成</h4>
<div>同步状态: {syncStatusBadge(selectedKB.external_sync_status)}</div>
<div>外部ID: {selectedKB.external_id || '未关联'}</div>
</div>
)}
{/* Documents */}
<h4 style={{ margin: '0 0 8px 0', fontSize: 14 }}>文档列表 ({detailDocTotal})</h4>
<div style={{ maxHeight: 400, overflowY: 'auto' }}>
<table className="admin-table" style={{ marginBottom: 0 }}>
<thead>
<tr>
<th>文件名</th><th>类型</th><th>大小</th><th>状态</th><th style={{ textAlign: 'center' }}>操作</th>
</tr>
</thead>
<tbody>
{detailDocs.map(doc => (
<tr key={doc.id}>
<td style={{ fontSize: 12 }}>{doc.filename || doc.file_name}</td>
<td style={{ fontSize: 11 }}><span style={{ padding: '1px 6px', background: '#F3F4F6', borderRadius: 3 }}>{doc.file_type || '-'}</span></td>
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)' }}>{formatSize(doc.file_size || 0)}</td>
<td>{statusBadge(doc.status)}</td>
<td style={{ textAlign: 'center' }}>
<button onClick={() => handlePreviewDoc(selectedKB.id, doc)} style={btnStyle('#3B82F6', 'small')}>预览</button>
<button onClick={() => handleDeleteDoc(selectedKB.id, doc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
</td>
</tr>
))}
{detailDocs.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', padding: 24, color: '#9CA3AF' }}>暂无文档,请上传</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Document Preview Modal */}
{previewDoc && (
<div style={overlayStyle} onClick={() => { setPreviewDoc(null); setPreviewContent(''); }}>
<div style={{ ...drawerStyle, maxWidth: '80vw' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16 }}>{previewDoc.filename || previewDoc.file_name}</h3>
<button onClick={() => { setPreviewDoc(null); setPreviewContent(''); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#9CA3AF' }}>✕</button>
</div>
<div style={{ fontSize: 12, color: '#6B7280', marginBottom: 12 }}>
类型: {previewDoc.file_type || '-'} | 大小: {formatSize(previewDoc.file_size || 0)}
</div>
<div style={{
maxHeight: '60vh', overflowY: 'auto', background: '#F9FAFB',
borderRadius: 8, padding: 16, fontSize: 13, lineHeight: 1.7,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
fontFamily: 'var(--font-mono)', color: '#374151',
border: '1px solid #E5E7EB',
}}>
{previewContent}
</div>
</div>
</div>
)}
{/* External Service Modal */}
{showExtModal && (
<div style={overlayStyle} onClick={() => setShowExtModal(false)}>
<div style={{ ...modalStyle, maxWidth: 650 }} onClick={e => e.stopPropagation()}>
<h3>外部知识库服务配置</h3>
{extServices.length > 0 && (
<div style={{ marginBottom: 16 }}>
<table className="admin-table" style={{ marginBottom: 0 }}>
<thead><tr><th>名称</th><th>类型</th><th>URL</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
{extServices.map(svc => (
<tr key={svc.id}>
<td>{svc.name}</td>
<td><span style={{ fontSize: 12, padding: '2px 6px', background: '#F3F4FF', color: '#8B5CF6', borderRadius: 3 }}>{svc.service_type === 'fastgpt' ? 'FastGPT' : svc.service_type === 'dify' ? 'Dify' : 'RAGFlow'}</span></td>
<td style={{ fontSize: 11, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>{svc.api_url}</td>
<td>{statusBadge(svc.status)}</td>
<td>
<button onClick={() => handleCheckExtStatus(svc.id)} style={btnStyle('#3B82F6', 'small')}>检测</button>
<button onClick={() => handleDeleteExtService(svc.id)} style={{ ...btnStyle('#DC2626', 'small'), marginLeft: 4 }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div style={{ borderTop: '1px solid #E5E7EB', paddingTop: 16 }}>
<h4 style={{ margin: '0 0 12px 0', fontSize: 14 }}>添加新服务</h4>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div><label style={labelStyle}>名称</label><input value={extForm.name} onChange={e => setExtForm({ ...extForm, name: e.target.value })} placeholder="如: 生产环境 FastGPT" style={fullInput} /></div>
<div><label style={labelStyle}>类型</label><select value={extForm.service_type} onChange={e => setExtForm({ ...extForm, service_type: e.target.value })} style={fullInput}><option value="fastgpt">FastGPT</option><option value="dify">Dify</option><option value="ragflow">RAGFlow</option></select></div>
</div>
<div style={{ marginTop: 12 }}><label style={labelStyle}>API URL</label><input value={extForm.api_url} onChange={e => setExtForm({ ...extForm, api_url: e.target.value })} placeholder="https://your-instance.com/api/v1" style={fullInput} /></div>
<div style={{ marginTop: 12 }}><label style={labelStyle}>API Key</label><input type="password" value={extForm.api_key} onChange={e => setExtForm({ ...extForm, api_key: e.target.value })} placeholder="Bearer Token 或 API Key" style={fullInput} /></div>
<div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
<button onClick={handleCreateExtService} style={btnStyle('#8B5CF6')}>添加服务</button>
<button onClick={() => setShowExtModal(false)} style={btnStyle('#6B7280')}>关闭</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
// Shared styles
const inputStyle: React.CSSProperties = { padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, width: 200, boxSizing: 'border-box' };
const selectStyle: React.CSSProperties = { padding: '8px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, background: '#fff' };
const fullInput: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid #D1D5DB', borderRadius: 6, fontSize: 13, boxSizing: 'border-box' };
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: 4, fontWeight: 600, fontSize: 13, color: '#374151' };
const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const modalStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 500, maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto' };
const drawerStyle: React.CSSProperties = { background: '#fff', borderRadius: 12, padding: 24, width: 560, maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' };
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
background: color, color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
fontSize: size === 'small' ? 12 : 14, padding: size === 'small' ? '4px 10px' : '8px 16px',
fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap',
});
export default Knowledge;
+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;
+963
View File
@@ -0,0 +1,963 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import McpMarketTab from './McpMarketTab';
// ============================================================
// Types
// ============================================================
interface MarketAppItem {
id: string;
name: string;
type: string;
category: string;
tags: string | null;
source: string;
publisher_name: string | null;
description: string | null;
icon: string | null;
price: number;
is_member_free: boolean;
featured: boolean;
featured_order: number;
status: string;
rating_avg: number;
review_count: number;
install_count: number;
version: string;
created_at: string | null;
updated_at: string | null;
tutorial?: string;
skill_content?: string;
}
interface OverviewData {
total_apps: number;
published: number;
pending: number;
removed: number;
total_installs: number;
total_purchases: number;
total_revenue: number;
total_reviews: number;
overall_rating: number;
by_type: Record<string, number>;
by_category: Record<string, number>;
trend_7d: Array<{ date: string; new_apps: number; new_installs: number }>;
}
interface CouponItem {
id: string;
code: string;
type: string;
value: number;
min_amount: number;
valid_from: string | null;
valid_to: string | null;
total_count: number;
used_count: number;
status: string;
}
interface ReviewItem {
id: string;
rating: number;
content: string;
username: string;
avatar: string | null;
app_name: string;
app_icon: string | null;
created_at: string | null;
}
const CATEGORY_LABELS: Record<string, string> = {
coding: '编程开发', assistant: '智能助手', tools: '效率工具',
writing: '内容创作', education: '教育培训', marketing: '营销推广',
data: '数据分析', design: '设计创意', business: '企业管理', other: '其他',
};
const TYPE_LABELS: Record<string, string> = {
skill: 'Skills工具', agent: '智能Agent', mcp: 'MCP协议',
};
const STATUS_LABELS: Record<string, string> = {
pending: '待审核', published: '已发布', removed: '已下架',
};
const STATUS_COLORS: Record<string, string> = {
pending: 'var(--color-warning)', published: 'var(--color-success)', removed: 'var(--color-danger)',
};
// ============================================================
// Icon helpers
// ============================================================
const Icon = ({ name, size = 18, color = 'currentColor' }: { name: string; size?: number; color?: string }) => {
const icons: Record<string, string> = {
chart: 'M3 3v18h18M7 14l4-4 4 4 5-5', store: 'M3 9l1-5h16l1 5M4 9v11h16V9M9 14h6',
check: 'M20 6L9 17l-5-5', close: 'M18 6L6 18M6 6l12 12', edit: 'M11 4H4v16h16v-7M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z',
star: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z',
trash: 'M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2',
tag: 'M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z',
plus: 'M12 5v14M5 12h14', search: 'M11 4a7 7 0 100 14 7 7 0 000-14zM21 21l-4.35-4.35',
download: 'M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3',
refresh: 'M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15',
trending: 'M23 6l-9.5 9.5-5-5L1 18', eye: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z',
};
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'inline-block', verticalAlign: 'middle' }}>
<path d={icons[name] || ''} />
</svg>
);
};
// ============================================================
// Main Component
// ============================================================
const Market: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'apps' | 'pending' | 'coupons' | 'reviews' | 'mcp'>('overview');
const [loading, setLoading] = useState(false);
// Overview data
const [overview, setOverview] = useState<OverviewData | null>(null);
const [topApps, setTopApps] = useState<MarketAppItem[]>([]);
const [topMetric, setTopMetric] = useState<string>('installs');
// Apps list
const [apps, setApps] = useState<MarketAppItem[]>([]);
const [appsTotal, setAppsTotal] = useState(0);
const [appsPage, setAppsPage] = useState(1);
const [filterStatus, setFilterStatus] = useState('');
const [filterType, setFilterType] = useState('');
const [filterCategory, setFilterCategory] = useState('');
const [filterKeyword, setFilterKeyword] = useState('');
// Pending
const [pendingApps, setPendingApps] = useState<MarketAppItem[]>([]);
const [pendingTotal, setPendingTotal] = useState(0);
// Coupons
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [couponModal, setCouponModal] = useState(false);
const [editingCoupon, setEditingCoupon] = useState<CouponItem | null>(null);
// Reviews
const [reviews, setReviews] = useState<ReviewItem[]>([]);
// Edit modal
const [editApp, setEditApp] = useState<MarketAppItem | null>(null);
const loadOverview = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getMarketOverview();
if (res?.code === 0) setOverview(res.data);
const topRes = await adminAPI.getTopApps(topMetric, 10);
if (topRes?.code === 0) setTopApps(topRes.data || []);
} catch (e) { console.error(e); }
setLoading(false);
}, [topMetric]);
const loadApps = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filterStatus) params.set('status', filterStatus);
if (filterType) params.set('app_type', filterType);
if (filterCategory) params.set('category', filterCategory);
if (filterKeyword) params.set('keyword', filterKeyword);
params.set('page', String(appsPage));
params.set('page_size', '20');
const res = await adminAPI.getAdminMarketApps(params.toString());
if (res?.code === 0) {
const d = res.data as any;
setApps(d?.items || []);
setAppsTotal(d?.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [filterStatus, filterType, filterCategory, filterKeyword, appsPage]);
const loadPending = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getPendingApps();
if (res?.code === 0) {
const d = res.data as any;
setPendingApps(d?.items || d || []);
setPendingTotal(d?.total || (Array.isArray(d) ? d.length : 0));
}
} catch (e) { console.error(e); }
setLoading(false);
}, []);
const loadCoupons = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getCoupons();
if (res?.code === 0) { const d = res.data as any; setCoupons(d?.items || d || []); }
} catch (e) { console.error(e); }
setLoading(false);
}, []);
const loadReviews = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getRecentReviews();
if (res?.code === 0) setReviews(res.data || []);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
useEffect(() => {
if (activeTab === 'overview') loadOverview();
else if (activeTab === 'apps') loadApps();
else if (activeTab === 'pending') loadPending();
else if (activeTab === 'coupons') loadCoupons();
else if (activeTab === 'reviews') loadReviews();
}, [activeTab, loadOverview, loadApps, loadPending, loadCoupons, loadReviews]);
// Actions
const handleApprove = async (id: string) => {
if (!confirm('确认审核通过?')) return;
try {
await adminAPI.approveApp(id);
loadPending();
loadOverview();
} catch (e) { alert('操作失败'); }
};
const handleReject = async (id: string) => {
const reason = prompt('拒绝原因:', '');
if (reason === null) return;
try {
await adminAPI.rejectApp(id + '?reason=' + encodeURIComponent(reason));
loadPending();
} catch (e) { alert('操作失败'); }
};
const handleToggleFeatured = async (app: MarketAppItem) => {
try {
await adminAPI.setFeaturedApp(app.id, !app.featured, app.featured ? 0 : 1);
loadApps();
} catch (e) { alert('操作失败'); }
};
const handleDeleteApp = async (id: string, name: string) => {
if (!confirm(`确认删除应用「${name}」?此操作不可恢复!`)) return;
try {
await adminAPI.deleteMarketApp(id);
loadApps();
} catch (e) { alert('操作失败'); }
};
const handleDeleteCoupon = async (id: string) => {
if (!confirm('确认删除此优惠券?')) return;
try {
await adminAPI.deleteCoupon(id);
loadCoupons();
} catch (e) { alert('操作失败'); }
};
const tabs = [
{ key: 'overview', label: '数据总览', icon: 'chart' },
{ key: 'apps', label: '应用管理', icon: 'store' },
{ key: 'pending', label: `待审核${pendingTotal > 0 ? `(${pendingTotal})` : ''}`, icon: 'eye' },
{ key: 'coupons', label: '优惠券', icon: 'tag' },
{ key: 'reviews', label: '用户评价', icon: 'star' },
{ key: 'mcp', label: 'MCP工具', icon: 'tool' },
] as const;
return (
<div style={{ padding: '24px', maxWidth: '1400px', margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px', color: 'var(--color-text-primary)' }}>
市场管理
</h1>
<p style={{ fontSize: '14px', color: 'var(--color-text-tertiary)', margin: 0 }}>
管理应用市场、审核提交、分析数据、配置优惠券
</p>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '24px', borderBottom: '1px solid var(--color-border)', paddingBottom: '0' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 20px',
border: 'none',
background: activeTab === tab.key ? 'var(--color-primary)' : 'transparent',
color: activeTab === tab.key ? '#fff' : 'var(--color-text-secondary)',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s',
}}
>
<Icon name={tab.icon} size={16} />
{tab.label}
</button>
))}
</div>
{/* Loading */}
{loading && (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<div className="audit-spinner" style={{ margin: '0 auto 12px' }} />
<span>加载中...</span>
</div>
)}
{/* Content */}
{!loading && activeTab === 'overview' && overview && (
<MarketOverview overview={overview} topApps={topApps} topMetric={topMetric} onMetricChange={setTopMetric} />
)}
{!loading && activeTab === 'apps' && (
<AppsManagement
apps={apps} total={appsTotal} page={appsPage} onPageChange={setAppsPage}
filterStatus={filterStatus} setFilterStatus={setFilterStatus}
filterType={filterType} setFilterType={setFilterType}
filterCategory={filterCategory} setFilterCategory={setFilterCategory}
filterKeyword={filterKeyword} setFilterKeyword={setFilterKeyword}
onEdit={setEditApp} onToggleFeatured={handleToggleFeatured} onDelete={handleDeleteApp}
/>
)}
{!loading && activeTab === 'pending' && (
<PendingApps apps={pendingApps} onApprove={handleApprove} onReject={handleReject} />
)}
{!loading && activeTab === 'coupons' && (
<CouponsManagement
coupons={coupons} onAdd={() => { setEditingCoupon(null); setCouponModal(true); }}
onEdit={(c) => { setEditingCoupon(c); setCouponModal(true); }}
onDelete={handleDeleteCoupon}
/>
)}
{!loading && activeTab === 'reviews' && (
<ReviewsList reviews={reviews} />
)}
{!loading && activeTab === 'mcp' && (
<McpMarketTab />
)}
{/* Edit Modal */}
{editApp && (
<EditAppModal app={editApp} onClose={() => setEditApp(null)} onSaved={() => { setEditApp(null); loadApps(); }} />
)}
{/* Coupon Modal */}
{couponModal && (
<CouponModal coupon={editingCoupon} onClose={() => setCouponModal(false)} onSaved={() => { setCouponModal(false); loadCoupons(); }} />
)}
</div>
);
};
// ============================================================
// Overview Component
// ============================================================
const MarketOverview: React.FC<{ overview: OverviewData; topApps: MarketAppItem[]; topMetric: string; onMetricChange: (v: string) => void }> = ({
overview, topApps, topMetric, onMetricChange
}) => {
const stats = [
{ label: '总应用数', value: overview.total_apps, sub: `${overview.published} 已发布`, color: 'var(--color-primary)' },
{ label: '总安装量', value: overview.total_installs, sub: `${overview.pending} 待审核`, color: 'var(--color-success)' },
{ label: '总收入', value: `¥${overview.total_revenue.toFixed(2)}`, sub: `${overview.total_purchases} 笔购买`, color: 'var(--color-warning)' },
{ label: '平均评分', value: overview.overall_rating.toFixed(1), sub: `${overview.total_reviews} 条评价`, color: 'var(--color-danger)' },
];
const maxTrend = Math.max(...overview.trend_7d.map(t => Math.max(t.new_apps, t.new_installs)), 1);
return (
<div>
{/* Stat cards */}
<div className="responsive-grid-4" style={{ gap: '16px', marginBottom: '24px' }}>
{stats.map((s, i) => (
<div key={i} style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)',
}}>
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', marginBottom: '8px' }}>{s.label}</div>
<div style={{ fontSize: '28px', fontWeight: 700, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-tertiary)', marginTop: '4px' }}>{s.sub}</div>
</div>
))}
</div>
{/* 7-day trend */}
<div style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '24px',
border: '1px solid var(--color-border)', marginBottom: '24px',
}}>
<h3 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 20px', color: 'var(--color-text-primary)' }}>
7日趋势
</h3>
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', height: '180px' }}>
{overview.trend_7d.map((t, i) => (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', gap: '4px', alignItems: 'flex-end', height: '140px' }}>
<div style={{
width: '16px', height: `${(t.new_apps / maxTrend) * 130 + 4}px`,
background: 'var(--color-primary)', borderRadius: '4px 4px 0 0',
minHeight: '4px', transition: 'height 0.3s',
}} title={`新应用: ${t.new_apps}`} />
<div style={{
width: '16px', height: `${(t.new_installs / maxTrend) * 130 + 4}px`,
background: 'var(--color-success)', borderRadius: '4px 4px 0 0',
minHeight: '4px', transition: 'height 0.3s',
}} title={`新安装: ${t.new_installs}`} />
</div>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
{t.date.slice(5)}
</span>
</div>
))}
</div>
<div style={{ display: 'flex', gap: '24px', marginTop: '16px', fontSize: '12px' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '12px', height: '12px', background: 'var(--color-primary)', borderRadius: '3px' }} />
<span style={{ color: 'var(--color-text-secondary)' }}>新应用</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ width: '12px', height: '12px', background: 'var(--color-success)', borderRadius: '3px' }} />
<span style={{ color: 'var(--color-text-secondary)' }}>新安装</span>
</span>
</div>
</div>
{/* Category distribution + Top apps */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '16px' }}>
{/* Categories */}
<div style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)',
}}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: '0 0 16px' }}>分类分布</h3>
{Object.entries(overview.by_category).map(([cat, cnt]) => (
<div key={cat} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>{CATEGORY_LABELS[cat] || cat}</span>
<span style={{ fontSize: '13px', fontWeight: 600 }}>{cnt}</span>
</div>
))}
{Object.keys(overview.by_category).length === 0 && (
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', textAlign: 'center', padding: '20px' }}>暂无数据</div>
)}
</div>
{/* Top apps */}
<div style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>热门应用</h3>
<select
value={topMetric}
onChange={(e) => onMetricChange(e.target.value)}
style={{
padding: '4px 12px', borderRadius: '6px', border: '1px solid var(--color-border)',
background: 'var(--color-bg-primary)', color: 'var(--color-text-primary)', fontSize: '12px',
}}
>
<option value="installs">按安装量</option>
<option value="rating">按评分</option>
<option value="revenue">按收入</option>
<option value="reviews">按评价数</option>
</select>
</div>
{topApps.map((app, i) => (
<div key={app.id} style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '8px 0', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontSize: '16px', fontWeight: 700, color: i < 3 ? 'var(--color-warning)' : 'var(--color-text-tertiary)', width: '24px' }}>
#{i + 1}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{app.name}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
{TYPE_LABELS[app.type] || app.type} · {CATEGORY_LABELS[app.category] || app.category}
</div>
</div>
<div style={{ textAlign: 'right', fontSize: '13px', fontWeight: 600 }}>
{topMetric === 'installs' && `${app.install_count}`}
{topMetric === 'rating' && `${app.rating_avg.toFixed(1)}`}
{topMetric === 'reviews' && `${app.review_count}`}
{topMetric === 'revenue' && `¥${(app as any).revenue?.toFixed(0) || 0}`}
</div>
</div>
))}
{topApps.length === 0 && (
<div style={{ fontSize: '13px', color: 'var(--color-text-tertiary)', textAlign: 'center', padding: '20px' }}>暂无数据</div>
)}
</div>
</div>
</div>
);
};
// ============================================================
// Apps Management Component
// ============================================================
const AppsManagement: React.FC<{
apps: MarketAppItem[]; total: number; page: number; onPageChange: (p: number) => void;
filterStatus: string; setFilterStatus: (v: string) => void;
filterType: string; setFilterType: (v: string) => void;
filterCategory: string; setFilterCategory: (v: string) => void;
filterKeyword: string; setFilterKeyword: (v: string) => void;
onEdit: (app: MarketAppItem) => void; onToggleFeatured: (app: MarketAppItem) => void; onDelete: (id: string, name: string) => void;
}> = ({ apps, total, page, onPageChange, filterStatus, setFilterStatus, filterType, setFilterType, filterCategory, setFilterCategory, filterKeyword, setFilterKeyword, onEdit, onToggleFeatured, onDelete }) => {
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Filters */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap' }}>
<input
placeholder="搜索应用..."
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
style={{ flex: 1, minWidth: '200px', padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}
/>
<select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部状态</option>
<option value="published">已发布</option>
<option value="pending">待审核</option>
<option value="removed">已下架</option>
</select>
<select value={filterType} onChange={(e) => setFilterType(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部类型</option>
<option value="skill">Skills工具</option>
<option value="agent">智能Agent</option>
<option value="mcp">MCP协议</option>
</select>
<select value={filterCategory} onChange={(e) => setFilterCategory(e.target.value)} style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="">全部分类</option>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
{/* Table */}
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['应用', '类型', '分类', '价格', '安装', '评分', '状态', '推荐', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{apps.map(app => (
<tr key={app.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{app.icon && <img src={app.icon} alt="" style={{ width: '32px', height: '32px', borderRadius: '6px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />}
<div>
<div style={{ fontSize: '13px', fontWeight: 500 }}>{app.name}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{app.publisher_name || '—'}</div>
</div>
</div>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{TYPE_LABELS[app.type] || app.type}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{CATEGORY_LABELS[app.category] || app.category}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
{app.price === 0 ? <span style={{ color: 'var(--color-success)' }}>免费</span> : `¥${app.price}`}
{app.is_member_free && <span style={{ fontSize: '10px', color: 'var(--color-warning)', marginLeft: '4px' }}>会员免费</span>}
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{app.install_count}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>
{app.rating_avg > 0 ? `★ ${app.rating_avg.toFixed(1)} (${app.review_count})` : '—'}
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: STATUS_COLORS[app.status] || 'var(--color-text-tertiary)', color: '#fff' }}>
{STATUS_LABELS[app.status] || app.status}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<button
onClick={() => onToggleFeatured(app)}
style={{
padding: '4px 8px', borderRadius: '4px', border: 'none', cursor: 'pointer', fontSize: '12px',
background: app.featured ? 'var(--color-warning)' : 'var(--color-bg-tertiary)',
color: app.featured ? '#fff' : 'var(--color-text-tertiary)',
}}
>
{app.featured ? '已推荐' : '推荐'}
</button>
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => onEdit(app)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
<button onClick={() => onDelete(app.id, app.name)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{apps.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无应用</div>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '16px' }}>
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1 }}>上一页</button>
<span style={{ padding: '6px 12px', fontSize: '13px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-secondary)', cursor: page >= totalPages ? 'not-allowed' : 'pointer', opacity: page >= totalPages ? 0.5 : 1 }}>下一页</button>
</div>
)}
</div>
);
};
// ============================================================
// Pending Apps Component
// ============================================================
const PendingApps: React.FC<{ apps: MarketAppItem[]; onApprove: (id: string) => void; onReject: (id: string) => void }> = ({ apps, onApprove, onReject }) => (
<div>
{apps.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<Icon name="check" size={48} color="var(--color-success)" />
<p style={{ marginTop: '16px', fontSize: '14px' }}>没有待审核的应用</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{apps.map(app => (
<div key={app.id} style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '20px',
border: '1px solid var(--color-border)', display: 'flex', gap: '16px',
}}>
{app.icon ? (
<img src={app.icon} alt="" style={{ width: '56px', height: '56px', borderRadius: '10px' }} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
) : (
<div style={{ width: '56px', height: '56px', borderRadius: '10px', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Icon name="store" size={24} color="var(--color-text-tertiary)" />
</div>
)}
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
<h4 style={{ margin: 0, fontSize: '15px', fontWeight: 600 }}>{app.name}</h4>
<span style={{ fontSize: '11px', padding: '2px 6px', borderRadius: '4px', background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}>
{TYPE_LABELS[app.type] || app.type}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
by {app.publisher_name || 'Unknown'}
</span>
</div>
<p style={{ margin: '0 0 8px', fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{app.description || '无描述'}
</p>
{app.skill_content && (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>查看 Skill 内容</summary>
<pre style={{ marginTop: '8px', padding: '12px', background: 'var(--color-bg-tertiary)', borderRadius: '8px', fontSize: '12px', overflow: 'auto', maxHeight: '200px', color: 'var(--color-text-secondary)' }}>
{app.skill_content.slice(0, 500)}{app.skill_content.length > 500 ? '...' : ''}
</pre>
</details>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button onClick={() => onApprove(app.id)} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-success)', color: '#fff', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Icon name="check" size={14} /> 审核通过
</button>
<button onClick={() => onReject(app.id)} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Icon name="close" size={14} /> 拒绝
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
// ============================================================
// Coupons Component
// ============================================================
const CouponsManagement: React.FC<{
coupons: CouponItem[]; onAdd: () => void; onEdit: (c: CouponItem) => void; onDelete: (id: string) => void;
}> = ({ coupons, onAdd, onEdit, onDelete }) => (
<div>
<div style={{ marginBottom: '16px' }}>
<button onClick={onAdd} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '6px' }}>
<Icon name="plus" size={16} /> 创建优惠券
</button>
</div>
<div style={{ background: 'var(--color-bg-secondary)', borderRadius: '12px', border: '1px solid var(--color-border)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border)' }}>
{['优惠券码', '类型', '面值', '最低消费', '有效期', '使用情况', '状态', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '12px', fontWeight: 600, color: 'var(--color-text-tertiary)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{coupons.map(c => (
<tr key={c.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
<td style={{ padding: '12px 16px', fontSize: '13px', fontWeight: 500, fontFamily: 'monospace' }}>{c.code}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.type === 'fixed' ? '固定金额' : '百分比'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.type === 'fixed' ? `¥${c.value}` : `${c.value}%`}</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>¥{c.min_amount}</td>
<td style={{ padding: '12px 16px', fontSize: '12px', color: 'var(--color-text-tertiary)' }}>
{c.valid_from?.slice(0, 10) || '—'} ~ {c.valid_to?.slice(0, 10) || '长期'}
</td>
<td style={{ padding: '12px 16px', fontSize: '13px' }}>{c.used_count} / {c.total_count}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: c.status === 'active' ? 'var(--color-success)' : 'var(--color-text-tertiary)', color: '#fff' }}>
{c.status === 'active' ? '有效' : '已停用'}
</span>
</td>
<td style={{ padding: '12px 16px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button onClick={() => onEdit(c)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
<button onClick={() => onDelete(c.id)} style={{ padding: '4px 8px', borderRadius: '4px', border: '1px solid var(--color-danger)', background: 'transparent', color: 'var(--color-danger)', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{coupons.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-tertiary)', fontSize: '14px' }}>暂无优惠券</div>
)}
</div>
</div>
);
// ============================================================
// Reviews List Component
// ============================================================
const ReviewsList: React.FC<{ reviews: ReviewItem[] }> = ({ reviews }) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{reviews.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px', color: 'var(--color-text-tertiary)' }}>
<Icon name="star" size={48} color="var(--color-text-tertiary)" />
<p style={{ marginTop: '16px', fontSize: '14px' }}>暂无用户评价</p>
</div>
) : (
reviews.map(r => (
<div key={r.id} style={{
background: 'var(--color-bg-secondary)', borderRadius: '12px', padding: '16px',
border: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
{r.avatar ? (
<img src={r.avatar} alt="" style={{ width: '28px', height: '28px', borderRadius: '50%' }} />
) : (
<div style={{ width: '28px', height: '28px', borderRadius: '50%', background: 'var(--color-bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px' }}>
{r.username?.charAt(0).toUpperCase() || 'U'}
</div>
)}
<span style={{ fontSize: '13px', fontWeight: 500 }}>{r.username}</span>
<span style={{ fontSize: '12px', color: 'var(--color-warning)' }}>
{'★'.repeat(r.rating)}{'☆'.repeat(5 - r.rating)}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>
on {r.app_name}
</span>
<span style={{ fontSize: '11px', color: 'var(--color-text-tertiary)', marginLeft: 'auto' }}>
{r.created_at?.slice(0, 16)}
</span>
</div>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--color-text-secondary)', lineHeight: 1.5 }}>
{r.content}
</p>
</div>
))
)}
</div>
);
// ============================================================
// Edit App Modal
// ============================================================
const EditAppModal: React.FC<{ app: MarketAppItem; onClose: () => void; onSaved: () => void }> = ({ app, onClose, onSaved }) => {
const [form, setForm] = useState({
name: app.name,
description: app.description || '',
category: app.category || 'other',
tags: app.tags || '',
price: app.price,
is_member_free: app.is_member_free,
featured: app.featured,
featured_order: app.featured_order,
status: app.status,
});
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await adminAPI.updateMarketApp(app.id, form);
onSaved();
} catch (e) {
alert('保存失败');
}
setSaving(false);
};
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: '12px', padding: '24px', width: '500px', maxWidth: '90vw', maxHeight: '85vh', overflow: 'auto' }} onClick={e => e.stopPropagation()}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 600 }}>编辑应用</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>应用名称</label>
<input value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>描述</label>
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} rows={3} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)', resize: 'vertical' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>分类</label>
<select value={form.category} onChange={e => setForm({ ...form, category: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
{Object.entries(CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>价格 (¥)</label>
<input type="number" value={form.price} onChange={e => setForm({ ...form, price: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>标签 (逗号分隔)</label>
<input value={form.tags} onChange={e => setForm({ ...form, tags: e.target.value })} placeholder="AI, 工具, 效率" style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>状态</label>
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="published">已发布</option>
<option value="pending">待审核</option>
<option value="removed">已下架</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>推荐排序</label>
<input type="number" value={form.featured_order} onChange={e => setForm({ ...form, featured_order: parseInt(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div style={{ display: 'flex', gap: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
<input type="checkbox" checked={form.is_member_free} onChange={e => setForm({ ...form, is_member_free: e.target.checked })} />
会员免费
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '13px', cursor: 'pointer' }}>
<input type="checkbox" checked={form.featured} onChange={e => setForm({ ...form, featured: e.target.checked })} />
推荐应用
</label>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 20px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '14px', opacity: saving ? 0.6 : 1 }}>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
};
// ============================================================
// Coupon Modal
// ============================================================
const CouponModal: React.FC<{ coupon: CouponItem | null; onClose: () => void; onSaved: () => void }> = ({ coupon, onClose, onSaved }) => {
const [form, setForm] = useState({
code: coupon?.code || '',
type: coupon?.type || 'fixed',
value: coupon?.value || 10,
min_amount: coupon?.min_amount || 0,
valid_from: coupon?.valid_from?.slice(0, 10) || '',
valid_to: coupon?.valid_to?.slice(0, 10) || '',
total_count: coupon?.total_count || 100,
});
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!form.code.trim()) { alert('请输入优惠券码'); return; }
setSaving(true);
try {
if (coupon) {
await adminAPI.updateCoupon(coupon.id, form);
} else {
await adminAPI.addCoupon(form);
}
onSaved();
} catch (e) {
alert('保存失败');
}
setSaving(false);
};
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }} onClick={onClose}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: '12px', padding: '24px', width: '440px', maxWidth: '90vw' }} onClick={e => e.stopPropagation()}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', fontWeight: 600 }}>{coupon ? '编辑优惠券' : '创建优惠券'}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>优惠券码</label>
<input value={form.code} onChange={e => setForm({ ...form, code: e.target.value })} placeholder="SUMMER2026" style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>类型</label>
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}>
<option value="fixed">固定金额</option>
<option value="percent">百分比</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>面值</label>
<input type="number" value={form.value} onChange={e => setForm({ ...form, value: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>最低消费 (¥)</label>
<input type="number" value={form.min_amount} onChange={e => setForm({ ...form, min_amount: parseFloat(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>发放数量</label>
<input type="number" value={form.total_count} onChange={e => setForm({ ...form, total_count: parseInt(e.target.value) || 0 })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>开始日期</label>
<input type="date" value={form.valid_from} onChange={e => setForm({ ...form, valid_from: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '13px', marginBottom: '4px', color: 'var(--color-text-secondary)' }}>结束日期</label>
<input type="date" value={form.valid_to} onChange={e => setForm({ ...form, valid_to: e.target.value })} style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }} />
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '24px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ padding: '8px 20px', borderRadius: '8px', border: '1px solid var(--color-border)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '14px', opacity: saving ? 0.6 : 1 }}>
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
};
export default Market;
+405
View File
@@ -0,0 +1,405 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const styles: Record<string, React.CSSProperties> = {
container: { padding: 24, backgroundColor: '#f5f7fa', minHeight: '100vh' },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 },
title: { fontSize: 24, fontWeight: 700, color: '#1A1F36' },
addBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
statsRow: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 24 },
statCard: { backgroundColor: '#fff', padding: 20, borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
statLabel: { fontSize: 13, color: '#6b7280', marginBottom: 8 },
statValue: { fontSize: 28, fontWeight: 700, color: '#1A1F36' },
toolbar: { display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' as const },
searchInput: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, width: 250 },
filterSelect: { padding: '8px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, backgroundColor: '#fff' },
table: { width: '100%', borderCollapse: 'collapse' as const, backgroundColor: '#fff', borderRadius: 12, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' },
th: { padding: '12px 16px', textAlign: 'left' as const, fontSize: 13, fontWeight: 600, color: '#6b7280', backgroundColor: '#f9fafb', borderBottom: '1px solid #e5e7eb' },
td: { padding: '12px 16px', fontSize: 14, color: '#374151', borderBottom: '1px solid #f3f4f6' },
badge: { display: 'inline-block', padding: '2px 10px', borderRadius: 20, fontSize: 12, fontWeight: 600 },
actionBtn: { padding: '4px 12px', border: '1px solid #e5e7eb', borderRadius: 6, fontSize: 13, cursor: 'pointer', marginRight: 6, backgroundColor: '#fff' },
pagination: { display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 },
pageBtn: { padding: '8px 16px', border: '1px solid #e5e7eb', borderRadius: 6, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
modalOverlay: { position: 'fixed' as const, top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 1000 },
modal: { backgroundColor: '#fff', borderRadius: 16, padding: 32, width: 560, maxHeight: '80vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.15)' },
modalTitle: { fontSize: 18, fontWeight: 700, marginBottom: 24, color: '#1A1F36' },
formGroup: { marginBottom: 16 },
label: { display: 'block', fontSize: 13, fontWeight: 600, color: '#374151', marginBottom: 6 },
input: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, boxSizing: 'border-box' as const },
textarea: { width: '100%', padding: '10px 12px', border: '1px solid #e5e7eb', borderRadius: 8, fontSize: 14, minHeight: 80, resize: 'vertical' as const, boxSizing: 'border-box' as const },
modalActions: { display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 24 },
cancelBtn: { padding: '10px 24px', border: '1px solid #e5e7eb', borderRadius: 8, cursor: 'pointer', backgroundColor: '#fff', fontSize: 14 },
submitBtn: { padding: '10px 24px', backgroundColor: '#4f46e5', color: '#fff', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 600 },
toast: { position: 'fixed' as const, top: 20, right: 20, padding: '12px 24px', borderRadius: 8, fontSize: 14, fontWeight: 600, zIndex: 2000, color: '#fff' },
detailsGrid: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginTop: 16 },
detailsItem: { fontSize: 13 },
detailsLabel: { color: '#6b7280', marginBottom: 4 },
};
const TRANSPORT_TYPES = [
{ value: '', label: '全部类型' },
{ value: 'stdio', label: 'STDIO' },
{ value: 'sse', label: 'SSE' },
];
const STATUS_TYPES = [
{ value: '', label: '全部状态' },
{ value: 'connected', label: '已连接' },
{ value: 'disconnected', label: '未连接' },
{ value: 'error', label: '错误' },
];
interface MCPServer {
id: string;
name: string;
description: string;
transport_type: string;
transport_type_label: string;
command: string;
args: Record<string, any>;
env_vars: Record<string, string>;
url: string;
headers: Record<string, string>;
is_enabled: boolean;
status: string;
last_connected_at: string;
tools_count: number;
resources_count: number;
created_at: string;
updated_at: string;
}
export default function AdminMcp() {
const [servers, setServers] = useState<MCPServer[]>([]);
const [stats, setStats] = useState<any>({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [filterTransport, setFilterTransport] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingServer, setEditingServer] = useState<MCPServer | null>(null);
const [toast, setToast] = useState<{ message: string; type: string } | null>(null);
const pageSize = 20;
const showToast = (message: string, type: string = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const fetchStats = useCallback(async () => {
try {
const result = await adminAPI.getAdminMcpStats() as any;
if (result.code === 0) setStats(result.data);
} catch (e) { /* ignore */ }
}, []);
const fetchServers = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (search) params.search = search;
if (filterTransport) params.transport_type = filterTransport;
if (filterStatus) params.status = filterStatus;
const result = await adminAPI.getAdminMcpServers(params) as any;
if (result.code === 0) {
setServers(result.data.items || []);
setTotal(result.data.total || 0);
}
} catch (e) {
showToast('加载服务失败', 'error');
} finally {
setLoading(false);
}
}, [page, search, filterTransport, filterStatus]);
useEffect(() => {
fetchStats();
fetchServers();
}, [fetchStats, fetchServers]);
const handleCreate = () => {
setEditingServer(null);
setShowModal(true);
};
const handleEdit = (server: MCPServer) => {
setEditingServer(server);
setShowModal(true);
};
const handleDelete = async (server: MCPServer) => {
if (!confirm(`确认删除 MCP 服务 "${server.name}"?`)) return;
try {
await adminAPI.deleteAdminMcpServer(server.id);
showToast('服务已删除');
fetchServers();
fetchStats();
} catch (e) {
showToast('删除失败', 'error');
}
};
const handleToggle = async (server: MCPServer) => {
try {
await adminAPI.toggleAdminMcpServer(server.id);
showToast(server.is_enabled ? '已禁用' : '已启用');
fetchServers();
fetchStats();
} catch (e) {
showToast('切换失败', 'error');
}
};
const handleTest = async (server: MCPServer) => {
try {
const result = await adminAPI.testAdminMcpServer(server.id) as any;
if (result.code === 0) {
showToast(result.data.success ? '连接成功' : `连接失败:${result.data.message}`, result.data.success ? 'success' : 'error');
fetchServers();
fetchStats();
}
} catch (e) {
showToast('测试失败', 'error');
}
};
const handleSubmit = async (formData: any) => {
try {
if (editingServer) {
await adminAPI.updateAdminMcpServer(editingServer.id, formData);
showToast('服务已更新');
} else {
await adminAPI.createAdminMcpServer(formData);
showToast('服务已创建');
}
setShowModal(false);
fetchServers();
fetchStats();
} catch (e: any) {
showToast(e.message || '操作失败', 'error');
}
};
const getStatusBadge = (status: string) => {
const labels: Record<string, string> = { connected: '已连接', disconnected: '未连接', error: '错误' };
const colors: Record<string, { bg: string; color: string }> = {
connected: { bg: '#dcfce7', color: '#166534' },
disconnected: { bg: '#f3f4f6', color: '#6b7280' },
error: { bg: '#fee2e2', color: '#991b1b' },
};
const c = colors[status] || colors.disconnected;
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{labels[status] || status}</span>;
};
const getTransportBadge = (type: string) => {
const c = type === 'sse' ? { bg: '#e0e7ff', color: '#3730a3' } : { bg: '#fef3c7', color: '#92400e' };
return <span style={{ ...styles.badge, backgroundColor: c.bg, color: c.color }}>{type === 'sse' ? 'SSE' : type === 'stdio' ? '标准IO' : type}</span>;
};
const totalPages = Math.ceil(total / pageSize);
return (
<div style={styles.container}>
{toast && (
<div style={{ ...styles.toast, backgroundColor: toast.type === 'error' ? '#ef4444' : '#10b981' }}>
{toast.message}
</div>
)}
<div style={styles.header}>
<h1 style={styles.title}>MCP 服务管理</h1>
<button style={styles.addBtn} onClick={handleCreate}>+ 添加服务</button>
</div>
<div style={styles.statsRow}>
<div style={styles.statCard}>
<div style={styles.statLabel}>服务总数</div>
<div style={styles.statValue}>{stats.total_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>已启用</div>
<div style={styles.statValue}>{stats.enabled_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>已连接</div>
<div style={{ ...styles.statValue, color: '#10b981' }}>{stats.connected_servers || 0}</div>
</div>
<div style={styles.statCard}>
<div style={styles.statLabel}>传输方式分布</div>
<div style={{ fontSize: 13, color: '#374151' }}>
{Object.entries(stats.by_transport || {}).map(([k, v]) => (
<span key={k} style={{ marginRight: 12 }}>{k}: {v as number}</span>
))}
</div>
</div>
</div>
<div style={styles.toolbar}>
<input
style={styles.searchInput}
placeholder="搜索服务..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
/>
<select style={styles.filterSelect} value={filterTransport} onChange={e => { setFilterTransport(e.target.value); setPage(1); }}>
{TRANSPORT_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
<select style={styles.filterSelect} value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}>
{STATUS_TYPES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>名称</th>
<th style={styles.th}>传输方式</th>
<th style={styles.th}>状态</th>
<th style={styles.th}>工具数</th>
<th style={styles.th}>最后连接</th>
<th style={styles.th}>启用</th>
<th style={styles.th}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40 }}>加载中...</td></tr>
) : servers.length === 0 ? (
<tr><td colSpan={7} style={{ ...styles.td, textAlign: 'center', padding: 40, color: '#9ca3af' }}>未找到 MCP 服务</td></tr>
) : servers.map(s => (
<tr key={s.id}>
<td style={styles.td}>
<div style={{ fontWeight: 600 }}>{s.name}</div>
<div style={{ fontSize: 12, color: '#9ca3af' }}>{s.description?.substring(0, 50)}{s.description?.length > 50 ? '...' : ''}</div>
</td>
<td style={styles.td}>{getTransportBadge(s.transport_type)}</td>
<td style={styles.td}>{getStatusBadge(s.status)}</td>
<td style={styles.td}>{s.tools_count || '-'}</td>
<td style={styles.td}>{s.last_connected_at ? new Date(s.last_connected_at).toLocaleString() : '从未'}</td>
<td style={styles.td}>
<span style={{ ...styles.badge, backgroundColor: s.is_enabled ? '#dcfce7' : '#f3f4f6', color: s.is_enabled ? '#166534' : '#6b7280' }}>
{s.is_enabled ? '是' : '否'}
</span>
</td>
<td style={styles.td}>
<button style={styles.actionBtn} onClick={() => handleEdit(s)}>编辑</button>
<button style={{ ...styles.actionBtn, color: s.is_enabled ? '#ef4444' : '#10b981' }} onClick={() => handleToggle(s)}>
{s.is_enabled ? '禁用' : '启用'}
</button>
<button style={{ ...styles.actionBtn, color: '#4f46e5' }} onClick={() => handleTest(s)}>测试</button>
<button style={{ ...styles.actionBtn, color: '#ef4444' }} onClick={() => handleDelete(s)}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div style={styles.pagination}>
<button style={styles.pageBtn} disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
<span style={{ padding: '8px 12px', fontSize: 14 }}>第 {page} / {totalPages} 页</span>
<button style={styles.pageBtn} disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
)}
{showModal && <McpModal server={editingServer} onSubmit={handleSubmit} onClose={() => setShowModal(false)} />}
</div>
);
}
function McpModal({ server, onSubmit, onClose }: { server: MCPServer | null; onSubmit: (data: any) => void; onClose: () => void }) {
const [form, setForm] = useState({
name: server?.name || '',
description: server?.description || '',
transport_type: server?.transport_type || 'stdio',
command: server?.command || '',
args: server?.args ? JSON.stringify(server.args, null, 2) : '',
env_vars: server?.env_vars ? JSON.stringify(server.env_vars, null, 2) : '',
url: server?.url || '',
headers: server?.headers ? JSON.stringify(server.headers, null, 2) : '',
});
const handleChange = (field: string, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: any = {
name: form.name,
description: form.description,
transport_type: form.transport_type,
};
if (form.transport_type === 'stdio') {
data.command = form.command;
try { data.args = form.args ? JSON.parse(form.args) : {}; } catch { data.args = {}; }
try { data.env_vars = form.env_vars ? JSON.parse(form.env_vars) : {}; } catch { data.env_vars = {}; }
} else {
data.url = form.url;
try { data.headers = form.headers ? JSON.parse(form.headers) : {}; } catch { data.headers = {}; }
}
onSubmit(data);
};
return (
<div style={styles.modalOverlay} onClick={onClose}>
<div style={styles.modal} onClick={e => e.stopPropagation()}>
<div style={styles.modalTitle}>{server ? '编辑 MCP 服务' : '添加 MCP 服务'}</div>
<form onSubmit={handleSubmit}>
<div style={styles.formGroup}>
<label style={styles.label}>名称 *</label>
<input style={styles.input} value={form.name} onChange={e => handleChange('name', e.target.value)} required placeholder="例如:文件系统服务" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>描述</label>
<textarea style={styles.textarea} value={form.description} onChange={e => handleChange('description', e.target.value)} placeholder="可选描述信息" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>传输类型</label>
<select style={{ ...styles.input, width: '100%' }} value={form.transport_type} onChange={e => handleChange('transport_type', e.target.value)}>
<option value="stdio">STDIO(命令行方式)</option>
<option value="sse">SSE(HTTP 方式)</option>
</select>
</div>
{form.transport_type === 'stdio' ? (
<>
<div style={styles.formGroup}>
<label style={styles.label}>命令</label>
<input style={styles.input} value={form.command} onChange={e => handleChange('command', e.target.value)} placeholder="例如:npx @modelcontextprotocol/server-filesystem" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>参数(JSON)</label>
<textarea style={styles.textarea} value={form.args} onChange={e => handleChange('args', e.target.value)} placeholder='["/path/to/allowed"]' />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>环境变量(JSON)</label>
<textarea style={styles.textarea} value={form.env_vars} onChange={e => handleChange('env_vars', e.target.value)} placeholder='{"KEY": "value"}' />
</div>
</>
) : (
<>
<div style={styles.formGroup}>
<label style={styles.label}>SSE 地址</label>
<input style={styles.input} value={form.url} onChange={e => handleChange('url', e.target.value)} placeholder="http://localhost:3001/sse" />
</div>
<div style={styles.formGroup}>
<label style={styles.label}>请求头(JSON)</label>
<textarea style={styles.textarea} value={form.headers} onChange={e => handleChange('headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
</div>
</>
)}
<div style={styles.modalActions}>
<button type="button" style={styles.cancelBtn} onClick={onClose}>取消</button>
<button type="submit" style={styles.submitBtn}>{server ? '更新' : '创建'}</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,729 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
// ============================================================
// Types
// ============================================================
interface McpTool {
id: string;
app_id: string;
name: string;
description: string | null;
icon: string | null;
category: string;
tags: string | null;
status: string;
publisher_name: string | null;
price: number;
is_member_free: boolean;
featured: boolean;
rating_avg: number;
install_count: number;
version: string;
server_type: string;
runtime: string | null;
command: string | null;
is_managed: boolean;
security_status: string;
security_issues: number;
deep_link: string | null;
package_name: string | null;
package_registry: string | null;
tools_count: number;
created_at: string | null;
}
interface McpToolDetail extends McpTool {
app: {
name: string;
description: string | null;
category: string;
status: string;
tutorial: string | null;
};
args: string[] | null;
env_vars: Record<string, string> | null;
transport_url: string | null;
builtin_name: string | null;
managed_url: string | null;
managed_port: number | null;
package_version: string;
package_repo_url: string | null;
security_report: Record<string, any> | null;
security_scanned_at: string | null;
permissions: Record<string, any> | null;
config_schema: Record<string, any> | null;
install_instructions: string | null;
tools_list: string[] | null;
author_name: string | null;
license: string;
homepage_url: string | null;
}
interface McpStats {
total: number;
published: number;
pending_review: number;
managed_servers: number;
by_security_status: Record<string, number>;
by_server_type: Record<string, number>;
by_category: Record<string, number>;
}
const SERVER_TYPE_LABELS: Record<string, string> = {
stdio: 'STDIO',
sse: 'SSE',
streamable_http: 'HTTP',
builtin: '内置',
};
const SECURITY_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: '#d29922' },
scanning: { label: '扫描中', color: '#58a6ff' },
approved: { label: '已通过', color: '#3fb950' },
rejected: { label: '已拒绝', color: '#f85149' },
failed: { label: '扫描失败', color: '#f85149' },
};
const MCP_CATEGORY_LABELS: Record<string, string> = {
developer: '开发工具', database: '数据库', search: '搜索检索',
file: '文件系统', browser: '浏览器', design: '设计创意',
data: '数据处理', communication: '通信协作', cloud: '云服务',
productivity: '效率工具', other: '其他',
};
// ============================================================
// Main Component
// ============================================================
const McpMarketTab: React.FC = () => {
const [tools, setTools] = useState<McpTool[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<McpStats | null>(null);
const [keyword, setKeyword] = useState('');
const [filterServerType, setFilterServerType] = useState('');
const [filterSecurity, setFilterSecurity] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [detailTool, setDetailTool] = useState<McpToolDetail | null>(null);
const loadStats = useCallback(async () => {
try {
const res = await adminAPI.getMcpMarketStats();
if (res?.code === 0) setStats(res.data);
} catch (e) { console.error(e); }
}, []);
const loadTools = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { page, page_size: 20 };
if (keyword) params.keyword = keyword;
if (filterServerType) params.server_type = filterServerType;
if (filterSecurity) params.security_status = filterSecurity;
const res = await adminAPI.getMcpTools(params);
if (res?.code === 0) {
setTools(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (e) { console.error(e); }
setLoading(false);
}, [page, keyword, filterServerType, filterSecurity]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => { loadTools(); }, [loadTools]);
const handleSeed = async () => {
if (!confirm('确认填充 Goose 内置 MCP 工具到市场?')) return;
try {
const res = await adminAPI.seedMcpTools();
if (res?.code === 0) {
alert(res.message || '填充成功');
loadStats();
loadTools();
}
} catch (e) { alert('操作失败'); }
};
const handleScan = async (id: string) => {
try {
const res = await adminAPI.scanMcpTool(id);
if (res?.code === 0) {
alert(`扫描完成: ${res.data?.status} (${res.data?.issues_count} 个问题)`);
loadTools();
}
} catch (e) { alert('扫描失败'); }
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`确认删除 MCP 工具「${name}」?`)) return;
try {
await adminAPI.deleteMcpTool(id);
loadTools();
loadStats();
} catch (e) { alert('删除失败'); }
};
const handleViewDetail = async (id: string) => {
try {
const res = await adminAPI.getMcpToolDetail(id);
if (res?.code === 0) setDetailTool(res.data);
} catch (e) { alert('加载详情失败'); }
};
const copyDeepLink = (link: string) => {
navigator.clipboard.writeText(link).then(() => {
alert('Deep Link 已复制到剪贴板!');
}).catch(() => {
prompt('请手动复制:', link);
});
};
const totalPages = Math.ceil(total / 20);
return (
<div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '20px' }}>
<StatCard label="MCP 工具总数" value={stats.total} sub={`${stats.published} 已发布`} color="var(--color-primary, #58a6ff)" />
<StatCard label="待审核" value={stats.pending_review} sub="安全审核中" color="#d29922" />
<StatCard label="托管服务器" value={stats.managed_servers} sub="平台托管" color="#3fb950" />
<StatCard label="已通过审核" value={stats.by_security_status?.approved || 0} sub="可发布" color="#bc8cff" />
</div>
)}
{/* Toolbar */}
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
placeholder="搜索 MCP 工具..."
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1); }}
style={{
flex: 1, minWidth: 200, padding: '8px 12px',
background: 'var(--color-bg-secondary, #161b22)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8, color: 'var(--color-text-primary, #e6edf3)', fontSize: 14,
}}
/>
<select
value={filterServerType}
onChange={(e) => { setFilterServerType(e.target.value); setPage(1); }}
style={selectStyle}
>
<option value="">全部类型</option>
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<select
value={filterSecurity}
onChange={(e) => { setFilterSecurity(e.target.value); setPage(1); }}
style={selectStyle}
>
<option value="">全部状态</option>
{Object.entries(SECURITY_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<button onClick={handleSeed} style={btnSecondaryStyle}>填充内置工具</button>
<button onClick={() => setShowCreate(true)} style={btnPrimaryStyle}>+ 创建 MCP 工具</button>
</div>
{/* Tools List */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>加载中...</div>
) : tools.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
暂无 MCP 工具。点击"填充内置工具"快速导入 Goose 内置扩展。
</div>
) : (
<div style={{ display: 'grid', gap: '12px' }}>
{tools.map(tool => (
<ToolCard
key={tool.id}
tool={tool}
onView={() => handleViewDetail(tool.id)}
onScan={() => handleScan(tool.id)}
onDelete={() => handleDelete(tool.id, tool.name)}
onCopyLink={() => tool.deep_link && copyDeepLink(tool.deep_link)}
/>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} style={pageBtnStyle}>上一页</button>
<span style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>{page} / {totalPages}</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)} style={pageBtnStyle}>下一页</button>
</div>
)}
{/* Create Modal */}
{showCreate && (
<CreateMcpToolModal
onClose={() => setShowCreate(false)}
onSaved={() => { setShowCreate(false); loadTools(); loadStats(); }}
/>
)}
{/* Detail Modal */}
{detailTool && (
<McpToolDetailModal
tool={detailTool}
onClose={() => setDetailTool(null)}
onCopyLink={() => detailTool.deep_link && copyDeepLink(detailTool.deep_link)}
/>
)}
</div>
);
};
// ============================================================
// Sub Components
// ============================================================
const StatCard: React.FC<{ label: string; value: number; sub: string; color: string }> = ({ label, value, sub, color }) => (
<div style={{
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #30363d)',
}}>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 700, color }}>{value}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 4 }}>{sub}</div>
</div>
);
const ToolCard: React.FC<{
tool: McpTool;
onView: () => void;
onScan: () => void;
onDelete: () => void;
onCopyLink: () => void;
}> = ({ tool, onView, onScan, onDelete, onCopyLink }) => {
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
const stLabel = SERVER_TYPE_LABELS[tool.server_type] || tool.server_type;
const catLabel = MCP_CATEGORY_LABELS[tool.category] || tool.category;
return (
<div style={{
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 12, padding: 16,
border: '1px solid var(--color-border, #30363d)',
display: 'flex', alignItems: 'flex-start', gap: 16,
}}>
{/* Icon */}
<div style={{
width: 48, height: 48, borderRadius: 10, flexShrink: 0,
background: tool.server_type === 'builtin' ? 'linear-gradient(135deg, #58a6ff, #bc8cff)' : 'var(--color-bg-tertiary, #21262d)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: '#fff',
}}>
{tool.name.charAt(0).toUpperCase()}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text-primary, #e6edf3)' }}>{tool.name}</span>
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{stLabel}</span>
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>托管</span>}
{tool.tools_count > 0 && <span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} tools</span>}
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 6 }}>
{tool.description || '暂无描述'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', display: 'flex', gap: 12 }}>
<span>{catLabel}</span>
{tool.package_name && <span>{tool.package_name}</span>}
{tool.publisher_name && <span>by {tool.publisher_name}</span>}
{tool.security_issues > 0 && <span style={{ color: '#f85149' }}>{tool.security_issues} 个安全问题</span>}
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button onClick={onView} style={btnSmallStyle}>详情</button>
{tool.deep_link && <button onClick={onCopyLink} style={btnSmallStyle}>Deep Link</button>}
<button onClick={onScan} style={btnSmallStyle}>扫描</button>
<button onClick={onDelete} style={{ ...btnSmallStyle, color: '#f85149' }}>删除</button>
</div>
</div>
);
};
const CreateMcpToolModal: React.FC<{ onClose: () => void; onSaved: () => void }> = ({ onClose, onSaved }) => {
const [form, setForm] = useState({
name: '', description: '', category: 'developer',
server_type: 'stdio', runtime: 'node', command: '', args: '',
package_name: '', package_registry: 'npm', package_repo_url: '',
transport_url: '', builtin_name: '',
author_name: '', license: 'MIT', tools_count: 0,
install_instructions: '',
});
const [saving, setSaving] = useState(false);
const handleSubmit = async () => {
if (!form.name) { alert('请输入工具名称'); return; }
if (form.server_type === 'stdio' && !form.command) { alert('STDIO 类型需要启动命令'); return; }
if (form.server_type === 'builtin' && !form.builtin_name) { alert('内置类型需要扩展名称'); return; }
if ((form.server_type === 'sse' || form.server_type === 'streamable_http') && !form.transport_url) { alert('SSE/HTTP 类型需要传输 URL'); return; }
setSaving(true);
try {
const payload: Record<string, any> = { ...form };
if (form.args) {
try { payload.args = JSON.parse(form.args); } catch { payload.args = form.args.split(' '); }
} else {
delete payload.args;
}
if (!form.tools_count) delete payload.tools_count;
if (!form.transport_url) delete payload.transport_url;
if (!form.builtin_name) delete payload.builtin_name;
const res = await adminAPI.createMcpTool(payload);
if (res?.code === 0) {
alert(`创建成功!安全状态: ${res.data?.security_status || 'pending'}`);
onSaved();
} else {
alert(res?.message || '创建失败');
}
} catch (e: any) {
alert('创建失败: ' + (e.message || '未知错误'));
}
setSaving(false);
};
return (
<div style={modalOverlayStyle}>
<div style={modalContentStyle}>
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20, color: 'var(--color-text-primary)' }}>创建 MCP 工具</h2>
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
<FormField label="工具名称 *">
<input style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="如: Filesystem MCP" />
</FormField>
<FormField label="描述">
<textarea style={{ ...inputStyle, minHeight: 60 }} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="分类">
<select style={inputStyle} value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{Object.entries(MCP_CATEGORY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</FormField>
<FormField label="服务器类型">
<select style={inputStyle} value={form.server_type} onChange={(e) => setForm({ ...form, server_type: e.target.value })}>
{Object.entries(SERVER_TYPE_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</FormField>
</div>
{form.server_type === 'stdio' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="运行时">
<select style={inputStyle} value={form.runtime} onChange={(e) => setForm({ ...form, runtime: e.target.value })}>
<option value="node">Node.js</option><option value="python">Python</option>
<option value="go">Go</option><option value="rust">Rust</option>
<option value="binary">二进制</option>
</select>
</FormField>
<FormField label="启动命令 *">
<input style={inputStyle} value={form.command} onChange={(e) => setForm({ ...form, command: e.target.value })} placeholder="如: npx" />
</FormField>
</div>
<FormField label="参数 (JSON 数组或空格分隔)">
<input style={inputStyle} value={form.args} onChange={(e) => setForm({ ...form, args: e.target.value })} placeholder='如: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]' />
</FormField>
</>
)}
{form.server_type === 'builtin' && (
<FormField label="内置扩展名 *">
<input style={inputStyle} value={form.builtin_name} onChange={(e) => setForm({ ...form, builtin_name: e.target.value })} placeholder="如: developer" />
</FormField>
)}
{(form.server_type === 'sse' || form.server_type === 'streamable_http') && (
<FormField label="传输 URL *">
<input style={inputStyle} value={form.transport_url} onChange={(e) => setForm({ ...form, transport_url: e.target.value })} placeholder="https://..." />
</FormField>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="包名">
<input style={inputStyle} value={form.package_name} onChange={(e) => setForm({ ...form, package_name: e.target.value })} placeholder="@modelcontextprotocol/server-filesystem" />
</FormField>
<FormField label="包源">
<select style={inputStyle} value={form.package_registry} onChange={(e) => setForm({ ...form, package_registry: e.target.value })}>
<option value="npm">npm</option><option value="pypi">pypi</option>
<option value="github">GitHub</option><option value="custom">自定义</option>
</select>
</FormField>
</div>
<FormField label="仓库 URL">
<input style={inputStyle} value={form.package_repo_url} onChange={(e) => setForm({ ...form, package_repo_url: e.target.value })} placeholder="https://github.com/..." />
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<FormField label="作者">
<input style={inputStyle} value={form.author_name} onChange={(e) => setForm({ ...form, author_name: e.target.value })} />
</FormField>
<FormField label="许可证">
<input style={inputStyle} value={form.license} onChange={(e) => setForm({ ...form, license: e.target.value })} />
</FormField>
</div>
<FormField label="工具数量">
<input type="number" style={inputStyle} value={form.tools_count} onChange={(e) => setForm({ ...form, tools_count: parseInt(e.target.value) || 0 })} />
</FormField>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose} style={btnSecondaryStyle}>取消</button>
<button onClick={handleSubmit} disabled={saving} style={btnPrimaryStyle}>{saving ? '创建中...' : '创建'}</button>
</div>
</div>
</div>
);
};
const McpToolDetailModal: React.FC<{ tool: McpToolDetail; onClose: () => void; onCopyLink: () => void }> = ({ tool, onClose, onCopyLink }) => {
const sec = SECURITY_LABELS[tool.security_status] || { label: tool.security_status, color: '#8b949e' };
return (
<div style={modalOverlayStyle}>
<div style={{ ...modalContentStyle, maxWidth: 700 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text-primary)' }}>{tool.app.name}</h2>
<button onClick={onClose} style={{ ...btnSmallStyle, fontSize: 18 }}>x</button>
</div>
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
{/* Badges */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<span style={{ ...badgeStyle, background: `${sec.color}22`, color: sec.color }}>{sec.label}</span>
<span style={{ ...badgeStyle, background: '#58a6ff22', color: '#58a6ff' }}>{SERVER_TYPE_LABELS[tool.server_type] || tool.server_type}</span>
{tool.is_managed && <span style={{ ...badgeStyle, background: '#3fb95022', color: '#3fb950' }}>平台托管</span>}
<span style={{ ...badgeStyle, background: '#8b949e22', color: '#8b949e' }}>{tool.tools_count} 个工具</span>
<span style={{ ...badgeStyle, background: '#bc8cff22', color: '#bc8cff' }}>{tool.license}</span>
</div>
{/* Description */}
{tool.app.description && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>描述</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{tool.app.description}</div>
</div>
)}
{/* Deep Link */}
{tool.deep_link && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>Goose Deep Link</div>
<div style={{ display: 'flex', gap: 8 }}>
<input readOnly value={tool.deep_link} style={{ ...inputStyle, flex: 1, fontFamily: 'monospace', fontSize: 12 }} />
<button onClick={onCopyLink} style={btnPrimaryStyle}>复制</button>
</div>
</div>
)}
{/* Config */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
{tool.command && <DetailItem label="启动命令" value={tool.command} />}
{tool.runtime && <DetailItem label="运行时" value={tool.runtime} />}
{tool.transport_url && <DetailItem label="传输 URL" value={tool.transport_url} />}
{tool.builtin_name && <DetailItem label="内置扩展" value={tool.builtin_name} />}
{tool.package_name && <DetailItem label="包名" value={tool.package_name} />}
<DetailItem label="版本" value={tool.package_version} />
{tool.author_name && <DetailItem label="作者" value={tool.author_name} />}
{tool.managed_url && <DetailItem label="托管 URL" value={tool.managed_url} />}
</div>
{/* Args */}
{tool.args && tool.args.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>启动参数</div>
<code style={{ ...codeBlockStyle, display: 'block' }}>{JSON.stringify(tool.args, null, 2)}</code>
</div>
)}
{/* Tools List */}
{tool.tools_list && tool.tools_list.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>暴露的工具 ({tool.tools_list.length})</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{tool.tools_list.map((t, i) => (
<span key={i} style={{ ...badgeStyle, background: '#30363d', color: '#8b949e', fontFamily: 'monospace' }}>{t}</span>
))}
</div>
</div>
)}
{/* Permissions */}
{tool.permissions && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>权限声明</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(tool.permissions).map(([k, v]) => (
<span key={k} style={{ ...badgeStyle, background: v ? '#f8514922' : '#30363d', color: v ? '#f85149' : '#8b949e' }}>
{k}: {v ? '是' : '否'}
</span>
))}
</div>
</div>
)}
{/* Security Report */}
{tool.security_report && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>安全扫描报告</div>
<div style={{ ...codeBlockStyle, whiteSpace: 'pre-wrap' }}>{JSON.stringify(tool.security_report, null, 2)}</div>
</div>
)}
{/* Install Instructions */}
{tool.install_instructions && (
<div style={{ marginBottom: 16 }}>
<div style={labelStyle}>安装说明</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap' }}>{tool.install_instructions}</div>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end', marginTop: 20 }}>
<button onClick={onClose} style={btnSecondaryStyle}>关闭</button>
</div>
</div>
</div>
);
};
// ============================================================
// Helper Components & Styles
// ============================================================
const FormField: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'block', fontSize: 13, color: 'var(--color-text-tertiary)', marginBottom: 4 }}>{label}</label>
{children}
</div>
);
const DetailItem: React.FC<{ label: string; value: string }> = ({ label, value }) => (
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>{value}</div>
</div>
);
// Style constants
const selectStyle: React.CSSProperties = {
padding: '8px 12px',
background: 'var(--color-bg-secondary, #161b22)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
color: 'var(--color-text-primary, #e6edf3)',
fontSize: 14,
cursor: 'pointer',
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 12px',
background: 'var(--color-bg-tertiary, #21262d)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
color: 'var(--color-text-primary, #e6edf3)',
fontSize: 14,
boxSizing: 'border-box',
};
const btnPrimaryStyle: React.CSSProperties = {
padding: '8px 16px',
background: 'var(--color-primary, #58a6ff)',
color: '#fff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
fontWeight: 500,
};
const btnSecondaryStyle: React.CSSProperties = {
padding: '8px 16px',
background: 'var(--color-bg-tertiary, #21262d)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 8,
cursor: 'pointer',
fontSize: 14,
};
const btnSmallStyle: React.CSSProperties = {
padding: '4px 10px',
background: 'var(--color-bg-tertiary, #21262d)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 6,
cursor: 'pointer',
fontSize: 12,
whiteSpace: 'nowrap',
};
const pageBtnStyle: React.CSSProperties = {
padding: '6px 14px',
background: 'var(--color-bg-secondary, #161b22)',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border, #30363d)',
borderRadius: 6,
cursor: 'pointer',
fontSize: 14,
};
const badgeStyle: React.CSSProperties = {
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
fontWeight: 500,
display: 'inline-block',
};
const modalOverlayStyle: React.CSSProperties = {
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
};
const modalContentStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary, #161b22)',
borderRadius: 16,
padding: 24,
width: '90%',
maxWidth: 600,
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--color-border, #30363d)',
};
const labelStyle: React.CSSProperties = {
fontSize: 12,
color: 'var(--color-text-tertiary)',
marginBottom: 4,
fontWeight: 500,
};
const codeBlockStyle: React.CSSProperties = {
background: 'var(--color-bg-tertiary, #21262d)',
borderRadius: 8,
padding: 12,
fontSize: 12,
color: 'var(--color-text-secondary)',
fontFamily: 'monospace',
overflow: 'auto',
};
export default McpMarketTab;
+680
View File
@@ -0,0 +1,680 @@
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 levelColors: Record<number, { bg: string; accent: string; label: string }> = {
0: { bg: 'var(--color-bg-tertiary)', accent: 'var(--color-text-secondary)', label: '免费版' },
1: { bg: 'rgba(99,102,241,0.1)', accent: '#818cf8', label: '基础版' },
2: { bg: 'rgba(34,197,94,0.1)', accent: '#22c55e', label: '专业版' },
3: { bg: 'rgba(245,158,11,0.1)', accent: '#f59e0b', label: '企业版' },
};
const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
<button
onClick={() => onChange(!checked)}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: checked ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background 0.2s', flexShrink: 0,
}}
>
<span style={{
position: 'absolute', top: 3, left: checked ? 22 : 3,
width: 18, height: 18, borderRadius: '50%', background: '#fff',
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
);
interface Plan {
id: string; name: string; level: number; duration_type: string; price: number;
daily_free_tokens: number; features: string[]; free_app_ids: string[]; free_model_ids: string[];
is_active: boolean;
max_agents: number; max_workflows: number; max_knowledge_bases: number; max_storage_mb: number;
api_calls_per_day: number; api_calls_per_min: number;
can_publish_apps: boolean; can_create_api_keys: boolean; can_use_workflow: boolean;
can_use_mcp: boolean; can_use_code_exec: boolean; can_use_privacy_mode: boolean; can_use_dag: boolean;
allowed_model_tiers: string[]; permissions: Record<string, any>;
}
interface UserMembership {
user_id: string; username: string; email: string;
membership_plan_id: string; membership_plan_name: string; membership_level: number;
membership_expire_at: string; auto_renew: boolean; member_status: string;
}
interface GlobalConfig {
id: string; key: string; value: any; type: string; description: string;
}
const Membership: React.FC = () => {
const [activeTab, setActiveTab] = useState<'plans' | 'benefits' | 'users' | 'configs' | 'stats'>('plans');
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(false);
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
const [showModal, setShowModal] = useState(false);
const [showAssignModal, setShowAssignModal] = useState(false);
const [assignUserId, setAssignUserId] = useState('');
const [assignPlanId, setAssignPlanId] = useState('');
const [assignDays, setAssignDays] = useState(30);
// User memberships
const [userMemberships, setUserMemberships] = useState<UserMembership[]>([]);
const [userTotal, setUserTotal] = useState(0);
const [userPage, setUserPage] = useState(1);
// Configs
const [configs, setConfigs] = useState<GlobalConfig[]>([]);
// Stats
const [stats, setStats] = useState<any>(null);
const fetchPlans = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getPlans();
setPlans(Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []));
} catch (e) { console.error('fetchPlans:', e); }
setLoading(false);
}, []);
const fetchUserMemberships = useCallback(async () => {
try {
const res = await adminAPI.getUserMemberships(userPage);
const d = res.data || res;
setUserMemberships(d?.items || []);
setUserTotal(d?.total || 0);
} catch (e) { console.error('fetchUserMemberships:', e); }
}, [userPage]);
const fetchConfigs = useCallback(async () => {
try {
const res = await adminAPI.getMembershipConfigs();
setConfigs(Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []));
} catch (e) { console.error('fetchConfigs:', e); }
}, []);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getMembershipStats();
setStats(res.data || res);
} catch (e) { console.error('fetchStats:', e); }
}, []);
useEffect(() => {
fetchPlans();
}, [fetchPlans]);
useEffect(() => {
if (activeTab === 'users') fetchUserMemberships();
if (activeTab === 'configs') fetchConfigs();
if (activeTab === 'stats') fetchStats();
}, [activeTab, fetchUserMemberships, fetchConfigs, fetchStats]);
// ===================== Plan Edit Form =====================
const emptyForm: Plan = {
id: '', name: '', level: 0, duration_type: 'month', price: 0, daily_free_tokens: 0,
features: [], free_app_ids: [], free_model_ids: [], is_active: true,
max_agents: 5, max_workflows: 3, max_knowledge_bases: 2, max_storage_mb: 1024,
api_calls_per_day: 100, api_calls_per_min: 10,
can_publish_apps: false, can_create_api_keys: false, can_use_workflow: false,
can_use_mcp: false, can_use_code_exec: false, can_use_privacy_mode: false, can_use_dag: false,
allowed_model_tiers: ['free'], permissions: {},
};
const [form, setForm] = useState<Plan>(emptyForm);
const [newFeature, setNewFeature] = useState('');
const handleEdit = (plan: Plan) => {
setEditingPlan(plan);
setForm({ ...plan });
setShowModal(true);
};
const handleCreate = () => {
setEditingPlan(null);
setForm({ ...emptyForm });
setShowModal(true);
};
const handleSave = async () => {
try {
const params = new URLSearchParams();
Object.keys(form).forEach(k => {
if (k === 'id' || k === 'created_at' || k === 'updated_at') return;
const val = (form as any)[k];
if (val === null || val === undefined) return;
if (typeof val === 'boolean') params.append(k, String(val));
else if (Array.isArray(val)) params.append(k, JSON.stringify(val));
else if (typeof val === 'object') params.append(k, JSON.stringify(val));
else params.append(k, String(val));
});
if (editingPlan) {
await adminAPI.updatePlan(editingPlan.id, `?${params.toString()}`);
} else {
await adminAPI.addPlan(`?${params.toString()}`);
}
setShowModal(false);
fetchPlans();
} catch (e) { console.error('handleSave:', e); alert('保存失败: ' + (e as any)?.message); }
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此会员计划?')) return;
try {
await adminAPI.deletePlan(id);
fetchPlans();
} catch (e) { alert('删除失败: ' + (e as any)?.message); }
};
const addFeature = () => {
if (newFeature.trim()) {
setForm({ ...form, features: [...form.features, newFeature.trim()] });
setNewFeature('');
}
};
const toggleModelTier = (tier: string) => {
setForm(prev => ({
...prev,
allowed_model_tiers: prev.allowed_model_tiers.includes(tier)
? prev.allowed_model_tiers.filter(t => t !== tier)
: [...prev.allowed_model_tiers, tier],
}));
};
// ===================== Assign Plan =====================
const handleAssignPlan = async () => {
if (!assignUserId || !assignPlanId) { alert('请填写用户ID和选择计划'); return; }
try {
await adminAPI.assignPlan(assignUserId, assignPlanId, assignDays);
setShowAssignModal(false);
setAssignUserId(''); setAssignPlanId(''); setAssignDays(30);
fetchUserMemberships();
} catch (e) { alert('分配失败: ' + (e as any)?.message); }
};
const handleRevoke = async (userId: string, username: string) => {
if (!confirm(`确定撤销 ${username} 的会员?`)) return;
try {
await adminAPI.revokeMembership(userId);
fetchUserMemberships();
} catch (e) { alert('撤销失败: ' + (e as any)?.message); }
};
const handleUpdateConfig = async (key: string, value: any) => {
try {
const params = new URLSearchParams();
params.append('value', String(value));
await adminAPI.updateMembershipConfig(key, `?${params.toString()}`);
fetchConfigs();
} catch (e) { alert('更新失败: ' + (e as any)?.message); }
};
// ===================== Render =====================
const tabs = [
{ key: 'plans', label: '会员计划', icon: <><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"/></> },
{ key: 'benefits', label: '权益对比', icon: <><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="3" y1="9" x2="21" y2="9"/></> },
{ key: 'users', label: '用户会员', icon: <><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"/></> },
{ key: 'configs', label: '全局配置', icon: <><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 .33-1.82 1.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 1.82.33H9a1.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-.33 1.82V9a1.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"/></> },
{ key: 'stats', label: '统计概览', icon: <><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></> },
];
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
const sectionStyle: React.CSSProperties = {
padding: 'var(--space-lg)', borderBottom: '1px solid var(--color-border)',
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
<h1 className="admin-page-title">会员订阅体系管理</h1>
{activeTab === 'plans' && (
<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>
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key as any)}
style={{
padding: '10px 20px', border: 'none', cursor: 'pointer',
background: activeTab === tab.key ? 'var(--color-bg-secondary)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
fontSize: 'var(--text-sm)', fontWeight: activeTab === tab.key ? 600 : 400,
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6,
}}
>
<SVGIcon size={14}>{tab.icon}</SVGIcon>
{tab.label}
</button>
))}
</div>
{/* ===================== Plans Tab ===================== */}
{activeTab === 'plans' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))', gap: 'var(--space-xl)' }}>
{plans.map((plan) => {
const colors = levelColors[plan.level] || levelColors[0];
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: 4 }}>
{colors.label} · Level {plan.level}
</span>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginTop: 4 }}>{plan.name}</h3>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<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>
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleDelete(plan.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>
{/* Price */}
<div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-lg)' }}>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-sm)', 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-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>每日Token</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{plan.daily_free_tokens?.toLocaleString()}</div>
</div>
</div>
{/* Quotas */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6, marginBottom: 'var(--space-md)' }}>
{[
{ label: 'Agent', value: plan.max_agents },
{ label: '工作流', value: plan.max_workflows },
{ label: '知识库', value: plan.max_knowledge_bases },
{ label: '存储(MB)', value: plan.max_storage_mb },
{ label: 'API/日', value: plan.api_calls_per_day },
{ label: 'API/分', value: plan.api_calls_per_min },
].map((item, i) => (
<div key={i} style={{ padding: '6px 8px', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
<div style={{ fontSize: 'var(--text-2xs)', color: 'var(--color-text-tertiary)' }}>{item.label}</div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>{item.value >= 999 ? '∞' : item.value?.toLocaleString()}</div>
</div>
))}
</div>
{/* Permissions */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--space-md)' }}>
{[
{ key: 'can_publish_apps', label: '发布应用' },
{ key: 'can_create_api_keys', label: 'API密钥' },
{ key: 'can_use_workflow', label: '工作流' },
{ key: 'can_use_mcp', label: 'MCP' },
{ key: 'can_use_code_exec', label: '代码执行' },
{ key: 'can_use_privacy_mode', label: '隐私模式' },
{ key: 'can_use_dag', label: 'DAG' },
].map(perm => {
const enabled = (plan as any)[perm.key];
return enabled ? (
<span key={perm.key} className="admin-badge" style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e', fontSize: 'var(--text-2xs)' }}>
✓ {perm.label}
</span>
) : (
<span key={perm.key} className="admin-badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-2xs)' }}>
✗ {perm.label}
</span>
);
})}
</div>
{/* Features */}
{plan.features && plan.features.length > 0 && (
<div>
<div style={{ fontSize: 'var(--text-xs)', fontWeight: 600, marginBottom: 4, color: 'var(--color-text-secondary)' }}>会员权益</div>
<ul style={{ listStyle: 'none', padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
{plan.features.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)' }}>
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
{f}
</li>
))}
</ul>
</div>
)}
</div>
);
})}
</div>
)}
{/* ===================== Benefits Comparison Tab ===================== */}
{activeTab === 'benefits' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th style={{ minWidth: 160 }}>权益项</th>
{plans.map(p => {
const c = levelColors[p.level] || levelColors[0];
return <th key={p.id} style={{ textAlign: 'center', minWidth: 120 }}>
<span className="admin-badge" style={{ background: c.bg, color: c.accent }}>{p.name}</span>
</th>;
})}
</tr>
</thead>
<tbody>
{[
{ key: 'price', label: '月价格(元)', type: 'number' },
{ key: 'daily_free_tokens', label: '每日免费Token', type: 'number' },
{ key: 'max_agents', label: '最大Agent数', type: 'number' },
{ key: 'max_workflows', label: '最大工作流数', type: 'number' },
{ key: 'max_knowledge_bases', label: '最大知识库数', type: 'number' },
{ key: 'max_storage_mb', label: '存储配额(MB)', type: 'number' },
{ key: 'api_calls_per_day', label: '每日API限额', type: 'number' },
{ key: 'api_calls_per_min', label: '每分钟API限额', type: 'number' },
{ key: 'can_publish_apps', label: '发布应用到市场', type: 'boolean' },
{ key: 'can_create_api_keys', label: '创建开发者API密钥', type: 'boolean' },
{ key: 'can_use_workflow', label: '工作流编排', type: 'boolean' },
{ key: 'can_use_mcp', label: 'MCP工具', type: 'boolean' },
{ key: 'can_use_code_exec', label: '代码执行', type: 'boolean' },
{ key: 'can_use_privacy_mode', label: '隐私模式', type: 'boolean' },
{ key: 'can_use_dag', label: 'DAG多Agent', type: 'boolean' },
{ key: 'allowed_model_tiers', label: '可用模型层级', type: 'list' },
].map(row => (
<tr key={row.key}>
<td style={{ fontWeight: 600 }}>{row.label}</td>
{plans.map(p => {
const val = (p as any)[row.key];
let display;
if (row.type === 'boolean') {
display = val ? <span style={{ color: '#22c55e' }}>✓</span> : <span style={{ color: 'var(--color-text-tertiary)' }}>✗</span>;
} else if (row.type === 'number') {
display = val >= 999 ? '不限' : (val?.toLocaleString() || '0');
} else if (row.type === 'list') {
display = Array.isArray(val) ? val.join(', ') : '-';
} else {
display = String(val ?? '-');
}
return <td key={p.id} style={{ textAlign: 'center', fontFamily: row.type === 'number' ? 'var(--font-mono)' : 'inherit' }}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Users Tab ===================== */}
{activeTab === 'users' && (
<div>
<div style={{ marginBottom: 'var(--space-lg)', display: 'flex', justifyContent: 'flex-end' }}>
<button className="admin-btn admin-btn-primary" onClick={() => setShowAssignModal(true)}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
分配会员
</button>
</div>
<div className="admin-card" 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>
{userMemberships.length === 0 ? (
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : userMemberships.map(u => (
<tr key={u.user_id}>
<td style={{ fontWeight: 600 }}>{u.username}</td>
<td>{u.email || '-'}</td>
<td>{u.membership_plan_name}</td>
<td>Level {u.membership_level}</td>
<td>{u.membership_expire_at ? new Date(u.membership_expire_at).toLocaleString('zh-CN') : '-'}</td>
<td>{u.auto_renew ? '✓' : '✗'}</td>
<td>
<span className={`admin-badge ${u.member_status === 'active' ? 'badge-success' : u.member_status === 'expired' ? 'badge-danger' : 'badge-neutral'}`}>
{u.member_status === 'active' ? '活跃' : u.member_status === 'expired' ? '已过期' : '免费'}
</span>
</td>
<td>
{u.membership_level > 0 && (
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleRevoke(u.user_id, u.username)}>
撤销
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{userTotal > 20 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 'var(--space-md)', marginTop: 'var(--space-lg)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={userPage <= 1} onClick={() => setUserPage(p => p - 1)}>上一页</button>
<span style={{ lineHeight: '32px', fontSize: 'var(--text-sm)' }}>第 {userPage} 页</span>
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={userPage * 20 >= userTotal} onClick={() => setUserPage(p => p + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* ===================== Configs Tab ===================== */}
{activeTab === 'configs' && (
<div className="admin-card">
<div className="admin-card-header"><h3 className="admin-card-title">全局会员配置</h3></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
{configs.map(cfg => (
<div key={cfg.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>{cfg.description}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{cfg.key}</div>
</div>
{cfg.type === 'boolean' ? (
<Toggle checked={!!cfg.value} onChange={(v) => handleUpdateConfig(cfg.key, v)} />
) : cfg.type === 'number' ? (
<input type="number" value={cfg.value} onChange={e => handleUpdateConfig(cfg.key, e.target.value)} style={{ ...inputStyle, width: 80 }} />
) : (
<input value={cfg.value} onChange={e => handleUpdateConfig(cfg.key, e.target.value)} style={{ ...inputStyle, width: 200 }} />
)}
</div>
))}
</div>
</div>
)}
{/* ===================== Stats Tab ===================== */}
{activeTab === 'stats' && stats && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-xl)' }}>
{[
{ label: '总用户数', value: stats.total_users, color: '#818cf8' },
{ label: '活跃会员', value: stats.active_members, color: '#22c55e' },
{ label: '已过期', value: stats.expired_members, color: '#ef4444' },
{ label: '免费用户', value: stats.free_users, color: 'var(--color-text-secondary)' },
{ label: '自动续费', value: stats.auto_renew_count, color: '#f59e0b' },
{ label: '今日新增', value: stats.today_new_subs, color: '#06b6d4' },
].map((s, i) => (
<div key={i} className="admin-card" style={{ marginBottom: 0, textAlign: 'center' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color }}>{s.value?.toLocaleString()}</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{s.label}</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-header"><h3 className="admin-card-title">各计划订阅统计</h3></div>
<table className="admin-table">
<thead><tr><th>计划名称</th><th>等级</th><th>用户数</th><th>状态</th></tr></thead>
<tbody>
{stats.plan_stats?.map((ps: any) => (
<tr key={ps.plan_id}>
<td style={{ fontWeight: 600 }}>{ps.plan_name}</td>
<td>Level {ps.level}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{ps.user_count}</td>
<td>{ps.is_active ? <span className="admin-badge badge-success">启用</span> : <span className="admin-badge badge-neutral">停用</span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* ===================== Edit/Create Plan Modal ===================== */}
{showModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', zIndex: 999,
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: '4vh', animation: 'fadeIn 0.2s',
}} onClick={() => setShowModal(false)}>
<div style={{
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)',
width: 720, maxHeight: '90vh', overflow: 'auto',
boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', borderBottom: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700 }}>
{editingPlan ? `编辑 ${editingPlan.name}` : '新建会员计划'}
</h3>
</div>
{/* Basic Info */}
<div style={sectionStyle}>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>基础信息</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>计划名称</label><input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></div>
<div><label style={labelStyle}>等级 (0-3)</label><input type="number" style={inputStyle} value={form.level} onChange={e => setForm({ ...form, level: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>周期</label><select style={inputStyle} value={form.duration_type} onChange={e => setForm({ ...form, duration_type: e.target.value })}><option value="month">月</option><option value="quarter">季</option><option value="year">年</option></select></div>
<div><label style={labelStyle}>价格(元)</label><input type="number" style={inputStyle} value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>每日免费Token</label><input type="number" style={inputStyle} value={form.daily_free_tokens} onChange={e => setForm({ ...form, daily_free_tokens: Number(e.target.value) })} /></div>
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<Toggle checked={form.is_active} onChange={v => setForm({ ...form, is_active: v })} />
<span style={{ fontSize: 'var(--text-sm)' }}>启用</span>
</label>
</div>
</div>
</div>
{/* Quotas */}
<div style={sectionStyle}>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>配额限制</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>最大Agent数</label><input type="number" style={inputStyle} value={form.max_agents} onChange={e => setForm({ ...form, max_agents: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>最大工作流数</label><input type="number" style={inputStyle} value={form.max_workflows} onChange={e => setForm({ ...form, max_workflows: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>最大知识库数</label><input type="number" style={inputStyle} value={form.max_knowledge_bases} onChange={e => setForm({ ...form, max_knowledge_bases: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>存储配额(MB)</label><input type="number" style={inputStyle} value={form.max_storage_mb} onChange={e => setForm({ ...form, max_storage_mb: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>每日API限额</label><input type="number" style={inputStyle} value={form.api_calls_per_day} onChange={e => setForm({ ...form, api_calls_per_day: Number(e.target.value) })} /></div>
<div><label style={labelStyle}>每分钟API限额</label><input type="number" style={inputStyle} value={form.api_calls_per_min} onChange={e => setForm({ ...form, api_calls_per_min: Number(e.target.value) })} /></div>
</div>
</div>
{/* Permissions */}
<div style={sectionStyle}>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>功能权限</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
{[
{ key: 'can_publish_apps', label: '发布应用到市场' },
{ key: 'can_create_api_keys', label: '创建开发者API密钥' },
{ key: 'can_use_workflow', label: '使用工作流编排' },
{ key: 'can_use_mcp', label: '使用MCP工具' },
{ key: 'can_use_code_exec', label: '使用代码执行' },
{ key: 'can_use_privacy_mode', label: '使用隐私模式' },
{ key: 'can_use_dag', label: '使用DAG多Agent' },
].map(perm => (
<label key={perm.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', cursor: 'pointer' }}>
<span style={{ fontSize: 'var(--text-sm)' }}>{perm.label}</span>
<Toggle checked={(form as any)[perm.key]} onChange={v => setForm({ ...form, [perm.key]: v })} />
</label>
))}
</div>
<div style={{ marginTop: 'var(--space-md)' }}>
<label style={labelStyle}>可用模型层级</label>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
{['free', 'standard', 'premium'].map(tier => (
<label key={tier} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', padding: '4px 12px', background: form.allowed_model_tiers.includes(tier) ? 'var(--color-accent-subtle)' : 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-full)', fontSize: 'var(--text-sm)' }}>
<input type="checkbox" checked={form.allowed_model_tiers.includes(tier)} onChange={() => toggleModelTier(tier)} style={{ accentColor: 'var(--color-accent)' }} />
{tier}
</label>
))}
</div>
</div>
</div>
{/* Features */}
<div style={sectionStyle}>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>会员权益标签</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--space-sm)' }}>
{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-subtle)', color: 'var(--color-accent)', fontSize: 'var(--text-xs)' }}>
{f}
<button onClick={() => setForm({ ...form, features: form.features.filter((_, j) => j !== i) })} style={{ cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, border: 'none', background: 'none' }}>×</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<input style={{ ...inputStyle, flex: 1 }} value={newFeature} onChange={e => setNewFeature(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addFeature(); } }} placeholder="输入权益名称,回车添加" />
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={addFeature}>添加</button>
</div>
</div>
{/* Actions */}
<div style={{ padding: 'var(--space-lg) var(--space-2xl)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)' }}>
<button className="admin-btn admin-btn-outline" onClick={() => setShowModal(false)}>取消</button>
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
</div>
</div>
</div>
)}
{/* ===================== Assign Plan Modal ===================== */}
{showAssignModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.5)', zIndex: 999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowAssignModal(false)}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 480, padding: 'var(--space-2xl)' }} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-xl)' }}>分配会员计划</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>用户ID</label><input style={inputStyle} value={assignUserId} onChange={e => setAssignUserId(e.target.value)} placeholder="输入用户ID" /></div>
<div><label style={labelStyle}>选择计划</label>
<select style={inputStyle} value={assignPlanId} onChange={e => setAssignPlanId(e.target.value)}>
<option value="">请选择...</option>
{plans.filter(p => p.is_active).map(p => <option key={p.id} value={p.id}>{p.name} (Level {p.level})</option>)}
</select>
</div>
<div><label style={labelStyle}>订阅时长(天)</label><input type="number" style={inputStyle} value={assignDays} onChange={e => setAssignDays(Number(e.target.value))} /></div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', marginTop: 'var(--space-xl)' }}>
<button className="admin-btn admin-btn-outline" onClick={() => setShowAssignModal(false)}>取消</button>
<button className="admin-btn admin-btn-primary" onClick={handleAssignPlan}>确认分配</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Membership;
+766
View File
@@ -0,0 +1,766 @@
import React, { useState, useEffect, useCallback, useMemo } 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();
};
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: '工具',
};
interface TestResult {
success: boolean;
message: string;
latency_ms?: number;
}
interface EditingModel {
id?: string;
name: string;
display_name: string;
provider_id: string;
api_key: string;
endpoint: string;
capabilities: string[];
timeout: number;
status: string;
price_per_million_tokens: number;
is_enabled: boolean;
}
// Stats panel
const ModelStatsPanel: React.FC<{ models: AIModel[]; providers: ModelProvider[] }> = ({ models, providers }) => {
const enabled = models.filter(m => m.isEnabled).length;
const defaults = models.filter(m => m.isDefault).length;
const totalCalls = models.reduce((s, m) => s + (m.totalCalls || 0), 0);
const paidModels = models.filter(m => m.status === 'paid').length;
const items = [
{ label: '供应商', value: providers.length, color: '#5B5FED', icon: '🔌' },
{ label: '模型总数', value: models.length, color: '#219B5E', icon: '🤖' },
{ label: '已启用', value: enabled, color: '#16A34A', icon: '✅' },
{ label: '总调用量', value: formatNumber(totalCalls), color: '#E8922C', icon: '📊' },
{ label: '默认模型', value: defaults, color: '#8B5CF6', icon: '⭐' },
{ label: '付费模型', value: paidModels, color: '#EC4899', icon: '💎' },
];
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
{items.map((item, i) => (
<div key={i} style={{
background: '#FFFFFF', borderRadius: 10, border: '1px solid rgba(0,0,0,0.08)',
padding: '14px 16px', display: 'flex', alignItems: 'center', gap: 12,
}}>
<span style={{ fontSize: 20 }}>{item.icon}</span>
<div>
<div style={{ fontSize: 11, color: '#6B7280' }}>{item.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: item.color, fontFamily: 'var(--font-mono)' }}>
{item.value}
</div>
</div>
</div>
))}
</div>
);
};
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>('');
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 [modelSearch, setModelSearch] = useState('');
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
const [testingIds, setTestingIds] = useState<Set<string>>(new Set());
const [testingAll, setTestingAll] = useState(false);
const [expandedTestResult, setExpandedTestResult] = useState<string | null>(null);
const [showModelEditor, setShowModelEditor] = useState(false);
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
const [modelSaving, setModelSaving] = 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 || []));
const modelData = modelsRes.data || modelsRes;
const rawModels = Array.isArray(modelData) ? modelData : (modelData?.items || []);
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
...m,
providerId: m.providerId || m.provider_id,
displayName: m.displayName || m.display_name || m.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) {
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
// Filtered models
const filteredModels = useMemo(() => {
if (!modelSearch.trim()) return models;
const q = modelSearch.toLowerCase();
return models.filter(m =>
m.name.toLowerCase().includes(q) ||
((m as any).display_name || '').toLowerCase().includes(q)
);
}, [models, modelSearch]);
// Test single model connection
const handleTestConnection = async (modelId: string) => {
setTestingIds(prev => new Set(prev).add(modelId));
try {
const result = await adminAPI.testModelConnection(modelId) as any;
const testResult: TestResult = result.success !== undefined ? result : (result.data || result as TestResult);
setTestResults(prev => ({ ...prev, [modelId]: testResult }));
} catch (err: any) {
setTestResults(prev => ({ ...prev, [modelId]: { success: false, message: err.message || '测试失败' } }));
} finally {
setTestingIds(prev => { const next = new Set(prev); next.delete(modelId); return next; });
}
};
// Test all models
const handleTestAll = async () => {
setTestingAll(true);
setTestResults({});
try {
const result = await adminAPI.testAllModels() as any;
const data = result.data || result;
const resultsMap: Record<string, TestResult> = {};
if (data.results) {
for (const r of data.results) {
resultsMap[r.model_id] = {
success: r.success,
message: r.message,
latency_ms: r.latency_ms,
};
}
}
setTestResults(resultsMap);
} catch (err: any) {
setError('批量测试失败: ' + (err.message || ''));
} finally {
setTestingAll(false);
}
};
// Get model type label
const getModelType = (caps: string[] | undefined): string => {
if (!caps || caps.length === 0) return 'text';
const lower = caps.map(c => c.toLowerCase());
if (lower.includes('image')) return 'image';
if (lower.includes('audio')) return 'audio';
if (lower.includes('video')) return 'video';
if (lower.includes('vision')) return 'vision';
return 'text';
};
const modelTypeLabels: Record<string, { label: string; color: string }> = {
text: { label: '文本', color: '#5B5FED' },
image: { label: '图像生成', color: '#D9383A' },
audio: { label: '音频', color: '#E8922C' },
video: { label: '视频', color: '#EC4899' },
vision: { label: '视觉理解', color: '#219B5E' },
};
const handleSetDefault = async (modelId: string) => {
try {
await adminAPI.setDefaultModel(modelId);
await loadData();
} catch (err: any) {
setError(err.message || '设置失败');
}
};
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);
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
await loadData();
} catch (err: any) {
setError(err.message || '更新供应商失败');
}
};
const handleDeleteProvider = async (id: string) => {
if (!confirm('确定删除该供应商?关联的模型也会受影响。')) return;
try {
await adminAPI.deleteProvider(id);
await loadData();
} catch (err: any) {
setError(err.message || '删除失败');
}
};
const handleFetchModels = async (providerId: string) => {
try {
const result = await adminAPI.fetchProviderModels(providerId) as any;
setFetchedModels(result.models || result.data?.models || []);
setFetchSource(result.source || result.data?.source || 'unknown');
setFetchMessage(result.message || result.data?.message || '');
setExpandedProvider(providerId);
setSelectedModelIds(new Set());
} catch (err: any) {
setError(err.message || '获取模型列表失败');
}
};
const handleAddModels = async (providerId: string) => {
if (selectedModelIds.size === 0) return;
try {
await adminAPI.addModels(providerId, Array.from(selectedModelIds));
setExpandedProvider(null);
setSelectedModelIds(new Set());
setFetchedModels([]);
await loadData();
} catch (err: any) {
setError(err.message || '添加模型失败');
}
};
const toggleModelSelection = (id: string) => {
const next = new Set(selectedModelIds);
next.has(id) ? next.delete(id) : next.add(id);
setSelectedModelIds(next);
};
const openModelEditor = (model?: AIModel, providerId?: string) => {
if (model) {
setEditingModel({
id: model.id, name: model.name,
display_name: (model as any).display_name || (model as any).displayName || model.name,
provider_id: (model as any).provider_id || model.providerId || providerId || '',
api_key: (model as any).api_key || model.apiKey || '',
endpoint: model.endpoint || '',
capabilities: model.capabilities || ['text'],
timeout: model.timeout || 60,
status: model.status || 'paid',
price_per_million_tokens: model.pricePerMillionTokens ?? 0,
is_enabled: model.isEnabled ?? true,
});
} else {
setEditingModel({
name: '', display_name: '', provider_id: providerId || '',
api_key: '', endpoint: '',
capabilities: ['text'], timeout: 60,
status: 'paid', price_per_million_tokens: 0, is_enabled: true,
});
}
setShowModelEditor(true);
};
const handleSaveModel = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingModel || !editingModel.name.trim()) return;
setModelSaving(true);
try {
if (editingModel.id) {
await adminAPI.updateAdminModel(editingModel.id, editingModel as any);
} else {
await adminAPI.createAdminModel(editingModel as any);
}
setShowModelEditor(false);
await loadData();
} catch (err: any) {
setError(err.message || '保存失败');
} finally {
setModelSaving(false);
}
};
const handleDeleteModel = async (modelId: string) => {
if (!confirm('确定删除该模型?')) return;
try {
await adminAPI.deleteAdminModel(modelId);
await loadData();
} catch (err: any) {
setError(err.message || '删除失败');
}
};
const toggleCapability = (cap: string) => {
if (!editingModel) return;
const caps = editingModel.capabilities || [];
editingModel.capabilities = caps.includes(cap) ? caps.filter(c => c !== cap) : [...caps, cap];
setEditingModel({ ...editingModel });
};
const getProviderModels = (providerId: string) =>
filteredModels.filter(m => ((m as any).provider_id || m.providerId) === providerId);
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div>
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 600 }}>大模型配置</h2>
<p style={{ margin: '4px 0 0', color: '#5A6480', fontSize: '13px' }}>
管理 AI 模型供应商、模型配置和连接测试
</p>
</div>
<button onClick={handleTestAll}
disabled={testingAll}
style={btnStyle(testingAll ? '#4B5563' : '#059669')}>
{testingAll ? '批量测试中...' : '⚡ 测试全部'}
</button>
<button onClick={() => { setShowAddProvider(true); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); }}
style={btnStyle('#5B5FED')}>
+ 添加供应商
</button>
</div>
{/* Stats */}
<ModelStatsPanel models={models} providers={providers} />
{error && (
<div style={{ ...alertStyle('#FEE2E2', '#DC2626'), marginBottom: '16px' }}>
{error} <button onClick={() => setError(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px' }}>×</button>
</div>
)}
{/* Search */}
<div style={{ marginBottom: 16, display: 'flex', gap: 12, alignItems: 'center' }}>
<div style={{ position: 'relative', flex: 1, maxWidth: 360 }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)' }}>
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
type="text" placeholder="搜索模型名称..."
value={modelSearch} onChange={e => setModelSearch(e.target.value)}
style={{ width: '100%', padding: '8px 12px 8px 36px', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', fontSize: '13px', boxSizing: 'border-box' }}
/>
</div>
{modelSearch && (
<span style={{ fontSize: 12, color: '#6B7280' }}>
找到 {filteredModels.length} 个模型
</span>
)}
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#6B7280' }}>加载中...</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{providers.map(provider => {
const provModels = getProviderModels(provider.id);
const isExpanded = expandedProvider === provider.id;
return (
<div key={provider.id} style={{ background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)', overflow: 'hidden' }}>
<div style={{ padding: '16px 20px', background: '#F9FAFB', borderBottom: '1px solid #E5E7EB', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontWeight: 600, fontSize: '15px' }}>{provider.name}</span>
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#EEF2FF', color: '#5B5FED', borderRadius: '4px' }}>
{provider.protocol}
</span>
{provModels.filter(m => m.isDefault).length > 0 && (
<span style={{ fontSize: '11px', padding: '2px 8px', background: '#DCFCE7', color: '#16A34A', borderRadius: '4px' }}>默认</span>
)}
<span style={{ fontSize: '11px', color: '#9CA3AF' }}>{provModels.length} 个模型</span>
</div>
<div style={{ fontSize: '12px', color: '#5A6480', marginTop: '4px' }}>
API: {(provider as any).api_base_url || provider.apiBaseUrl || '未配置'}
{(provider as any).api_key || provider.apiKey ? ' ✓ 已配置Key' : ' ✗ 未配置Key'}
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => handleFetchModels(provider.id)} style={btnStyle('#6B7280', 'small')}>获取模型</button>
<button onClick={() => { setEditingProvider(provider); setProviderForm({ name: provider.name, protocol: provider.protocol,
api_base_url: (provider as any).api_base_url || provider.apiBaseUrl || '',
api_key: (provider as any).api_key || provider.apiKey || '' }); setShowAddProvider(true); }} style={btnStyle('#6B7280', 'small')}>编辑</button>
<button onClick={() => handleDeleteProvider(provider.id)} style={btnStyle('#EF4444', 'small')}>删除</button>
<button onClick={() => openModelEditor(undefined, provider.id)} style={btnStyle('#5B5FED', 'small')}>+ 添加模型</button>
</div>
</div>
{isExpanded && (
<div style={{ padding: '16px 20px', background: '#FFFBEB', borderBottom: '1px solid #FDE68A' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontSize: '13px', color: '#92400E' }}>
{fetchMessage || `找到 ${fetchedModels.length} 个模型(来源: ${fetchSource})`}
</span>
{fetchedModels.length > 0 && (
<button onClick={() => handleAddModels(provider.id)} style={btnStyle('#5B5FED', 'small')} disabled={selectedModelIds.size === 0}>
添加选中 ({selectedModelIds.size})
</button>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{fetchedModels.map(m => (
<label key={m.id} style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', cursor: 'pointer',
padding: '4px 8px', background: selectedModelIds.has(m.id) ? 'rgba(108,99,255,0.15)' : 'rgba(17,22,39,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px' }}>
<input type="checkbox" checked={selectedModelIds.has(m.id)} onChange={() => toggleModelSelection(m.id)} />
{m.name || m.id}
</label>
))}
</div>
</div>
)}
<div style={{ padding: '0' }}>
{provModels.length === 0 ? (
<div style={{ padding: '24px', textAlign: 'center', color: '#6B7280', fontSize: '13px' }}>
暂无模型,点击"获取模型"或"添加模型"添加
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: '#F3F4F6' }}>
<th style={thStyle}>模型名称</th>
<th style={thStyle}>显示名称</th>
<th style={thStyle}>类型</th>
<th style={thStyle}>能力</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>价格</th>
<th style={thStyle}>调用量</th>
<th style={thStyle}>操作</th>
</tr>
</thead>
<tbody>
{provModels.map(model => (
<tr key={model.id} style={{ borderBottom: '1px solid #F3F4F6' }}>
<td style={tdStyle}>
<div style={{ fontWeight: 500 }}>{model.name}</div>
<div style={{ fontSize: 11, color: '#9CA3AF', fontFamily: 'var(--font-mono)' }}>{model.id?.slice(0, 8)}...</div>
</td>
<td style={tdStyle}>
<span style={{ fontSize: 13, color: '#374151' }}>
{(model as any).display_name || (model as any).displayName || '-'}
</span>
{model.isDefault && <span style={{ fontSize: '11px', color: '#16A34A', marginLeft: 6 }}>⭐ 默认</span>}
</td>
<td style={tdStyle}>
<span style={{
fontSize: '11px', padding: '2px 8px', borderRadius: '3px',
background: (modelTypeLabels[getModelType(model.capabilities)].color) + '22',
color: modelTypeLabels[getModelType(model.capabilities)].color,
fontWeight: 500,
}}>
{modelTypeLabels[getModelType(model.capabilities)].label}
</span>
</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(model.capabilities || []).map(cap => (
<span key={cap} style={{ fontSize: '11px', padding: '2px 6px', background: capabilityColors[cap] + '22', color: capabilityColors[cap], borderRadius: '3px' }}>
{capabilityLabels[cap] || cap}
</span>
))}
</div>
</td>
<td style={tdStyle}>
<span style={{ fontSize: '12px', padding: '2px 8px', background: model.isEnabled ? '#DCFCE7' : '#FEE2E2', color: model.isEnabled ? '#16A34A' : '#DC2626', borderRadius: '4px' }}>
{model.isEnabled ? '启用' : '禁用'}
</span>
</td>
<td style={tdStyle}>
<span style={{ fontSize: '12px', color: '#5A6480' }}>
¥{(model.pricePerMillionTokens || 0).toFixed(4)}/M
</span>
</td>
<td style={tdStyle}>
<div style={{ fontSize: '13px', fontWeight: 500, color: '#374151' }}>
{formatNumber(model.totalCalls || 0)}
</div>
<div style={{ fontSize: '10px', color: '#9CA3AF' }}>
{formatNumber(model.totalTokens || 0)} tokens
</div>
</td>
<td style={tdStyle}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<button onClick={() => handleTestConnection(model.id)} disabled={testingIds.has(model.id)}
style={{ ...btnStyle(testingIds.has(model.id) ? '#4B5563' : '#059669', 'small') }}>
{testingIds.has(model.id) ? '测试中...' : '测试'}
</button>
{testResults[model.id] && (
<span
onClick={() => setExpandedTestResult(expandedTestResult === model.id ? null : model.id)}
style={{
fontSize: '11px', padding: '4px 8px', cursor: 'pointer',
background: testResults[model.id].success ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: testResults[model.id].success ? '#22C55E' : '#EF4444',
borderRadius: '4px', border: `1px solid ${testResults[model.id].success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
whiteSpace: 'nowrap',
}}>
{testResults[model.id].success ? `✓ ${testResults[model.id].latency_ms || 0}ms` : '✗ 失败'}
</span>
)}
{!model.isDefault && model.isEnabled && (
<button onClick={() => handleSetDefault(model.id)} style={btnStyle('#5B5FED', 'small')}>设为默认</button>
)}
{expandedTestResult === model.id && testResults[model.id] && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setExpandedTestResult(null)}>
<div style={{
background: '#FFFFFF', borderRadius: 12, padding: 24,
maxWidth: 480, width: '90%',
border: `1px solid ${testResults[model.id].success ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'}`,
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: 16, color: '#E2E8F0' }}>连接测试详情</h3>
<button onClick={() => setExpandedTestResult(null)} style={{ background: 'none', border: 'none', color: '#6B7280', cursor: 'pointer', fontSize: 18 }}>×</button>
</div>
<div style={{ marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>模型</span>
<span style={{ fontSize: 14, color: '#E2E8F0', marginLeft: 8 }}>{model.name}</span>
</div>
<div style={{ marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>类型</span>
<span style={{ fontSize: 14, color: modelTypeLabels[getModelType(model.capabilities)].color, marginLeft: 8 }}>
{modelTypeLabels[getModelType(model.capabilities)].label}
</span>
</div>
<div style={{ marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>状态</span>
<span style={{
fontSize: 14, marginLeft: 8, fontWeight: 600,
color: testResults[model.id].success ? '#22C55E' : '#EF4444',
}}>
{testResults[model.id].success ? '✓ 成功' : '✗ 失败'}
</span>
</div>
{testResults[model.id].latency_ms !== undefined && testResults[model.id].latency_ms !== null && (
<div style={{ marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>延迟</span>
<span style={{ fontSize: 14, color: '#E2E8F0', marginLeft: 8, fontFamily: 'monospace' }}>
{testResults[model.id].latency_ms}ms
</span>
</div>
)}
<div style={{ marginBottom: 16 }}>
<span style={{ fontSize: 12, color: '#6B7280', display: 'block', marginBottom: 4 }}>详情</span>
<div style={{
fontSize: 13, color: '#9CA3AF', lineHeight: 1.6,
background: 'rgba(0,0,0,0.3)', padding: 12, borderRadius: 8,
wordBreak: 'break-word',
}}>
{testResults[model.id].message}
</div>
</div>
</div>
</div>
)}
<button onClick={() => openModelEditor(model as AIModel)} style={btnStyle('#6B7280', 'small')}>编辑</button>
<button onClick={() => handleDeleteModel(model.id)} style={btnStyle('#EF4444', 'small')}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
})}
{providers.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px', background: '#FFFFFF', borderRadius: '10px', border: '1px solid rgba(0,0,0,0.08)' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
<p style={{ color: '#5A6480', marginBottom: '16px' }}>暂无模型供应商,点击上方按钮添加</p>
</div>
)}
</div>
)}
{/* Provider Modal */}
{showAddProvider && (
<div style={modalOverlay} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
<div style={modalContent} onClick={e => e.stopPropagation()}>
<h3 style={{ marginTop: 0 }}>{editingProvider ? '编辑供应商' : '添加供应商'}</h3>
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
<div style={formGroup}>
<label style={labelStyle}>供应商名称 *</label>
<input value={providerForm.name} onChange={e => setProviderForm({ ...providerForm, name: e.target.value })} placeholder="如:SiliconFlow" style={inputStyle} required />
</div>
<div style={formGroup}>
<label style={labelStyle}>API 协议</label>
<select value={providerForm.protocol} onChange={e => setProviderForm({ ...providerForm, protocol: e.target.value })} style={inputStyle}>
{protocols.map(p => <option key={p.id} value={p.id}>{p.name} ({p.id})</option>)}
</select>
</div>
<div style={formGroup}>
<label style={labelStyle}>API Base URL</label>
<input value={providerForm.api_base_url} onChange={e => setProviderForm({ ...providerForm, api_base_url: e.target.value })} placeholder="https://api.siliconflow.cn" style={inputStyle} />
</div>
<div style={formGroup}>
<label style={labelStyle}>API Key</label>
<input type="password" value={providerForm.api_key} onChange={e => setProviderForm({ ...providerForm, api_key: e.target.value })} placeholder="sk-..." style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button type="button" onClick={() => { setShowAddProvider(false); setEditingProvider(null); }} style={btnStyle('#6B7280')}>取消</button>
<button type="submit" style={btnStyle('#5B5FED')}>{editingProvider ? '保存' : '添加'}</button>
</div>
</form>
</div>
</div>
)}
{/* Model Editor Modal */}
{showModelEditor && editingModel && (
<div style={modalOverlay} onClick={() => setShowModelEditor(false)}>
<div style={{ ...modalContent, maxWidth: '600px' }} onClick={e => e.stopPropagation()}>
<h3 style={{ marginTop: 0 }}>{editingModel.id ? '编辑模型' : '添加模型'}</h3>
<form onSubmit={handleSaveModel}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div style={formGroup}>
<label style={labelStyle}>模型名称 *</label>
<input value={editingModel.name} onChange={e => setEditingModel({ ...editingModel, name: e.target.value })} placeholder="如:DeepSeek-V4-Flash" style={inputStyle} required />
</div>
<div style={formGroup}>
<label style={labelStyle}>显示名称</label>
<input value={editingModel.display_name} onChange={e => setEditingModel({ ...editingModel, display_name: e.target.value })} placeholder="如:DeepSeek V4" style={inputStyle} />
</div>
</div>
<div style={formGroup}>
<label style={labelStyle}>供应商</label>
<select value={editingModel.provider_id} onChange={e => setEditingModel({ ...editingModel, provider_id: e.target.value })} style={inputStyle}>
{providers.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div style={formGroup}>
<label style={labelStyle}>API Key</label>
<input type="password" value={editingModel.api_key} onChange={e => setEditingModel({ ...editingModel, api_key: e.target.value })} placeholder="sk-... (留空则使用供应商的Key)" style={inputStyle} />
</div>
<div style={formGroup}>
<label style={labelStyle}>API Endpoint</label>
<input value={editingModel.endpoint} onChange={e => setEditingModel({ ...editingModel, endpoint: e.target.value })} placeholder="https://api.siliconflow.cn/v1/chat/completions" style={inputStyle} />
</div>
<div style={formGroup}>
<label style={labelStyle}>模型能力 (可多选)</label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{Object.entries(capabilityLabels).map(([key, label]) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer',
padding: '4px 10px', background: (editingModel.capabilities || []).includes(key) ? capabilityColors[key] + '22' : '#F3F4F6',
border: `1px solid ${(editingModel.capabilities || []).includes(key) ? capabilityColors[key] : '#D1D5DB'}`,
borderRadius: '4px', fontSize: '13px' }}>
<input type="checkbox" checked={(editingModel.capabilities || []).includes(key)} onChange={() => toggleCapability(key)} style={{ display: 'none' }} />
<span style={{ color: capabilityColors[key] }}>●</span> {label}
</label>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px' }}>
<div style={formGroup}>
<label style={labelStyle}>超时 (秒)</label>
<input type="number" value={editingModel.timeout} onChange={e => setEditingModel({ ...editingModel, timeout: parseInt(e.target.value) || 60 })} style={inputStyle} />
</div>
<div style={formGroup}>
<label style={labelStyle}>计费状态</label>
<select value={editingModel.status} onChange={e => setEditingModel({ ...editingModel, status: e.target.value })} style={inputStyle}>
<option value="free">免费</option>
<option value="paid">付费</option>
</select>
</div>
<div style={formGroup}>
<label style={labelStyle}>价格 (¥/M tokens)</label>
<input type="number" step="0.0001" value={editingModel.price_per_million_tokens} onChange={e => setEditingModel({ ...editingModel, price_per_million_tokens: parseFloat(e.target.value) || 0 })} style={inputStyle} />
</div>
</div>
<div style={formGroup}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input type="checkbox" checked={editingModel.is_enabled} onChange={e => setEditingModel({ ...editingModel, is_enabled: e.target.checked })} />
启用该模型
</label>
</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button type="button" onClick={() => setShowModelEditor(false)} style={btnStyle('#6B7280')}>取消</button>
<button type="submit" disabled={modelSaving} style={btnStyle('#5B5FED')}>{modelSaving ? '保存中...' : '保存'}</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
// Styles
const btnStyle = (color: string, size: 'normal' | 'small' = 'normal'): React.CSSProperties => ({
background: color, color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer',
fontSize: size === 'small' ? '12px' : '14px', padding: size === 'small' ? '4px 10px' : '8px 16px',
fontWeight: 500, transition: 'opacity 0.2s', whiteSpace: 'nowrap' as const,
});
const thStyle: React.CSSProperties = { padding: '10px 16px', textAlign: 'left', fontSize: '12px', color: '#6B7280', fontWeight: 600 };
const tdStyle: React.CSSProperties = { padding: '12px 16px', fontSize: '13px', verticalAlign: 'middle' };
const formGroup: React.CSSProperties = { marginBottom: '16px' };
const labelStyle: React.CSSProperties = { display: 'block', marginBottom: '6px', fontSize: '13px', fontWeight: 500, color: '#374151' };
const inputStyle: React.CSSProperties = { width: '100%', padding: '8px 12px', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box' };
const alertStyle = (bg: string, color: string): React.CSSProperties => ({ background: bg, color, padding: '10px 16px', borderRadius: '6px', fontSize: '13px' });
const modalOverlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalContent: React.CSSProperties = { background: '#FFFFFF', borderRadius: '12px', padding: '24px', width: '480px', maxWidth: '90vw', maxHeight: '90vh', overflow: 'auto', boxSizing: 'border-box' };
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;
File diff suppressed because it is too large. Load diff
+520
View File
@@ -0,0 +1,520 @@
// @ts-nocheck
/**
* 推广系统管理页面 — 使用 admin 设计系统
*/
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>
);
interface Promotion {
id: number; title: string; description: string; reward_type: string; reward_value: number;
min_payout: number; start_date: string; end_date: string; status: string; max_referrals: number;
created_at: string; updated_at: string; link_count: number; total_rewards: number;
}
interface ReferralLink {
id: number; code: string; user_id: number; username: string; promotion_id: number;
promotion_title: string; target_url: string; channel: string; total_clicks: number;
total_registrations: number; total_conversions: number; status: string; created_at: string;
}
interface ReferralReward {
id: number; referral_code: string; referrer_name: string; referee_name: string;
order_id: string; reward_type: string; reward_amount: number; reward_points: number;
status: string; payout_method: string; paid_at: string; created_at: string;
}
const Promotion: React.FC = () => {
const [activeTab, setActiveTab] = useState<'overview' | 'promotions' | 'links' | 'rewards' | 'leaderboard'>('overview');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [stats, setStats] = useState<any>(null);
const [promotions, setPromotions] = useState<Promotion[]>([]);
const [referralLinks, setReferralLinks] = useState<ReferralLink[]>([]);
const [rewards, setRewards] = useState<ReferralReward[]>([]);
const [leaderboard, setLeaderboard] = useState<any[]>([]);
const [promoPage, setPromoPage] = useState(1);
const [totalPromos, setTotalPromos] = useState(0);
const [showPromoModal, setShowPromoModal] = useState(false);
const [showLinkModal, setShowLinkModal] = useState(false);
const [editingPromo, setEditingPromo] = useState<Promotion | null>(null);
const loadStats = useCallback(async () => {
try {
const res = await adminAPI.getPromotionStats();
const data = (res as any)?.data || res || {};
setStats(data);
} catch (err) { console.error('Stats load failed:', err); }
}, []);
const loadPromotions = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getPromotions({ page: promoPage, page_size: 20 });
const data = (res as any)?.data || res || {};
setPromotions(data.items || []);
setTotalPromos(data.total || 0);
} catch (err) { setError('加载失败'); }
finally { setLoading(false); }
}, [promoPage]);
const loadLinks = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getReferralLinks({ page: 1, page_size: 50 });
const data = (res as any)?.data || res || {};
setReferralLinks(data.items || []);
} catch (err) { setError('加载失败'); }
finally { setLoading(false); }
}, []);
const loadRewards = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getReferralRewards({ page: 1, page_size: 50 });
const data = (res as any)?.data || res || {};
setRewards(data.items || []);
} catch (err) { setError('加载失败'); }
finally { setLoading(false); }
}, []);
const loadLeaderboard = useCallback(async () => {
try {
const res = await adminAPI.getPromotionLeaderboard({ limit: 50 });
const data = (res as any)?.data || res || {};
setLeaderboard(data.items || data.leaderboard || []);
} catch (err) { console.error('Leaderboard load failed:', err); }
}, []);
useEffect(() => {
setError('');
if (activeTab === 'overview') loadStats();
else if (activeTab === 'promotions') loadPromotions();
else if (activeTab === 'links') loadLinks();
else if (activeTab === 'rewards') loadRewards();
else if (activeTab === 'leaderboard') loadLeaderboard();
}, [activeTab, loadStats, loadPromotions, loadLinks, loadRewards, loadLeaderboard]);
const handleSavePromo = async (formData: any) => {
setLoading(true);
try {
if (editingPromo) await adminAPI.updatePromotion(editingPromo.id, formData);
else await adminAPI.createPromotion(formData);
setShowPromoModal(false); setEditingPromo(null); loadPromotions(); loadStats();
} catch (err) { setError('保存失败'); }
finally { setLoading(false); }
};
const handleTogglePromo = async (id: number) => {
try { await adminAPI.togglePromotion(id); loadPromotions(); }
catch (err) { setError('操作失败'); }
};
const handleDeletePromo = async (id: number) => {
if (!window.confirm('确定删除此推广活动?')) return;
try { await adminAPI.deletePromotion(id); loadPromotions(); loadStats(); }
catch (err) { setError('删除失败'); }
};
const handleGenerateLink = async (formData: any) => {
setLoading(true);
try { await adminAPI.generateReferralLink(formData); setShowLinkModal(false); loadLinks(); loadStats(); }
catch (err) { setError('生成失败'); }
finally { setLoading(false); }
};
const handleApproveReward = async (id: number) => {
try { await adminAPI.approveReward(id); loadRewards(); }
catch (err) { setError('操作失败'); }
};
const handleMarkPaid = async (id: number) => {
const payout_method = prompt('请输入提现方式(wechat/alipay/bank/balance):');
if (!payout_method) return;
try {
await adminAPI.markRewardPaid(id, { payout_method, payout_transaction_id: `TXN${Date.now()}` });
loadRewards();
} catch (err) { setError('操作失败'); }
};
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
const tabs = [
{ key: 'overview', label: '概览', icon: <><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></> },
{ key: 'promotions', label: '推广活动', icon: <><path d="M3 11l18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/></> },
{ key: 'links', label: '分销链接', icon: <><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></> },
{ key: 'rewards', label: '返佣记录', icon: <><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"/></> },
{ key: 'leaderboard', label: '排行榜', icon: <><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2z"/></> },
];
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
<div>
<h1 className="admin-page-title">推广系统管理</h1>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>管理推广活动、分销链接、返佣记录和推广统计</p>
</div>
{activeTab === 'promotions' && (
<button className="admin-btn admin-btn-primary" onClick={() => { setEditingPromo(null); setShowPromoModal(true); }}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
创建活动
</button>
)}
{activeTab === 'links' && (
<button className="admin-btn admin-btn-primary" onClick={() => setShowLinkModal(true)}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
生成链接
</button>
)}
</div>
{error && <div style={{ padding: '8px 16px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-md)', fontSize: 'var(--text-sm)' }}>{error}</div>}
{/* Tabs */}
<div style={{ display: 'flex', gap: 2, marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border)' }}>
{tabs.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key as any)}
style={{
padding: '10px 20px', border: 'none', cursor: 'pointer',
background: activeTab === tab.key ? 'var(--color-bg-secondary)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
fontSize: 'var(--text-sm)', fontWeight: activeTab === tab.key ? 600 : 400,
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6,
}}>
<SVGIcon size={14}>{tab.icon}</SVGIcon>
{tab.label}
</button>
))}
</div>
{/* ===================== Overview Tab ===================== */}
{activeTab === 'overview' && (
<div>
{!stats ? (
<div style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>加载中...</div>
) : (
<>
<div className="responsive-grid-4" style={{ marginBottom: 'var(--space-xl)' }}>
{[
{ label: '推广活动总数', value: stats.total_promotions || 0, color: '#818cf8', icon: 'M3 11l18-5v12L3 14v-3z' },
{ label: '进行中的活动', value: stats.active_promotions || 0, color: '#22c55e', icon: 'M9 12l2 2 4-4' },
{ label: '分销链接总数', value: stats.total_referrals || stats.total_referral_links || 0, color: '#f59e0b', icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71' },
{ label: '已发返佣总额', value: `¥${(stats.total_rewards || stats.total_rewards_paid || 0).toFixed(2)}`, color: '#06b6d4', icon: 'M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' },
].map((s, i) => (
<div key={i} className="admin-card" style={{ marginBottom: 0, textAlign: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 'var(--space-sm)' }}>
<div style={{ width: 40, height: 40, borderRadius: 'var(--radius-full)', background: `${s.color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SVGIcon size={20}><path d={s.icon}/></SVGIcon>
</div>
</div>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{s.label}</div>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-card-header"><h3 className="admin-card-title">待审核返佣</h3></div>
<div style={{ padding: 'var(--space-lg)', textAlign: 'center' }}>
<div style={{ fontSize: 'var(--text-3xl)', fontWeight: 800, color: '#f59e0b' }}>¥{(stats.pending_rewards || 0).toFixed(2)}</div>
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>待审核返佣金额</div>
</div>
</div>
</>
)}
</div>
)}
{/* ===================== Promotions Tab ===================== */}
{activeTab === 'promotions' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>ID</th><th>活动标题</th><th>返佣类型</th><th>返佣值</th>
<th>状态</th><th>链接数</th><th>已发返佣</th><th>创建时间</th><th>操作</th>
</tr>
</thead>
<tbody>
{promotions.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : promotions.map(promo => (
<tr key={promo.id}>
<td>{promo.id}</td>
<td>
<div style={{ fontWeight: 600 }}>{promo.title}</div>
{promo.description && <div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{promo.description}</div>}
</td>
<td>
<span className="admin-badge" style={{ background: 'rgba(99,102,241,0.1)', color: '#818cf8' }}>
{promo.reward_type === 'percentage' ? '百分比' : promo.reward_type === 'fixed' ? '固定金额' : '积分'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
{promo.reward_type === 'percentage' ? `${promo.reward_value}%` :
promo.reward_type === 'fixed' ? `¥${promo.reward_value?.toFixed(2)}` : `${promo.reward_value} 分`}
</td>
<td>
<span className={`admin-badge ${promo.status === 'active' ? 'badge-success' : 'badge-neutral'}`}>
{promo.status === 'active' ? '进行中' : promo.status === 'inactive' ? '已停用' : '已过期'}
</span>
</td>
<td>{promo.link_count || 0}</td>
<td>¥{(promo.total_rewards || 0).toFixed(2)}</td>
<td style={{ fontSize: 'var(--text-xs)' }}>{promo.created_at ? new Date(promo.created_at).toLocaleDateString('zh-CN') : '-'}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => { setEditingPromo(promo); setShowPromoModal(true); }}>编辑</button>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleTogglePromo(promo.id)}>
{promo.status === 'active' ? '停用' : '启用'}
</button>
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleDeletePromo(promo.id)}>删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{totalPromos > 20 && (
<div className="pagination-bar" style={{ marginTop: 'var(--space-md)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={promoPage <= 1} onClick={() => setPromoPage(p => p - 1)}>上一页</button>
<span style={{ fontSize: 'var(--text-sm)' }}>第 {promoPage} 页 / 共 {Math.ceil(totalPromos / 20)} 页</span>
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={promoPage >= Math.ceil(totalPromos / 20)} onClick={() => setPromoPage(p => p + 1)}>下一页</button>
</div>
)}
</div>
)}
{/* ===================== Links Tab ===================== */}
{activeTab === 'links' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>ID</th><th>推广短码</th><th>推广人</th><th>关联活动</th>
<th>点击数</th><th>注册数</th><th>转化数</th><th>状态</th><th>操作</th>
</tr>
</thead>
<tbody>
{referralLinks.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : referralLinks.map(link => (
<tr key={link.id}>
<td>{link.id}</td>
<td><code style={{ background: 'var(--color-bg-tertiary)', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{link.code}</code></td>
<td>{link.username}</td>
<td>{link.promotion_title || '-'}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_clicks || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_registrations || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{link.total_conversions || 0}</td>
<td>
<span className={`admin-badge ${link.status === 'active' ? 'badge-success' : 'badge-neutral'}`}>
{link.status === 'active' ? '活跃' : '停用'}
</span>
</td>
<td>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => {
const url = `${link.target_url}?ref=${link.code}`;
navigator.clipboard.writeText(url);
alert('链接已复制');
}}>复制链接</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Rewards Tab ===================== */}
{activeTab === 'rewards' && (
<div className="admin-card" style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>ID</th><th>推广码</th><th>推广人</th><th>被推荐人</th>
<th>返佣类型</th><th>返佣金额</th><th>状态</th><th>打款时间</th><th>操作</th>
</tr>
</thead>
<tbody>
{rewards.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : rewards.map(reward => (
<tr key={reward.id}>
<td>{reward.id}</td>
<td><code style={{ background: 'var(--color-bg-tertiary)', padding: '2px 8px', borderRadius: 'var(--radius-sm)', fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{reward.referral_code}</code></td>
<td>{reward.referrer_name}</td>
<td>{reward.referee_name}</td>
<td>
<span className="admin-badge" style={{ background: 'rgba(99,102,241,0.1)', color: '#818cf8' }}>
{reward.reward_type === 'registration' ? '注册奖励' : reward.reward_type === 'first_purchase' ? '首购返佣' : '续费返佣'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{reward.reward_amount?.toFixed(2)}</td>
<td>
<span className={`admin-badge ${reward.status === 'paid' ? 'badge-success' : reward.status === 'approved' ? 'badge-success' : reward.status === 'pending' ? 'badge-neutral' : 'badge-danger'}`}>
{reward.status === 'pending' ? '待审核' : reward.status === 'approved' ? '已通过' : reward.status === 'paid' ? '已打款' : '已取消'}
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)' }}>{reward.paid_at ? new Date(reward.paid_at).toLocaleDateString('zh-CN') : '-'}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
{reward.status === 'pending' && <button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleApproveReward(reward.id)}>审核通过</button>}
{reward.status === 'approved' && <button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleMarkPaid(reward.id)}>标记已打款</button>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Leaderboard Tab ===================== */}
{activeTab === 'leaderboard' && (
<div className="admin-card" 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>
{leaderboard.length === 0 ? (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
) : leaderboard.map((entry, index) => (
<tr key={entry.user_id || index}>
<td>
<span style={{ fontSize: 'var(--text-lg)' }}>{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}</span>
</td>
<td style={{ fontWeight: 600 }}>{entry.username}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_clicks || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_registrations || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{entry.total_conversions || 0}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{(entry.total_revenue || 0).toFixed(2)}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>¥{(entry.total_rewards || 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ===================== Promotion Modal ===================== */}
{showPromoModal && (
<PromotionModal promotion={editingPromo} onSave={handleSavePromo} onCancel={() => { setShowPromoModal(false); setEditingPromo(null); }} />
)}
{/* ===================== Link Modal ===================== */}
{showLinkModal && (
<LinkModal onSave={handleGenerateLink} onCancel={() => setShowLinkModal(false)} />
)}
</div>
);
};
// ==================== 推广活动弹窗 ====================
const PromotionModal: React.FC<{ promotion: Promotion | null; onSave: (data: any) => void; onCancel: () => void }> = ({ promotion, onSave, onCancel }) => {
const [formData, setFormData] = useState({
title: promotion?.title || '', description: promotion?.description || '',
reward_type: promotion?.reward_type || 'percentage', reward_value: promotion?.reward_value || 5,
min_payout: promotion?.min_payout || 10, start_date: promotion?.start_date ? promotion.start_date.slice(0, 16) : '',
end_date: promotion?.end_date ? promotion.end_date.slice(0, 16) : '', max_referrals: promotion?.max_referrals || 0,
status: promotion?.status || 'active',
});
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 999, display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '4vh' }} onClick={onCancel}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 600, maxHeight: '90vh', overflow: 'auto', boxShadow: 'var(--shadow-xl)' }} onClick={e => e.stopPropagation()}>
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', borderBottom: '1px solid var(--color-border)' }}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700 }}>{promotion ? '编辑推广活动' : '创建推广活动'}</h3>
</div>
<form onSubmit={e => { e.preventDefault(); onSave(formData); }}>
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>活动标题 *</label><input style={inputStyle} required value={formData.title} onChange={e => setFormData({ ...formData, title: e.target.value })} placeholder="例如:新用户推广计划" /></div>
<div><label style={labelStyle}>活动描述</label><textarea style={{ ...inputStyle, resize: 'vertical' }} value={formData.description} onChange={e => setFormData({ ...formData, description: e.target.value })} rows={3} placeholder="活动说明..." /></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>返佣类型 *</label><select style={inputStyle} value={formData.reward_type} onChange={e => setFormData({ ...formData, reward_type: e.target.value })}><option value="percentage">百分比</option><option value="fixed">固定金额</option><option value="points">积分</option></select></div>
<div><label style={labelStyle}>返佣值 *</label><input type="number" style={inputStyle} required step={formData.reward_type === 'percentage' ? 1 : 0.01} min={0} value={formData.reward_value} onChange={e => setFormData({ ...formData, reward_value: parseFloat(e.target.value) })} /></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>最低提现金额</label><input type="number" style={inputStyle} step={0.01} min={0} value={formData.min_payout} onChange={e => setFormData({ ...formData, min_payout: parseFloat(e.target.value) })} /></div>
<div><label style={labelStyle}>最大推广人数 (0=不限)</label><input type="number" style={inputStyle} min={0} value={formData.max_referrals} onChange={e => setFormData({ ...formData, max_referrals: parseInt(e.target.value) })} /></div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>开始时间 *</label><input type="datetime-local" style={inputStyle} required value={formData.start_date} onChange={e => setFormData({ ...formData, start_date: e.target.value })} /></div>
<div><label style={labelStyle}>结束时间 (可选)</label><input type="datetime-local" style={inputStyle} value={formData.end_date} onChange={e => setFormData({ ...formData, end_date: e.target.value })} /></div>
</div>
</div>
<div style={{ padding: 'var(--space-lg) var(--space-2xl)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', borderTop: '1px solid var(--color-border)' }}>
<button type="button" className="admin-btn admin-btn-outline" onClick={onCancel}>取消</button>
<button type="submit" className="admin-btn admin-btn-primary">保存</button>
</div>
</form>
</div>
</div>
);
};
// ==================== 生成链接弹窗 ====================
const LinkModal: React.FC<{ onSave: (data: any) => void; onCancel: () => void }> = ({ onSave, onCancel }) => {
const [formData, setFormData] = useState({ promotion_id: '', target_url: 'https://bot.qihuyun.com/register', channel: 'link' });
const [promotions, setPromotions] = useState<Promotion[]>([]);
useEffect(() => {
adminAPI.getPromotions({ status: 'active', page: 1, page_size: 100 })
.then(res => { const d = (res as any)?.data || res; if (d?.items) setPromotions(d.items); })
.catch(err => console.error('Failed to load promotions:', err));
}, []);
const inputStyle: React.CSSProperties = {
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
};
const labelStyle: React.CSSProperties = {
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
};
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', zIndex: 999, display: 'flex', alignItems: 'center', justifyContent: 'center' }} onClick={onCancel}>
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 480, padding: 'var(--space-2xl)' }} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-xl)' }}>生成分销链接</h3>
<form onSubmit={e => { e.preventDefault(); onSave({ promotion_id: parseInt(formData.promotion_id), target_url: formData.target_url, channel: formData.channel }); }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
<div><label style={labelStyle}>选择推广活动 *</label><select style={inputStyle} required value={formData.promotion_id} onChange={e => setFormData({ ...formData, promotion_id: e.target.value })}><option value="">-- 请选择 --</option>{promotions.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}</select></div>
<div><label style={labelStyle}>目标URL *</label><input type="url" style={inputStyle} required value={formData.target_url} onChange={e => setFormData({ ...formData, target_url: e.target.value })} /></div>
<div><label style={labelStyle}>推广渠道</label><select style={inputStyle} value={formData.channel} onChange={e => setFormData({ ...formData, channel: e.target.value })}><option value="link">链接</option><option value="qrcode">二维码</option><option value="api">API</option></select></div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', marginTop: 'var(--space-xl)' }}>
<button type="button" className="admin-btn admin-btn-outline" onClick={onCancel}>取消</button>
<button type="submit" className="admin-btn admin-btn-primary">生成链接</button>
</div>
</form>
</div>
</div>
);
};
export default Promotion;
+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;
@@ -0,0 +1,772 @@
import React, { useState, useEffect, useRef } 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>
);
type TabKey = 'basic' | 'logo' | 'email' | 'sms' | 'registration';
const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [
{ key: 'basic', label: '基础设置', icon: <><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"/></> },
{ key: 'logo', label: 'Logo 设置', icon: <><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></> },
{ key: 'email', label: '邮件配置', icon: <><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"/></> },
{ key: 'sms', label: '短信配置', icon: <><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></> },
{ key: 'registration', label: '注册设置', icon: <><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></> },
];
const SiteSettings: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabKey>('basic');
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 [registration, setRegistration] = useState({
allow_register: true,
require_email_verify: false,
require_phone_verify: false,
default_role: 'user',
welcome_message: '',
});
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);
const [logoUploading, setLogoUploading] = useState(false);
const [logoPreview, setLogoPreview] = useState('');
const logoInputRef = useRef<HTMLInputElement>(null);
// 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);
}
};
// Load all configs
useEffect(() => {
async function loadConfig() {
try {
const [siteRes, emailRes, smsRes, regRes] = await Promise.all([
adminAPI.getSiteSettings(),
adminAPI.getEmailConfig(),
adminAPI.getSmsConfig(),
adminAPI.getSiteSettings().then((r: any) => ({ data: r?.data?.registration || {} })).catch(() => ({ data: {} })),
]);
if (siteRes?.data) {
setBrand({
logo: siteRes.data.logo || siteRes.data.site_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 || '',
});
setLogoPreview(siteRes.data.logo || siteRes.data.site_logo || '');
}
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 || '',
});
}
// Try to load registration settings from site settings
try {
const regData = (siteRes as any)?.data;
if (regData?.allow_register !== undefined) {
setRegistration({
allow_register: regData.allow_register === true || regData.allow_register === 'true',
require_email_verify: regData.require_email_verify === true || regData.require_email_verify === 'true',
require_phone_verify: regData.require_phone_verify === true || regData.require_phone_verify === 'true',
default_role: regData.default_role || 'user',
welcome_message: regData.welcome_message || '',
});
}
} catch {}
} 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);
};
// Logo upload handler
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Preview locally
const reader = new FileReader();
reader.onload = (ev) => setLogoPreview(ev.target?.result as string);
reader.readAsDataURL(file);
// Upload to server
setLogoUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await adminAPI.uploadLogo(formData);
const data = (res as any)?.data || res;
const url = data?.url || data?.logo_url || '';
if (url) {
setBrand({ ...brand, logo: url });
showSaved('Logo');
}
} catch (e: any) {
showTestResult('Logo 上传失败: ' + (e.detail || e.message || '未知错误'));
} finally {
setLogoUploading(false);
}
};
const saveSiteSettings = async () => {
try {
await adminAPI.updateSiteSettings({
logo: brand.logo || logoPreview,
site_name: brand.siteName,
announcement: brand.announcement,
icp_number: brand.icpNumber,
contact_phone: brand.contactPhone,
});
showSaved('基础设置');
} catch (e: any) {
showTestResult('保存失败: ' + (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) {
showTestResult('保存失败: ' + (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) {
showTestResult('保存失败: ' + (e.detail || e.message));
}
};
const saveRegistrationSettings = async () => {
try {
await adminAPI.updateSiteSettings({
allow_register: String(registration.allow_register),
require_email_verify: String(registration.require_email_verify),
require_phone_verify: String(registration.require_phone_verify),
default_role: registration.default_role,
welcome_message: registration.welcome_message,
});
showSaved('注册设置');
} catch (e: any) {
showTestResult('保存失败: ' + (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', color: 'var(--color-text-secondary)' }}>加载中...</div>;
}
const FormGroup = ({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) => (
<div className="admin-form-group">
<label className="admin-form-label">
{label}
{required && <span style={{ color: 'var(--color-error)' }}> *</span>}
</label>
{children}
</div>
);
const FormRow = ({ children }: { children: React.ReactNode }) => (
<div className="admin-form-row">{children}</div>
);
const FormActions = ({ children }: { children: React.ReactNode }) => (
<div className="admin-form-actions">{children}</div>
);
// ========== Render Tab Content ==========
const renderTabContent = () => {
switch (activeTab) {
// ── 基础设置 ──
case 'basic':
return (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">基础信息</h3>
</div>
<FormRow>
<FormGroup label="网站名称" required>
<input className="admin-form-input" value={brand.siteName}
onChange={e => setBrand({ ...brand, siteName: e.target.value })}
placeholder="例如: 七虎AI" />
</FormGroup>
<FormGroup label="LOGO URL">
<input className="admin-form-input" value={brand.logo}
onChange={e => { setBrand({ ...brand, logo: e.target.value }); setLogoPreview(e.target.value); }}
placeholder="https://... 或上传后自动填入" />
</FormGroup>
</FormRow>
<FormGroup label="首页公告">
<textarea className="admin-form-input admin-form-textarea"
value={brand.announcement}
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
style={{ minHeight: 80 }}
placeholder="将在首页顶部显示的公告内容..." />
</FormGroup>
<FormRow>
<FormGroup label="备案号">
<input className="admin-form-input" value={brand.icpNumber}
onChange={e => setBrand({ ...brand, icpNumber: e.target.value })}
placeholder="例如: 京ICP备XXXXXXXX号" />
</FormGroup>
<FormGroup label="客服联系方式">
<input className="admin-form-input" value={brand.contactPhone}
onChange={e => setBrand({ ...brand, contactPhone: e.target.value })}
placeholder="电话 / 邮箱 / 微信" />
</FormGroup>
</FormRow>
<FormActions>
<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>
</FormActions>
</div>
);
// ── Logo 设置 ──
case 'logo':
return (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">Logo 上传</h3>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
建议尺寸 200x60px,支持 PNG/JPG/SVG/WEBP,最大 2MB
</span>
</div>
<div style={{
display: 'flex', gap: 'var(--space-2xl)', alignItems: 'flex-start',
flexWrap: 'wrap' as const,
}}>
{/* Preview */}
<div style={{
width: 280, minHeight: 160,
border: '2px dashed var(--color-border)',
borderRadius: 'var(--radius-lg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-bg-elevated)',
flexShrink: 0,
position: 'relative',
}}>
{logoPreview ? (
<div style={{ textAlign: 'center' }}>
<img
src={logoPreview}
alt="Logo Preview"
style={{ maxWidth: 240, maxHeight: 80, objectFit: 'contain' }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div style={{ marginTop: 8, fontSize: 'var(--text-xs)', color: 'var(--color-text-muted)' }}>
实时预览
</div>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
<SVGIcon size={32}>
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>
</SVGIcon>
<div style={{ marginTop: 8, fontSize: 'var(--text-sm)' }}>暂无 Logo</div>
<div style={{ fontSize: 'var(--text-xs)' }}>点击上传或拖拽到此处</div>
</div>
)}
{logoUploading && (
<div style={{
position: 'absolute', inset: 0,
background: 'rgba(0,0,0,0.5)', borderRadius: 'var(--radius-lg)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div className="spinner" />
</div>
)}
</div>
{/* Upload Actions */}
<div style={{ flex: 1, minWidth: 240 }}>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)', marginBottom: 'var(--space-lg)' }}>
上传平台 Logo,将显示在网站标题栏、首页导航等位置。
</p>
<input
ref={logoInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml,image/webp"
onChange={handleLogoUpload}
style={{ display: 'none' }}
/>
<div style={{ display: 'flex', gap: 'var(--space-sm)', flexWrap: 'wrap' }}>
<button
className="admin-btn admin-btn-primary"
onClick={() => logoInputRef.current?.click()}
disabled={logoUploading}
>
<SVGIcon><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>
{logoUploading ? '上传中...' : '选择文件上传'}
</button>
{logoPreview && (
<button
className="admin-btn admin-btn-outline"
onClick={() => {
setLogoPreview('');
setBrand({ ...brand, logo: '' });
if (logoInputRef.current) logoInputRef.current.value = '';
}}
>
<SVGIcon><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
移除 Logo
</button>
)}
</div>
{/* URL Fallback */}
<div style={{ marginTop: 'var(--space-xl)', paddingTop: 'var(--space-lg)',
borderTop: '1px solid var(--color-border)' }}>
<FormGroup label="或使用外链 URL">
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<input className="admin-form-input"
value={brand.logo}
onChange={e => { setBrand({ ...brand, logo: e.target.value }); setLogoPreview(e.target.value || ''); }}
placeholder="https://example.com/logo.png"
style={{ flex: 1 }} />
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
保存
</button>
</div>
</FormGroup>
</div>
</div>
</div>
</div>
);
// ── 邮件配置 ──
case 'email':
return (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">SMTP 邮件服务</h3>
</div>
<FormRow>
<FormGroup label="SMTP 服务器" required>
<input className="admin-form-input" value={mail.smtp_host}
onChange={e => setMail({ ...mail, smtp_host: e.target.value })}
placeholder="例如: smtp.gmail.com" />
</FormGroup>
<FormGroup label="端口">
<input className="admin-form-input" type="number" value={mail.smtp_port}
onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
</FormGroup>
</FormRow>
<FormRow>
<FormGroup label="用户名" required>
<input className="admin-form-input" value={mail.username}
onChange={e => setMail({ ...mail, username: e.target.value })} />
</FormGroup>
<FormGroup label="密码" required>
<input className="admin-form-input" type="password" value={mail.password}
onChange={e => setMail({ ...mail, password: e.target.value })} />
</FormGroup>
</FormRow>
<FormRow>
<FormGroup label="发件地址" required>
<input className="admin-form-input" value={mail.from_address}
onChange={e => setMail({ ...mail, from_address: e.target.value })}
placeholder="noreply@example.com" />
</FormGroup>
<FormGroup label="安全协议">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
<input type="checkbox" checked={mail.use_tls}
onChange={e => setMail({ ...mail, use_tls: e.target.checked })}
style={{ accentColor: 'var(--color-accent)' }} />
启用 TLS 加密
</label>
</FormGroup>
</FormRow>
<FormActions>
<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>
</FormActions>
</div>
);
// ── 短信配置 ──
case 'sms':
return (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">短信服务</h3>
</div>
<FormRow>
<FormGroup 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>
</FormGroup>
<FormGroup label="短信签名">
<input className="admin-form-input" value={sms.sign_name}
onChange={e => setSms({ ...sms, sign_name: e.target.value })} />
</FormGroup>
</FormRow>
<FormRow>
<FormGroup label="API Key (AccessKey ID)">
<input className="admin-form-input" value={sms.api_key}
onChange={e => setSms({ ...sms, api_key: e.target.value })} />
</FormGroup>
<FormGroup label="API Secret (AccessKey Secret)">
<input className="admin-form-input" type="password" value={sms.api_secret}
onChange={e => setSms({ ...sms, api_secret: e.target.value })} />
</FormGroup>
</FormRow>
<FormGroup label="模板编码" required>
<input className="admin-form-input" value={sms.template_code}
onChange={e => setSms({ ...sms, template_code: e.target.value })}
placeholder="例如: SMS_00000001"
style={{ maxWidth: 400 }} />
</FormGroup>
<FormActions>
<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>
</FormActions>
</div>
);
// ── 注册设置 ──
case 'registration':
return (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">注册与准入</h3>
</div>
<FormRow>
<FormGroup label="允许新用户注册">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
<input type="checkbox" checked={registration.allow_register}
onChange={e => setRegistration({ ...registration, allow_register: e.target.checked })}
style={{ accentColor: 'var(--color-accent)' }} />
开启注册(关闭后仅管理员可手动创建用户)
</label>
</FormGroup>
<FormGroup label="默认角色">
<select className="admin-form-input" value={registration.default_role}
onChange={e => setRegistration({ ...registration, default_role: e.target.value })}>
<option value="user">普通用户</option>
<option value="member">会员</option>
<option value="developer">开发者</option>
</select>
</FormGroup>
</FormRow>
<FormRow>
<FormGroup label="邮箱验证">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
<input type="checkbox" checked={registration.require_email_verify}
onChange={e => setRegistration({ ...registration, require_email_verify: e.target.checked })}
style={{ accentColor: 'var(--color-accent)' }} />
注册时要求邮箱验证
</label>
</FormGroup>
<FormGroup label="手机验证">
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: '10px 0', cursor: 'pointer', color: 'var(--color-text-secondary)', fontSize: 'var(--text-sm)' }}>
<input type="checkbox" checked={registration.require_phone_verify}
onChange={e => setRegistration({ ...registration, require_phone_verify: e.target.checked })}
style={{ accentColor: 'var(--color-accent)' }} />
注册时要求短信验证
</label>
</FormGroup>
</FormRow>
<FormGroup label="欢迎语(注册成功后展示)">
<textarea className="admin-form-input admin-form-textarea"
value={registration.welcome_message}
onChange={e => setRegistration({ ...registration, welcome_message: e.target.value })}
style={{ minHeight: 80 }}
placeholder="欢迎加入七虎AI!从这里开始你的 AI 创造之旅..." />
</FormGroup>
<FormActions>
<button className="admin-btn admin-btn-primary" onClick={saveRegistrationSettings}>
<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>
</FormActions>
</div>
);
default:
return null;
}
};
// ========== Main Render ==========
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>
{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>
{/* Horizontal Tab Bar */}
<div style={{
display: 'flex', gap: 0,
borderBottom: '1px solid var(--color-border)',
marginBottom: 'var(--space-2xl)',
overflowX: 'auto' as const,
}}>
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '10px 18px',
border: 'none',
background: 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-muted)',
fontSize: 'var(--text-sm)',
fontWeight: activeTab === tab.key ? 600 : 400,
cursor: 'pointer',
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
transition: 'all var(--transition-fast)',
whiteSpace: 'nowrap' as const,
marginBottom: -1,
}}
>
<SVGIcon size={15}>{tab.icon}</SVGIcon>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{renderTabContent()}
{/* Email Test Modal */}
{showEmailTest && (
<div className="admin-modal-overlay" onClick={() => setShowEmailTest(false)}>
<div className="admin-modal" onClick={e => e.stopPropagation()}>
<div className="admin-modal-header">
<h3 style={{ display: 'flex', alignItems: 'center', gap: 8, margin: 0 }}>
<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>
<button className="admin-modal-close" onClick={() => setShowEmailTest(false)}>
<SVGIcon size={18}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
</button>
</div>
<div className="admin-modal-body">
<FormGroup label="收件邮箱" required>
<input className="admin-form-input" type="email" value={emailTestTo}
onChange={e => setEmailTestTo(e.target.value)}
placeholder="例如: admin@example.com" />
</FormGroup>
<FormGroup label="邮件主题">
<input className="admin-form-input" value={emailTestSubject}
onChange={e => setEmailTestSubject(e.target.value)} />
</FormGroup>
<FormGroup label="邮件内容">
<textarea className="admin-form-input admin-form-textarea"
value={emailTestBody}
onChange={e => setEmailTestBody(e.target.value)}
style={{ minHeight: 120 }} />
</FormGroup>
<div style={{ marginBottom: 'var(--space-lg)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm"
onClick={handleAIGenerateContent} disabled={emailGenerating}>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
</button>
</div>
</div>
<div className="admin-modal-footer">
<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>
)}
{/* 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.3)',
zIndex: 9999, fontSize: 'var(--text-sm)',
maxWidth: 420,
}}>
{testResult}
</div>
)}
</div>
);
};
export default SiteSettings;
+666
View File
@@ -0,0 +1,666 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
import type { SkillState } from '@/types';
type AdminSkill = {
id: string;
user_id: string;
owner_username?: string;
skill_name: string;
description?: string;
tags: string[];
category?: string;
category_label?: string;
state: SkillState;
use_count: number;
version: number;
skill_md_path?: string;
last_used_at?: string;
created_at?: string;
updated_at?: string;
skill_md_content?: string;
publish_records?: PublishRecord[];
};
interface PublishRecord {
id: string;
price: number;
status: string;
published_at?: string;
}
interface SkillVersion {
id: string;
version: number;
description?: string;
skill_md_content?: string;
created_at?: string;
}
interface SkillsStats {
total_skills: number;
by_state: Record<string, number>;
by_category: Record<string, number>;
total_published: number;
total_versions: number;
}
const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: string }> = {
baby: { label: '萌芽期', color: '#6B7280', bg: '#F3F4F6' },
main: { label: '主力期', color: '#10B981', bg: '#D1FAE5' },
staging: { label: '灰度期', color: '#F59E0B', bg: '#FEF3C7' },
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
};
const CATEGORIES = [
{ id: 'writing', label: '文字创作' },
{ id: 'coding', label: '代码能力' },
{ id: 'data_analysis', label: '数据分析' },
{ id: 'qa', label: '知识问答' },
{ id: 'automation', label: '流程自动化' },
{ id: 'creativity', label: '创意生成' },
{ id: 'communication', label: '沟通协作' },
{ id: 'reasoning', label: '逻辑推理' },
{ id: 'other', label: '其他' },
];
const PAGE_SIZE = 15;
const Skills: React.FC = () => {
const [skills, setSkills] = useState<AdminSkill[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<any>(null);
// Filters
const [keyword, setKeyword] = useState('');
const [filterState, setFilterState] = useState<string>('');
const [filterCategory, setFilterCategory] = useState<string>('');
// Create/Edit Modal
const [showModal, setShowModal] = useState(false);
const [editingSkill, setEditingSkill] = useState<AdminSkill | null>(null);
const [form, setForm] = useState({
skill_name: '',
user_id: '',
description: '',
tags: '',
category: '',
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
});
// Version Drawer
const [showVersions, setShowVersions] = useState(false);
const [versions, setVersions] = useState<any[]>([]);
const [versionSkill, setVersionSkill] = useState<AdminSkill | null>(null);
const [loadingVersions, setLoadingVersions] = useState(false);
// Users list (for owner selection)
const [users, setUsers] = useState<{id: string; username: string}[]>([]);
// Confirm delete
const [deleteTarget, setDeleteTarget] = useState<AdminSkill | null>(null);
// Toast
const [toast, setToast] = useState<{msg: string; type: 'success' | 'error'} | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 3000);
};
// Load skills list
const loadSkills = async () => {
setLoading(true);
try {
const params: any = { page, page_size: PAGE_SIZE };
if (keyword) params.keyword = keyword;
if (filterState) params.state = filterState;
if (filterCategory) params.category = filterCategory;
const result = await adminAPI.getAdminSkills(params) as any;
const data = result.data || result;
setSkills(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(`加载失败: ${e.message || e}`, 'error');
} finally {
setLoading(false);
}
};
// Load stats
const loadStats = async () => {
try {
const result = await adminAPI.getAdminSkillsStats() as any;
const data = result.data || result;
setStats(data);
} catch {}
};
// Load users for owner selection
const loadUsers = async () => {
try {
const result = await adminAPI.getUsers({ page_size: 100 }) as any;
const data = result.data || result;
setUsers((data.items || data || []).slice(0, 20));
} catch {}
};
useEffect(() => {
loadSkills();
}, [page, keyword, filterState, filterCategory]);
useEffect(() => {
loadStats();
loadUsers();
}, []);
// Search
const handleSearch = () => {
setPage(1);
loadSkills();
};
// Open create modal
const openCreate = () => {
setEditingSkill(null);
setForm({
skill_name: '',
user_id: users[0]?.id || '',
description: '',
tags: '',
category: '',
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
});
setShowModal(true);
};
// Open edit modal
const openEdit = (skill: AdminSkill) => {
setEditingSkill(skill);
setForm({
skill_name: skill.skill_name,
user_id: skill.user_id,
description: skill.description || '',
tags: (skill.tags || []).join(', '),
category: skill.category || '',
skill_md_content: skill.skill_md_content || '',
});
setShowModal(true);
};
// Save skill
const handleSave = async () => {
if (!form.skill_name.trim()) {
showToast('技能名称不能为空', 'error');
return;
}
if (!form.user_id) {
showToast('请选择所属用户', 'error');
return;
}
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean);
const payload: any = {
skill_name: form.skill_name,
user_id: form.user_id,
description: form.description,
tags,
category: form.category || undefined,
skill_md_content: form.skill_md_content,
};
try {
if (editingSkill) {
await adminAPI.updateAdminSkill(editingSkill.id, payload);
showToast('技能已更新');
} else {
await adminAPI.createAdminSkill(payload);
showToast('技能已创建');
}
setShowModal(false);
loadSkills();
loadStats();
} catch (e: any) {
showToast(`保存失败: ${e.message || e}`, 'error');
}
};
// Delete skill
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await adminAPI.deleteAdminSkill(deleteTarget.id);
showToast('技能已删除');
setDeleteTarget(null);
loadSkills();
loadStats();
} catch (e: any) {
showToast(`删除失败: ${e.message || e}`, 'error');
}
};
// Change state
const handleChangeState = async (skill: AdminSkill, newState: string) => {
try {
await adminAPI.changeAdminSkillState(skill.id, { state: newState });
showToast(`状态已变更为 ${STATE_CONFIG[newState as SkillState].label}`);
loadSkills();
loadStats();
} catch (e: any) {
showToast(`状态修改失败: ${e.message || e}`, 'error');
}
};
// Load version history
const handleShowVersions = async (skill: AdminSkill) => {
setVersionSkill(skill);
setShowVersions(true);
setLoadingVersions(true);
try {
const result = await adminAPI.getAdminSkillVersions(skill.id) as any;
const data = result.data || result;
setVersions(data.versions || []);
} catch (e: any) {
showToast(`加载版本历史失败: ${e.message || e}`, 'error');
setVersions([]);
} finally {
setLoadingVersions(false);
}
};
// Rollback version
const handleRollback = async (skill: AdminSkill, targetVersion: number) => {
if (!confirm(`确定回滚到版本 ${targetVersion} 吗?`)) return;
try {
await adminAPI.rollbackAdminSkill(skill.id, { version: targetVersion });
showToast(`已回滚到版本 ${targetVersion}`);
setShowVersions(false);
loadSkills();
} catch (e: any) {
showToast(`回滚失败: ${e.message || e}`, 'error');
}
};
// Publish/Unpublish
const handlePublish = async (skill: AdminSkill) => {
const price = parseFloat(prompt('设置定价(元,0=免费):', '0') || '0');
if (isNaN(price) || price < 0) return;
try {
await adminAPI.publishAdminSkill(skill.id, { price, market_description: skill.description || '' });
showToast('已发布到应用市场');
loadSkills();
} catch (e: any) {
showToast(`发布失败: ${e.message || e}`, 'error');
}
};
const handleUnpublish = async (skill: AdminSkill) => {
if (!confirm('确定从市场下架吗?')) return;
try {
await adminAPI.unpublishAdminSkill(skill.id);
showToast('已从市场下架');
loadSkills();
} catch (e: any) {
showToast(`下架失败: ${e.message || e}`, 'error');
}
};
const totalPages = Math.ceil(total / PAGE_SIZE);
const isPublished = (skill: AdminSkill) =>
(skill.publish_records || []).some(r => r.status === 'published');
return (
<div className="admin-page">
{/* Toast */}
{toast && (
<div className={`toast ${toast.type}`} style={{
position: 'fixed', top: 20, right: 20, zIndex: 9999,
padding: '12px 20px', borderRadius: 8,
background: toast.type === 'success' ? '#10B981' : '#EF4444',
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
}}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="admin-page-header">
<div>
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>技能管理</h2>
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
管理全平台 AI 技能,支持版本历史与市场发布
</p>
</div>
<button className="admin-btn primary" onClick={openCreate}>+ 创建技能</button>
</div>
{/* Stats Cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
<div className="stat-card">
<div className="stat-value">{stats.total_skills}</div>
<div className="stat-label">技能总数</div>
</div>
{Object.entries(STATE_CONFIG).map(([state, cfg]) => (
<div key={state} className="stat-card" style={{ borderLeft: `3px solid ${cfg.color}` }}>
<div className="stat-value">{stats.by_state[state] || 0}</div>
<div className="stat-label">{cfg.label}</div>
</div>
))}
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
<div className="stat-value">{stats.total_published}</div>
<div className="stat-label">已发布市场</div>
</div>
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
<div className="stat-value">{stats.total_versions}</div>
<div className="stat-label">版本记录</div>
</div>
</div>
)}
{/* Filters */}
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
<input
className="admin-form-input"
placeholder="搜索技能名称或描述..."
value={keyword}
onChange={e => setKeyword(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSearch()}
style={{ maxWidth: 280 }}
/>
<select className="admin-form-input" value={filterState} onChange={e => { setFilterState(e.target.value); setPage(1); }} style={{ maxWidth: 140 }}>
<option value="">全部状态</option>
{Object.entries(STATE_CONFIG).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<select className="admin-form-input" value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} style={{ maxWidth: 160 }}>
<option value="">全部分类</option>
{CATEGORIES.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
<button className="admin-btn" onClick={handleSearch}>搜索</button>
{(keyword || filterState || filterCategory) && (
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterState(''); setFilterCategory(''); setPage(1); }}>
清空筛选
</button>
)}
</div>
{/* Table */}
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>技能名称</th>
<th>状态</th>
<th>分类</th>
<th>所有者</th>
<th>版本</th>
<th>使用次数</th>
<th>市场</th>
<th>更新时间</th>
<th style={{ width: 280 }}>操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
) : skills.length === 0 ? (
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr>
) : skills.map(skill => {
const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby;
const published = isPublished(skill);
return (
<tr key={skill.id}>
<td>
<div style={{ fontWeight: 500 }}>{skill.skill_name}</div>
{skill.description && (
<div style={{ fontSize: 12, color: '#6B7280', marginTop: 2, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{skill.description}
</div>
)}
{skill.tags && skill.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{skill.tags.slice(0, 3).map(tag => (
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
))}
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
</div>
)}
</td>
<td>
<span style={{
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12,
background: cfg.bg, color: cfg.color, fontWeight: 500
}}>
{cfg.label}
</span>
</td>
<td>{skill.category_label || '-'}</td>
<td>{skill.owner_username || skill.user_id}</td>
<td>v{skill.version}</td>
<td>{skill.use_count}</td>
<td>
{published ? (
<span style={{ color: '#10B981', fontSize: 13 }}>✓ 已发布</span>
) : (
<span style={{ color: '#9CA3AF', fontSize: 13 }}>未发布</span>
)}
</td>
<td style={{ fontSize: 12, color: '#6B7280' }}>
{skill.updated_at ? skill.updated_at.slice(0, 16).replace('T', ' ') : '-'}
</td>
<td>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button className="admin-btn small" onClick={() => openEdit(skill)}>编辑</button>
<button className="admin-btn small" onClick={() => handleShowVersions(skill)}>版本</button>
{published ? (
<button className="admin-btn small warning" onClick={() => handleUnpublish(skill)}>下架</button>
) : (
<button className="admin-btn small success" onClick={() => handlePublish(skill)}>发布</button>
)}
{/* State change */}
<select
className="admin-form-input small"
value={skill.state}
onChange={e => handleChangeState(skill, e.target.value)}
style={{ padding: '2px 4px', fontSize: 12, maxWidth: 90 }}
>
{Object.entries(STATE_CONFIG).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<button className="admin-btn small danger" onClick={() => setDeleteTarget(skill)}>删除</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="admin-pagination">
<span style={{ color: '#6B7280', fontSize: 13 }}>
共 {total} 条,第 {page}/{totalPages} 页
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
return p <= totalPages && (
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`}
onClick={() => setPage(p)}>{p}</button>
);
})}
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>{editingSkill ? '编辑技能' : '创建技能'}</h3>
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div className="form-group">
<label className="form-label">技能名称 *</label>
<input className="admin-form-input" value={form.skill_name}
onChange={e => setForm(f => ({ ...f, skill_name: e.target.value }))}
placeholder="例如: 代码审查助手" />
</div>
{!editingSkill && (
<div className="form-group">
<label className="form-label">所属用户 *</label>
<select className="admin-form-input" value={form.user_id}
onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}>
{users.map(u => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
)}
<div className="form-group">
<label className="form-label">分类</label>
<select className="admin-form-input" value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
<option value="">选择分类</option>
{CATEGORIES.map(c => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">标签(逗号分隔)</label>
<input className="admin-form-input" value={form.tags}
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="例如: 代码, 审查, 自动化" />
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label className="form-label">描述</label>
<textarea className="admin-form-input" value={form.description} rows={3}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
placeholder="描述技能的功能和使用场景..." />
</div>
</div>
<div className="form-group">
<label className="form-label">SKILL.md 内容(技能定义)</label>
<textarea
className="admin-form-input"
value={form.skill_md_content}
onChange={e => setForm(f => ({ ...f, skill_md_content: e.target.value }))}
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 400, lineHeight: 1.6 }}
placeholder="## 技能定义..."
/>
</div>
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
<button className="admin-btn primary" onClick={handleSave}>
{editingSkill ? '保存更改' : '创建技能'}
</button>
</div>
</div>
</div>
)}
{/* Version History Drawer */}
{showVersions && versionSkill && (
<div className="modal-overlay" onClick={() => setShowVersions(false)}>
<div className="modal-content" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>版本历史 - {versionSkill.skill_name}</h3>
<button className="modal-close" onClick={() => setShowVersions(false)}>×</button>
</div>
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{loadingVersions ? (
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</div>
) : versions.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无版本记录</div>
) : (
<div>
{versions.map((v, idx) => (
<div key={v.id} style={{
padding: 16, marginBottom: 12, border: `1px solid ${idx === 0 ? '#10B981' : '#E5E7EB'}`,
borderRadius: 8, background: idx === 0 ? '#F0FDF4' : '#fff'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<div>
<span style={{ fontWeight: 600, fontSize: 15 }}>版本 {v.version}</span>
{idx === 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#10B981', background: '#D1FAE5', padding: '1px 8px', borderRadius: 12 }}>当前</span>}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: '#6B7280' }}>{v.created_at?.slice(0, 16).replace('T', ' ')}</span>
{idx > 0 && (
<button className="admin-btn small" onClick={() => handleRollback(versionSkill, v.version)}>回滚至此</button>
)}
</div>
</div>
{v.description && <div style={{ fontSize: 13, color: '#6B7280', marginBottom: 8 }}>{v.description}</div>}
{v.skill_md_content && (
<details>
<summary style={{ fontSize: 13, color: '#6366F1', cursor: 'pointer' }}>查看 SKILL.md 内容</summary>
<pre style={{ fontSize: 12, background: '#F9FAFB', padding: 12, borderRadius: 6, marginTop: 8, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{v.skill_md_content}
</pre>
</details>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
{/* Delete Confirm */}
{deleteTarget && (
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
<div className="modal-content" style={{ maxWidth: 400 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>确认删除</h3>
</div>
<div className="modal-body">
<p>确定要删除技能 <strong>{deleteTarget.skill_name}</strong> 吗?</p>
<p style={{ fontSize: 13, color: '#EF4444' }}>此操作不可恢复,相关版本历史也将被删除。</p>
</div>
<div className="modal-footer">
<button className="admin-btn" onClick={() => setDeleteTarget(null)}>取消</button>
<button className="admin-btn danger" onClick={handleDelete}>确认删除</button>
</div>
</div>
</div>
)}
<style>{`
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
.modal-header h3 { margin: 0; font-size: 18px; }
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
.modal-body { padding: 24px; overflow-y: auto; }
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
.form-group { margin-bottom: 16px; }
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
`}</style>
</div>
);
};
export default Skills;
File diff suppressed because it is too large. Load diff
+235
View File
@@ -0,0 +1,235 @@
import { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
interface Tenant {
id: string; name: string; slug: string; logo: string; description: string;
status: string; max_members: number; max_storage_mb: number;
max_api_calls_per_day: number; member_count?: number; created_at: string; expire_at?: string;
}
interface Member { id: string; user_id: string; username: string; email: string; role: string; status: string; joined_at: string; avatar?: string; }
interface Stats { total_tenants: number; active_tenants: number; suspended_tenants: number; total_members: number; pending_invitations: number; }
const Tenant: React.FC = () => {
const [tab, setTab] = useState<'list' | 'members' | 'stats'>('list');
const [tenants, setTenants] = useState<Tenant[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [selectedTenant, setSelectedTenant] = useState<string>('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
const fetchTenants = useCallback(async () => {
setLoading(true);
try {
const res = await adminAPI.getTenants(page);
const d = (res as any)?.data || res;
setTenants(d?.items || []);
setTotal(d?.total || 0);
} catch (e) { console.error('fetchTenants:', e); }
setLoading(false);
}, [page]);
const fetchStats = useCallback(async () => {
try {
const res = await adminAPI.getTenantStats();
setStats((res as any)?.data || res);
} catch (e) { console.error('fetchStats:', e); }
}, []);
const fetchMembers = useCallback(async () => {
if (!selectedTenant) return;
try {
const res = await adminAPI.getTenantMembers(selectedTenant);
const d = (res as any)?.data || res;
setMembers(d?.items || []);
} catch (e) { console.error('fetchMembers:', e); }
}, [selectedTenant]);
useEffect(() => { fetchTenants(); fetchStats(); }, [fetchTenants, fetchStats]);
useEffect(() => { if (tab === 'members' && selectedTenant) fetchMembers(); }, [tab, selectedTenant, fetchMembers]);
const handleCreate = async () => {
try {
const params = `?name=${encodeURIComponent(form.name)}&slug=${encodeURIComponent(form.slug)}&description=${encodeURIComponent(form.description)}&max_members=${form.max_members}&max_storage_mb=${form.max_storage_mb}&max_api_calls_per_day=${form.max_api_calls_per_day}`;
await adminAPI.createTenant(params);
setShowCreate(false);
setForm({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 });
fetchTenants();
} catch (e) { console.error('create:', e); }
};
const statusColors: Record<string, string> = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' };
const roleColors: Record<string, string> = { owner: '#a78bfa', admin: '#60a5fa', member: '#6B7280', viewer: '#9CA3AF' };
return (
<div style={{ padding: '24px', color: 'var(--color-text-primary)' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>多租户管理</h1>
{/* Stats cards */}
{stats && (
<div className="responsive-grid-auto" style={{ gap: '16px', marginBottom: '24px' }}>
{[
{ label: '总租户数', value: stats.total_tenants, color: '#6C63FF' },
{ label: '活跃租户', value: stats.active_tenants, color: '#10b981' },
{ label: '已暂停', value: stats.suspended_tenants, color: '#f59e0b' },
{ label: '总成员数', value: stats.total_members, color: '#3b82f6' },
{ label: '待处理邀请', value: stats.pending_invitations, color: '#ec4899' },
].map((s, i) => (
<div key={i} style={{ background: '#FFFFFF', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: '32px', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '13px', color: '#6B7280', marginTop: '4px' }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => (
<button key={k} onClick={() => setTab(k)}
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: tab === k ? '#6C63FF' : '#F0F2F8', color: tab === k ? '#fff' : '#6B7280',
fontWeight: 600, fontSize: '14px' }}>{label}</button>
))}
{tab === 'list' && (
<button onClick={() => setShowCreate(true)} style={{ marginLeft: 'auto', padding: '8px 20px', borderRadius: '8px',
border: 'none', cursor: 'pointer', background: '#6C63FF', color: '#fff', fontWeight: 600, fontSize: '14px' }}>
+ 创建租户
</button>
)}
</div>
{/* Tab content */}
{tab === 'list' && (
<div>
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#9CA3AF' }}>加载中...</div> : (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead>
<tr style={{ background: '#F8F9FC' }}>
{['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{tenants.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{t.name}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.slug}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
background: `${statusColors[t.status]}22`, color: statusColors[t.status] }}>{t.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.member_count ?? '-'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{(t.max_storage_mb / 1024).toFixed(1)} GB</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{t.max_api_calls_per_day.toLocaleString()}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{t.created_at?.substring(0, 10)}</td>
<td style={{ padding: '12px 16px' }}>
<button onClick={() => { setSelectedTenant(t.id); setTab('members'); }}
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)',
background: 'transparent', color: '#6B7280', cursor: 'pointer', fontSize: '12px' }}>管理</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{total > 20 && (
<div className="pagination-bar" style={{ marginTop: '20px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ padding: '6px 16px', color: '#6B7280' }}>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.12)', background: '#FFFFFF', color: '#6B7280', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</div>
)}
{tab === 'members' && (
<div>
<div style={{ marginBottom: '16px' }}>
<select value={selectedTenant} onChange={e => setSelectedTenant(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '8px', background: '#FFFFFF', color: '#1A1F36', border: '1px solid rgba(0,0,0,0.12)', fontSize: '14px' }}>
<option value="">选择租户...</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{selectedTenant && (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#FFFFFF', borderRadius: '12px', overflow: 'hidden', border: '1px solid rgba(0,0,0,0.08)' }}>
<thead>
<tr style={{ background: '#F8F9FC' }}>
{['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#6B7280', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{members.map(m => (
<tr key={m.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{m.username || m.user_id.substring(0, 8)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#6B7280' }}>{m.email || '-'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
background: `${roleColors[m.role]}22`, color: roleColors[m.role] }}>{m.role}</span>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: m.status === 'active' ? '#10b981' : '#ef4444', fontSize: '13px' }}>{m.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#9CA3AF' }}>{m.joined_at?.substring(0, 16)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{tab === 'stats' && stats && (
<div style={{ background: '#FFFFFF', borderRadius: '12px', padding: '32px', border: '1px solid rgba(0,0,0,0.08)', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
<div className="responsive-grid-2" style={{ gap: '24px' }}>
{Object.entries(stats).map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#F8F9FC', borderRadius: '8px' }}>
<span style={{ fontSize: '14px', color: '#6B7280' }}>{k.replace(/_/g, ' ').toUpperCase()}</span>
<span style={{ fontSize: '24px', fontWeight: 800, color: '#6C63FF' }}>{String(v)}</span>
</div>
))}
</div>
</div>
)}
{/* Create modal */}
{showCreate && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setShowCreate(false)}>
<div onClick={e => e.stopPropagation()} style={{ background: '#FFFFFF', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw', boxShadow: '0 4px 24px rgba(0,0,0,0.12)' }}>
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px', color: '#1A1F36' }}>创建租户</h2>
{[
{ k: 'name', label: '租户名称', type: 'text' },
{ k: 'slug', label: '标识(英文)', type: 'text' },
{ k: 'description', label: '描述', type: 'text' },
{ k: 'max_members', label: '最大成员数', type: 'number' },
{ k: 'max_storage_mb', label: '存储上限(MB)', type: 'number' },
{ k: 'max_api_calls_per_day', label: 'API调用/天', type: 'number' },
].map(f => (
<div key={f.k} style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '13px', color: '#6B7280', marginBottom: '6px' }}>{f.label}</label>
<input type={f.type} value={(form as any)[f.k]} onChange={e => setForm({ ...form, [f.k]: f.type === 'number' ? Number(e.target.value) : e.target.value })}
style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', background: '#F8F9FC', color: '#1A1F36', border: '1px solid rgba(0,0,0,0.12)', fontSize: '14px', boxSizing: 'border-box' }} />
</div>
))}
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button onClick={() => setShowCreate(false)} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid rgba(0,0,0,0.12)', background: 'transparent', color: '#6B7280', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleCreate} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', background: '#6C63FF', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>创建</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Tenant;
+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;
}
}
+295
View File
@@ -0,0 +1,295 @@
import React, { useState, useMemo } from 'react';
import { adminAPI } from '@/api';
import { mockUsers, mockMembershipPlans } from '@/mock/data';
import type { User } 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 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);
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 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>
{/* User Detail Modal */}
{selectedUser && (
<UserDetailModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
/>
)}
</div>
);
};
export default UsersPage;
+755
View File
@@ -0,0 +1,755 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom";
interface WorkflowNode {
id: string;
type: string;
position: { x: number; y: number };
data: { label: string; config: Record<string, any> };
}
interface WorkflowEdge {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
}
interface Workflow {
id: string;
name: string;
description: string;
agent_id: string | null;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
variables: Record<string, any>;
status: string;
version: number;
created_at: string;
updated_at: string;
}
interface NodeTemplate {
type: string;
label: string;
icon: string;
color: string;
description: string;
config_schema: Record<string, any>;
}
const NODE_COLORS: Record<string, string> = {
start: "#3B6D11",
llm: "#534AB7",
knowledge: "#185FA5",
skill: "#0F6E56",
condition: "#BA7517",
http: "#D4537E",
code: "#5F5E5A",
agent: "#7C3AED",
end: "#A32D2D",
};
const NODE_LABELS: Record<string, string> = {
start: "开始",
llm: "LLM 调用",
knowledge: "知识库检索",
skill: "Skill 调用",
condition: "条件分支",
http: "HTTP 请求",
code: "代码执行",
agent: "Agent 节点",
end: "结束",
};
export default function Workflow() {
const navigate = useNavigate();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<"list" | "editor">("list");
const [currentWf, setCurrentWf] = useState<Workflow | null>(null);
const [templates, setTemplates] = useState<NodeTemplate[]>([]);
const [options, setOptions] = useState<Record<string, any[]>>({});
const [selectedNode, setSelectedNode] = useState<WorkflowNode | null>(null);
const [draggingNode, setDraggingNode] = useState<string | null>(null);
const [connectingFrom, setConnectingFrom] = useState<string | null>(null);
const [executeResult, setExecuteResult] = useState<any>(null);
const [executeInput, setExecuteInput] = useState("");
const [showExecute, setShowExecute] = useState(false);
const [executing, setExecuting] = useState(false);
const [nodeStatus, setNodeStatus] = useState<Record<string, string>>({});
const [stats, setStats] = useState({ total: 0, draft: 0, published: 0, archived: 0 });
const canvasRef = useRef<HTMLDivElement>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const api = (window as any).api;
// Load workflows
const loadWorkflows = useCallback(async () => {
setLoading(true);
try {
const res = await api.get("/admin/workflows/");
if (res.code === 0) setWorkflows(res.data.items || []);
} catch (e) { console.error(e); }
try {
const s = await api.get("/admin/workflows/stats");
if (s.code === 0) setStats(s.data);
} catch (e) { console.error(e); }
setLoading(false);
}, []);
// Load templates
const loadTemplates = useCallback(async () => {
try {
const res = await api.get("/admin/workflows/templates");
if (res.code === 0) {
setTemplates(Object.values(res.data.node_templates || {}));
setOptions(res.data.options || {});
}
} catch (e) { console.error(e); }
}, []);
useEffect(() => { loadWorkflows(); loadTemplates(); }, []);
// Create new workflow
const handleCreate = async () => {
const name = prompt("工作流名称:", "新工作流");
if (!name) return;
try {
const res = await api.post("/admin/workflows/", {
name,
description: "",
nodes: [
{ id: "start-1", type: "start", position: { x: 100, y: 200 }, data: { label: "开始", config: { input_variable: "input" } } },
{ id: "end-1", type: "end", position: { x: 500, y: 200 }, data: { label: "结束", config: { output_variable: "output" } } },
],
edges: [],
});
if (res.code === 0) {
loadWorkflows();
handleOpenEditor(res.data);
}
} catch (e) { console.error(e); }
};
// Open editor
const handleOpenEditor = (wf: Workflow) => {
setCurrentWf(wf);
setSelectedNode(null);
setView("editor");
};
// Save workflow
const handleSave = async () => {
if (!currentWf) return;
try {
const res = await api.put(`/admin/workflows/${currentWf.id}`, {
name: currentWf.name,
description: currentWf.description,
nodes: currentWf.nodes,
edges: currentWf.edges,
variables: currentWf.variables,
});
if (res.code === 0) {
setCurrentWf(res.data);
alert("保存成功");
}
} catch (e) { console.error(e); }
};
// Delete workflow
const handleDelete = async (id: string) => {
if (!confirm("确认删除此工作流?")) return;
try {
await api.del(`/admin/workflows/${id}`);
loadWorkflows();
} catch (e) { console.error(e); }
};
// Toggle publish
const handleToggle = async (wf: Workflow) => {
const isActive = wf.status !== "published";
try {
await api.put(`/admin/workflows/${wf.id}/toggle?is_active=${isActive}`);
loadWorkflows();
} catch (e) { console.error(e); }
};
// Add node to canvas
const handleAddNode = (template: NodeTemplate) => {
if (!currentWf) return;
const id = `${template.type}-${Date.now()}`;
const newNode: WorkflowNode = {
id,
type: template.type,
position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 },
data: {
label: template.label,
config: Object.fromEntries(
Object.entries(template.config_schema).map(([k, v]: [string, any]) => [k, v.default ?? ""])
),
},
};
setCurrentWf({ ...currentWf, nodes: [...currentWf.nodes, newNode] });
};
// Node drag
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
const node = currentWf!.nodes.find(n => n.id === nodeId);
if (!node || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
dragOffset.current = {
x: e.clientX - rect.left - node.position.x,
y: e.clientY - rect.top - node.position.y,
};
setDraggingNode(nodeId);
setSelectedNode(node);
};
const handleCanvasMouseMove = (e: React.MouseEvent) => {
if (!draggingNode || !currentWf || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left - dragOffset.current.x;
const y = e.clientY - rect.top - dragOffset.current.y;
setCurrentWf({
...currentWf,
nodes: currentWf.nodes.map(n => n.id === draggingNode ? { ...n, position: { x, y } } : n),
});
};
const handleCanvasMouseUp = () => { setDraggingNode(null); };
// Canvas click (deselect / cancel connecting)
const handleCanvasClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
setSelectedNode(null);
setConnectingFrom(null);
}
};
// Start connecting from a node
const handleStartConnect = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
setConnectingFrom(nodeId);
};
// Complete connection to a node
const handleEndConnect = (e: React.MouseEvent, nodeId: string) => {
e.stopPropagation();
if (!connectingFrom || !currentWf) return;
if (connectingFrom === nodeId) { setConnectingFrom(null); return; }
// Check if edge already exists
const exists = currentWf.edges.some(e => e.source === connectingFrom && e.target === nodeId);
if (exists) { setConnectingFrom(null); return; }
const newEdge: WorkflowEdge = {
id: `e-${Date.now()}`,
source: connectingFrom,
target: nodeId,
};
setCurrentWf({ ...currentWf, edges: [...currentWf.edges, newEdge] });
setConnectingFrom(null);
};
// Delete edge
const handleDeleteEdge = (edgeId: string) => {
if (!currentWf) return;
setCurrentWf({ ...currentWf, edges: currentWf.edges.filter(e => e.id !== edgeId) });
};
// Delete selected node
const handleDeleteNode = () => {
if (!selectedNode || !currentWf) return;
const nodeId = selectedNode.id;
setCurrentWf({
...currentWf,
nodes: currentWf.nodes.filter(n => n.id !== nodeId),
edges: currentWf.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
});
setSelectedNode(null);
};
// Update node config
const handleConfigChange = (key: string, value: any) => {
if (!selectedNode || !currentWf) return;
const updatedNodes = currentWf.nodes.map(n =>
n.id === selectedNode.id
? { ...n, data: { ...n.data, label: n.data.label, config: { ...n.data.config, [key]: value } } }
: n
);
setCurrentWf({ ...currentWf, nodes: updatedNodes });
setSelectedNode(updatedNodes.find(n => n.id === selectedNode.id) || null);
};
// Execute workflow (supports streaming via WebSocket when available)
const handleExecute = async () => {
if (!currentWf) return;
setExecuting(true);
setExecuteResult(null);
setNodeStatus({});
// Highlight running nodes in real-time
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsHost = window.location.host;
const wsUrl = `${wsProtocol}//${wsHost}/app/api/admin/workflows/${currentWf.id}/execute-stream`;
let ws: WebSocket | null = null;
let result: any = null;
try {
// Connect WebSocket
ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "node_start") {
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "running" }));
// Add to log
setExecuteResult((prev: any) => ({
...prev,
log: [...(prev?.log || []), {
node_id: msg.node_id,
node_label: msg.node_label,
node_type: msg.node_type,
success: null,
}],
}));
} else if (msg.type === "node_complete") {
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "completed" }));
// Update log entry
setExecuteResult((prev: any) => {
const log = (prev?.log || []).map((l: any) =>
l.node_id === msg.node_id ? { ...l, success: true, output_preview: msg.output_preview } : l
);
return { ...prev, log };
});
} else if (msg.type === "node_error") {
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "failed" }));
setExecuteResult((prev: any) => {
const log = (prev?.log || []).map((l: any) =>
l.node_id === msg.node_id ? { ...l, success: false, error: msg.error } : l
);
return { ...prev, log };
});
} else if (msg.type === "workflow_complete") {
result = msg.data;
setExecuteResult(msg.data);
} else if (msg.type === "workflow_error") {
result = msg.data;
setExecuteResult(msg.data);
}
} catch (e) { /* ignore parse errors */ }
};
ws.onopen = () => {
ws?.send(JSON.stringify({
input_text: executeInput,
token: (window as any).api?.getToken?.() || "",
}));
};
// Also call REST API as fallback
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
input_text: executeInput,
});
if (res.code === 0 && !result) {
setExecuteResult(res.data);
}
} catch (e) {
console.error(e);
// Try REST API fallback
try {
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
input_text: executeInput,
});
if (res.code === 0) setExecuteResult(res.data);
} catch (e2) { console.error(e2); }
} finally {
setExecuting(false);
setTimeout(() => {
if (ws?.readyState === WebSocket.OPEN) ws.close();
}, 1000);
}
};
// SVG edge path (bezier curve)
const getEdgePath = (src: WorkflowNode, tgt: WorkflowNode) => {
const x1 = src.position.x + 90;
const y1 = src.position.y + 25;
const x2 = tgt.position.x;
const y2 = tgt.position.y + 25;
const dx = Math.abs(x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
};
// ============================================================
// LIST VIEW
// ============================================================
if (view === "list") {
return (
<div style={{ padding: "24px", color: "#1A1F36", minHeight: "100vh" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
<div>
<h2 style={{ margin: 0, fontSize: "20px", color: "#1A1F36" }}>Agent 可视化编排</h2>
<p style={{ margin: "4px 0 0", color: "#6B7280", fontSize: "13px" }}>拖拽式 DAG 工作流编排引擎 · LLM/知识库/Skill/条件/HTTP/代码/Agent 节点 · 并行多 Agent 协作</p>
</div>
<button onClick={handleCreate} style={btnPrimary}>+ 新建工作流</button>
</div>
{/* Stats */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "16px", marginBottom: "24px" }}>
{[
{ label: "总工作流", value: stats.total, color: "#534AB7" },
{ label: "草稿", value: stats.draft, color: "#BA7517" },
{ label: "已发布", value: stats.published, color: "#3B6D11" },
{ label: "已归档", value: stats.archived, color: "#5F5E5A" },
].map(s => (
<div key={s.label} style={{ ...statCard, borderLeft: `3px solid ${s.color}` }}>
<div style={{ fontSize: "24px", fontWeight: 600, color: "#1A1F36" }}>{s.value}</div>
<div style={{ fontSize: "12px", color: "#6B7280" }}>{s.label}</div>
</div>
))}
</div>
{/* Workflow list */}
<div style={{ background: "#FFFFFF", borderRadius: "12px", overflow: "hidden" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#6B7280", fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#9CA3AF" }}>加载中...</td></tr>
) : workflows.length === 0 ? (
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#9CA3AF" }}>暂无工作流,点击"新建工作流"开始</td></tr>
) : workflows.map(wf => (
<tr key={wf.id} style={{ borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
<td style={{ padding: "12px 16px" }}>
<div style={{ color: "#1A1F36", fontWeight: 500 }}>{wf.name}</div>
<div style={{ color: "#9CA3AF", fontSize: "12px" }}>{wf.description || "—"}</div>
</td>
<td style={{ padding: "12px 16px" }}>
<span style={statusBadge(wf.status)}>{wf.status === "published" ? "已发布" : wf.status === "archived" ? "已归档" : "草稿"}</span>
</td>
<td style={{ padding: "12px 16px", color: "#9CA3AF" }}>{wf.nodes?.length || 0}</td>
<td style={{ padding: "12px 16px", color: "#9CA3AF" }}>v{wf.version}</td>
<td style={{ padding: "12px 16px", color: "#6B7280", fontSize: "12px" }}>{wf.updated_at?.slice(0, 19).replace("T", " ") || "—"}</td>
<td style={{ padding: "12px 16px" }}>
<button onClick={() => handleOpenEditor(wf)} style={btnSmall}>编辑</button>
<button onClick={() => handleToggle(wf)} style={{ ...btnSmall, marginLeft: "4px" }}>{wf.status === "published" ? "归档" : "发布"}</button>
<button onClick={() => handleDelete(wf.id)} style={{ ...btnSmall, marginLeft: "4px", color: "#F09595" }}>删除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ============================================================
// EDITOR VIEW
// ============================================================
if (!currentWf) return null;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: "#F8F9FC", color: "#1A1F36" }}>
{/* Toolbar */}
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#FFFFFF", borderBottom: "1px solid rgba(0,0,0,0.08)", gap: "8px" }}>
<button onClick={() => { setView("list"); loadWorkflows(); }} style={btnSmall}>← 返回</button>
<input
value={currentWf.name}
onChange={e => setCurrentWf({ ...currentWf, name: e.target.value })}
style={{ background: "transparent", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "6px", padding: "4px 12px", color: "#1A1F36", fontSize: "14px", width: "200px" }}
/>
<span style={{ color: "#9CA3AF", fontSize: "12px" }}>v{currentWf.version} · {currentWf.nodes.length} 节点 · {currentWf.edges.length} 连线</span>
<div style={{ flex: 1 }} />
<button onClick={handleSave} style={btnPrimary}>保存</button>
<button onClick={() => setShowExecute(!showExecute)} style={{ ...btnSmall, borderColor: "#3B6D11" }}>执行</button>
{selectedNode && <button onClick={handleDeleteNode} style={{ ...btnSmall, color: "#F09595" }}>删除节点</button>}
</div>
{/* Execute panel */}
{showExecute && (
<div style={{ padding: "12px 16px", background: "#FFFFFF", borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "12px", color: "#6B7280" }}>输入文本</label>
<textarea
value={executeInput}
onChange={e => setExecuteInput(e.target.value)}
placeholder="输入测试文本..."
style={{ width: "100%", background: "#F8F9FC", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "6px", padding: "8px", color: "#1A1F36", fontSize: "13px", minHeight: "40px", resize: "vertical" }}
/>
</div>
<button onClick={handleExecute} style={{ ...btnPrimary, opacity: executing ? 0.6 : 1 }} disabled={executing}>
{executing ? "执行中..." : "执行工作流"}
</button>
</div>
{executing && (
<div style={{ marginTop: "8px", fontSize: "12px", color: "#534AB7" }}>
⏳ 工作流执行中...
</div>
)}
{executeResult && (
<div style={{ marginTop: "12px" }}>
<div style={{ fontSize: "12px", color: "#6B7280", marginBottom: "4px" }}>执行日志:</div>
{executeResult.log?.map((l: any, i: number) => (
<div key={i} style={{ fontSize: "12px", padding: "4px 8px", background: l.success ? "rgba(59,109,17,0.15)" : "rgba(163,45,45,0.15)", borderRadius: "4px", marginBottom: "2px" }}>
<span style={{ color: l.success ? "#97C459" : "#F09595" }}>[{l.node_type}]</span>{" "}
<span style={{ color: "#9CA3AF" }}>{l.node_label}</span>
{l.output_preview && <span style={{ color: "#9CA3AF" }}> → {l.output_preview.slice(0, 100)}</span>}
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
</div>
))}
<div style={{ marginTop: "8px", padding: "8px", background: "#F8F9FC", borderRadius: "6px", border: "1px solid rgba(0,0,0,0.08)" }}>
<div style={{ fontSize: "12px", color: "#6B7280", marginBottom: "4px" }}>最终输出:</div>
<pre style={{ margin: 0, color: "#97C459", fontSize: "13px", whiteSpace: "pre-wrap" }}>{executeResult.output}</pre>
</div>
</div>
)}
</div>
)}
{/* Main area: palette | canvas | config */}
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{/* Node palette */}
<div style={{ width: "180px", background: "#FFFFFF", borderRight: "1px solid rgba(0,0,0,0.08)", padding: "12px", overflowY: "auto" }}>
<div style={{ fontSize: "12px", color: "#9CA3AF", marginBottom: "8px", textTransform: "uppercase" }}>节点类型</div>
{Object.entries(NODE_LABELS).map(([type, label]) => (
<div
key={type}
onClick={() => {
const tmpl = templates.find(t => t.type === type);
if (tmpl) handleAddNode(tmpl);
else handleAddNode({ type, label, icon: "", color: NODE_COLORS[type], description: "", config_schema: {} });
}}
style={{
padding: "8px 12px",
marginBottom: "4px",
borderRadius: "6px",
cursor: "pointer",
background: "#FFFFFF",
border: "1px solid rgba(0,0,0,0.08)",
borderLeft: `3px solid ${NODE_COLORS[type]}`,
transition: "background 0.2s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "#E8ECF4")}
onMouseLeave={e => (e.currentTarget.style.background = "#FFFFFF")}
>
<span style={{ color: "#1A1F36", fontSize: "13px" }}>{label}</span>
</div>
))}
</div>
{/* Canvas */}
<div
ref={canvasRef}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
style={{
flex: 1,
position: "relative",
overflow: "hidden",
background: "#F8F9FC",
}}
>
{/* SVG for edges */}
<svg style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
{currentWf.edges.map(edge => {
const src = currentWf.nodes.find(n => n.id === edge.source);
const tgt = currentWf.nodes.find(n => n.id === edge.target);
if (!src || !tgt) return null;
return (
<g key={edge.id} style={{ pointerEvents: "auto" }}>
<path d={getEdgePath(src, tgt)} fill="none" stroke="#534AB7" strokeWidth={2} markerEnd="url(#arrowhead)" />
<circle cx={(src.position.x + tgt.position.x + 90) / 2} cy={(src.position.y + tgt.position.y + 25) / 2} r={8} fill="#FFFFFF" stroke="#534AB7" strokeWidth={1} style={{ cursor: "pointer" }} onClick={() => handleDeleteEdge(edge.id)} />
<text x={(src.position.x + tgt.position.x + 90) / 2} y={(src.position.y + tgt.position.y + 25) / 2 + 3} textAnchor="middle" fill="#F09595" fontSize="10" style={{ pointerEvents: "none" }}>x</text>
</g>
);
})}
<defs>
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#534AB7" />
</marker>
</defs>
</svg>
{/* Nodes */}
{currentWf.nodes.map(node => {
const isSelected = selectedNode?.id === node.id;
const status = nodeStatus[node.id];
const baseColor = NODE_COLORS[node.type] || "#5F5E5A";
const color = status === "running" ? "#58a6ff" : status === "completed" ? "#3fb950" : status === "failed" ? "#f85149" : baseColor;
const borderWidth = isSelected || status ? 2 : 1;
return (
<div
key={node.id}
onMouseDown={e => handleNodeMouseDown(e, node.id)}
onClick={e => { e.stopPropagation(); setSelectedNode(node); }}
style={{
position: "absolute",
left: node.position.x,
top: node.position.y,
width: "90px",
minHeight: "50px",
background: "#FFFFFF",
border: `${borderWidth}px solid ${color}`,
borderRadius: "8px",
cursor: draggingNode === node.id ? "grabbing" : "grab",
padding: "8px",
textAlign: "center",
boxShadow: isSelected ? `0 0 12px ${color}40` : "none",
zIndex: isSelected ? 10 : 1,
}}
>
<div style={{ fontSize: "11px", color, fontWeight: 600, marginBottom: "2px" }}>{NODE_LABELS[node.type] || node.type}</div>
<div style={{ fontSize: "11px", color: "#9CA3AF" }}>{node.data.label}</div>
{/* Connection handles */}
<div
onClick={e => connectingFrom ? handleEndConnect(e, node.id) : handleStartConnect(e, node.id)}
style={{
position: "absolute", right: -6, top: "50%", transform: "translateY(-50%)",
width: 12, height: 12, borderRadius: "50%",
background: connectingFrom === node.id ? "#97C459" : "#534AB7",
cursor: "pointer", border: "2px solid #1A1F36",
}}
/>
<div
onClick={e => connectingFrom && handleEndConnect(e, node.id)}
style={{
position: "absolute", left: -6, top: "50%", transform: "translateY(-50%)",
width: 12, height: 12, borderRadius: "50%",
background: "#9CA3AF", cursor: "pointer", border: "2px solid #FFFFFF",
}}
/>
</div>
);
})}
{connectingFrom && (
<div style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", background: "#534AB7", color: "#fff", padding: "4px 12px", borderRadius: "6px", fontSize: "12px" }}>
点击目标节点的右端口完成连线
</div>
)}
</div>
{/* Config panel */}
<div style={{ width: "280px", background: "#FFFFFF", borderLeft: "1px solid rgba(0,0,0,0.08)", padding: "16px", overflowY: "auto" }}>
{selectedNode ? (
<>
<div style={{ fontSize: "14px", fontWeight: 600, color: "#1A1F36", marginBottom: "4px" }}>
{NODE_LABELS[selectedNode.type] || selectedNode.type}
</div>
<div style={{ fontSize: "12px", color: "#9CA3AF", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
<div style={{ marginBottom: "12px" }}>
<label style={{ fontSize: "12px", color: "#6B7280" }}>显示名称</label>
<input
value={selectedNode.data.label}
onChange={e => {
const updated = currentWf.nodes.map(n => n.id === selectedNode.id ? { ...n, data: { ...n.data, label: e.target.value } } : n);
setCurrentWf({ ...currentWf, nodes: updated });
setSelectedNode({ ...selectedNode, data: { ...selectedNode.data, label: e.target.value } });
}}
style={configInput}
/>
</div>
{/* Config fields */}
{Object.entries(templates.find(t => t.type === selectedNode.type)?.config_schema || {}).map(([key, schema]: [string, any]) => (
<div key={key} style={{ marginBottom: "12px" }}>
<label style={{ fontSize: "12px", color: "#6B7280" }}>{schema.label || key}</label>
{schema.help && <div style={{ fontSize: "11px", color: "#9CA3AF", marginBottom: "2px" }}>{schema.help}</div>}
{schema.type === "textarea" ? (
<textarea
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, e.target.value)}
style={{ ...configInput, minHeight: "60px", resize: "vertical" }}
/>
) : schema.type === "select" && schema.options_from ? (
<select
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, e.target.value)}
style={configInput}
>
<option value="">-- 选择 --</option>
{(options[schema.options_from] || []).map((opt: any) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : schema.type === "select" && schema.options ? (
<select
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, e.target.value)}
style={configInput}
>
<option value="">-- 选择 --</option>
{schema.options.map((opt: string) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={schema.type === "number" ? "number" : "text"}
value={selectedNode.data.config[key] ?? ""}
onChange={e => handleConfigChange(key, schema.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value)}
style={configInput}
/>
)}
</div>
))}
</>
) : (
<div style={{ color: "#9CA3AF", fontSize: "13px", textAlign: "center", marginTop: "40px" }}>
选择一个节点查看配置
</div>
)}
</div>
</div>
</div>
);
}
// ============================================================
// Styles
// ============================================================
const btnPrimary: React.CSSProperties = {
background: "#534AB7", color: "#fff", border: "none", borderRadius: "6px",
padding: "6px 16px", cursor: "pointer", fontSize: "13px", fontWeight: 500,
};
const btnSmall: React.CSSProperties = {
background: "transparent", color: "#9CA3AF", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "4px",
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
};
const statCard: React.CSSProperties = {
background: "#FFFFFF", borderRadius: "8px", padding: "16px", border: "1px solid rgba(0,0,0,0.08)",
};
const configInput: React.CSSProperties = {
width: "100%", background: "#F8F9FC", border: "1px solid rgba(0,0,0,0.08)",
borderRadius: "4px", padding: "6px 8px", color: "#1A1F36", fontSize: "13px",
outline: "none",
};
function statusBadge(status: string): React.CSSProperties {
const colors: Record<string, string> = {
published: "#3B6D11",
draft: "#BA7517",
archived: "#5F5E5A",
};
const bg = colors[status] || "#5F5E5A";
return {
background: `${bg}20`, color: bg, padding: "2px 8px",
borderRadius: "4px", fontSize: "12px",
};
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff