This commit is contained in:
wangli 2026-01-21 00:37:59 +08:00
commit 59408d1d80
66 changed files with 23544 additions and 0 deletions

26
index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>闲鱼自动回复管理系统</title>
<!-- 在页面加载前立即设置主题,避免闪白 -->
<script>
(function() {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6496
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

51
package.json Normal file
View File

@ -0,0 +1,51 @@
{
"name": "xianyu-admin-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^10.18.0",
"lucide-react": "^0.309.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"tailwind-merge": "^2.2.0",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.17.0",
"@typescript-eslint/parser": "^6.17.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

3924
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

10
public/favicon.svg Normal file
View File

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3B82F6"/>
<stop offset="100%" style="stop-color:#1D4ED8"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="url(#grad)"/>
<path d="M16 7C11.03 7 7 10.58 7 15c0 2.5 1.4 4.7 3.5 6.1L9 25l4.5-2.3c.8.2 1.6.3 2.5.3 4.97 0 9-3.58 9-8s-4.03-8-9-8z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

BIN
public/static/qq-group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
public/static/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

180
src/App.tsx Normal file
View File

@ -0,0 +1,180 @@
import React, { useEffect, useState, useRef } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { MainLayout } from '@/components/layout/MainLayout'
import { Login } from '@/pages/auth/Login'
import { Register } from '@/pages/auth/Register'
import { Dashboard } from '@/pages/dashboard/Dashboard'
import { Accounts } from '@/pages/accounts/Accounts'
import { Items } from '@/pages/items/Items'
import { Orders } from '@/pages/orders/Orders'
import { Keywords } from '@/pages/keywords/Keywords'
import { About } from '@/pages/about/About'
import { Disclaimer } from '@/pages/disclaimer/Disclaimer'
import { Cards } from '@/pages/cards/Cards'
import { Delivery } from '@/pages/delivery/Delivery'
import { NotificationChannels } from '@/pages/notifications/NotificationChannels'
import { MessageNotifications } from '@/pages/notifications/MessageNotifications'
import { Settings } from '@/pages/settings/Settings'
import { ItemReplies } from '@/pages/item-replies/ItemReplies'
import { ItemSearch } from '@/pages/search/ItemSearch'
import { Users } from '@/pages/admin/Users'
import { Logs } from '@/pages/admin/Logs'
import { RiskLogs } from '@/pages/admin/RiskLogs'
import { DataManagement } from '@/pages/admin/DataManagement'
import { DisclaimerModal } from '@/components/common/DisclaimerModal'
import { verifyToken } from '@/api/auth'
import { Toast } from '@/components/common/Toast'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, setAuth, clearAuth, token: storeToken, _hasHydrated } = useAuthStore()
const [authState, setAuthState] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const [showDisclaimer, setShowDisclaimer] = useState(false)
const checkingRef = useRef(false)
useEffect(() => {
// 等待 zustand persist 完成 hydration
if (!_hasHydrated) {
return
}
// 防止并发检查
if (checkingRef.current) {
return
}
const checkAuth = async () => {
checkingRef.current = true
// 优先使用 store 中的 token其次是 localStorage
const token = storeToken || localStorage.getItem('auth_token')
if (!token) {
setAuthState('unauthenticated')
checkingRef.current = false
return
}
// 验证 token 有效性(不再单纯相信本地 isAuthenticated 状态)
try {
const result = await verifyToken()
if (result.authenticated && result.user_id) {
setAuth(token, {
user_id: result.user_id,
username: result.username || '',
is_admin: result.is_admin || false,
})
setAuthState('authenticated')
// 检查是否已同意免责声明(针对每个用户)
const disclaimerKey = `disclaimer_accepted_${result.user_id}`
const disclaimerAccepted = localStorage.getItem(disclaimerKey)
if (!disclaimerAccepted) {
setShowDisclaimer(true)
}
} else {
clearAuth()
setAuthState('unauthenticated')
}
} catch {
clearAuth()
setAuthState('unauthenticated')
} finally {
checkingRef.current = false
}
}
checkAuth()
}, [_hasHydrated, isAuthenticated, storeToken, setAuth, clearAuth])
const handleDisclaimerAgree = () => {
// 使用用户ID存储免责声明同意状态
const userId = useAuthStore.getState().user?.user_id
if (userId) {
localStorage.setItem(`disclaimer_accepted_${userId}`, 'true')
}
setShowDisclaimer(false)
}
const handleDisclaimerDisagree = () => {
clearAuth()
setShowDisclaimer(false)
setAuthState('unauthenticated')
}
// 等待 hydration 或检查完成
if (!_hasHydrated || authState === 'checking') {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)
}
if (authState === 'unauthenticated') {
return <Navigate to="/login" replace />
}
return (
<>
{children}
<DisclaimerModal
isOpen={showDisclaimer}
onAgree={handleDisclaimerAgree}
onDisagree={handleDisclaimerDisagree}
/>
</>
)
}
function App() {
return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
{/* 全局 Toast 组件 */}
<Toast />
<Routes>
{/* Public routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="accounts" element={<Accounts />} />
<Route path="items" element={<Items />} />
<Route path="orders" element={<Orders />} />
<Route path="keywords" element={<Keywords />} />
<Route path="item-replies" element={<ItemReplies />} />
<Route path="cards" element={<Cards />} />
<Route path="delivery" element={<Delivery />} />
<Route path="notification-channels" element={<NotificationChannels />} />
<Route path="message-notifications" element={<MessageNotifications />} />
<Route path="item-search" element={<ItemSearch />} />
<Route path="settings" element={<Settings />} />
<Route path="disclaimer" element={<Disclaimer />} />
<Route path="about" element={<About />} />
{/* Admin routes */}
<Route path="admin/users" element={<Users />} />
<Route path="admin/logs" element={<Logs />} />
<Route path="admin/risk-logs" element={<RiskLogs />} />
<Route path="admin/data" element={<DataManagement />} />
</Route>
{/* Catch all */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
)
}
export default App

202
src/api/accounts.ts Normal file
View File

@ -0,0 +1,202 @@
import { get, post, put, del } from '@/utils/request'
import type { Account, AccountDetail, ApiResponse } from '@/types'
// 获取账号列表(返回账号对象数组)
export const getAccounts = async (): Promise<Account[]> => {
interface BackendAccount {
id: string
value: string
remark?: string
username?: string
password?: string
enabled: boolean
created_at?: string
updated_at?: string
user_id?: number
auto_confirm: boolean
pause_duration?: number
show_browser?: boolean
}
const data = await get<BackendAccount[]>('/cookies')
// 后端返回的是完整账号对象数组转换为前端Account格式
return data.map(item => ({
id: item.id,
cookie: item.value || '',
remark: item.remark,
enabled: item.enabled,
use_ai_reply: false,
use_default_reply: false,
auto_confirm: item.auto_confirm
}))
}
// 获取账号详情列表
export const getAccountDetails = async (): Promise<AccountDetail[]> => {
interface BackendAccountDetail {
id: string
value: string
enabled: boolean
auto_confirm: boolean
remark?: string
pause_duration?: number
username?: string
login_password?: string
show_browser?: boolean
}
const data = await get<BackendAccountDetail[]>('/cookies/details')
// 后端返回 value 字段,前端使用 cookie 字段
return data.map((item) => ({
id: item.id,
cookie: item.value,
remark: item.remark,
enabled: item.enabled,
auto_confirm: item.auto_confirm,
note: item.remark,
pause_duration: item.pause_duration,
username: item.username,
login_password: item.login_password,
show_browser: item.show_browser,
use_ai_reply: false,
use_default_reply: false,
}))
}
// 添加账号
export const addAccount = (data: { id: string; cookie: string }): Promise<ApiResponse> => {
// 后端需要 id 和 value 字段
return post('/cookies', { id: data.id, value: data.cookie })
}
// 更新账号 Cookie 值
export const updateAccountCookie = (id: string, value: string): Promise<ApiResponse> => {
return put(`/cookies/${id}`, { id, value })
}
// 更新账号启用/禁用状态
export const updateAccountStatus = (id: string, enabled: boolean): Promise<ApiResponse> => {
return put(`/cookies/${id}/status`, { enabled })
}
// 更新账号备注
export const updateAccountRemark = (id: string, remark: string): Promise<ApiResponse> => {
return put(`/cookies/${id}/remark`, { remark })
}
// 更新账号自动确认设置
export const updateAccountAutoConfirm = (id: string, autoConfirm: boolean): Promise<ApiResponse> => {
return put(`/cookies/${id}/auto-confirm`, { auto_confirm: autoConfirm })
}
// 更新账号暂停时间
export const updateAccountPauseDuration = (id: string, pauseDuration: number): Promise<ApiResponse> => {
return put(`/cookies/${id}/pause-duration`, { pause_duration: pauseDuration })
}
// 更新账号登录信息(用户名、密码、是否显示浏览器)
export const updateAccountLoginInfo = (id: string, data: {
username?: string
login_password?: string
show_browser?: boolean
}): Promise<ApiResponse> => {
return put(`/cookies/${id}/login-info`, data)
}
// 删除账号
export const deleteAccount = (id: string): Promise<ApiResponse> => {
return del(`/cookies/${id}`)
}
// 获取账号二维码登录
export const getQRCode = (accountId: string): Promise<{ success: boolean; qrcode_url?: string; token?: string }> => {
return post('/qrcode/generate', { account_id: accountId })
}
// 检查二维码登录状态
export const checkQRCodeStatus = (token: string): Promise<{ success: boolean; status: string; cookie?: string }> => {
return post('/qrcode/check', { token })
}
// 账号密码登录
export const passwordLogin = (data: { account_id: string; account: string; password: string; show_browser?: boolean }): Promise<ApiResponse> => {
return post('/password-login', data)
}
// 生成扫码登录二维码
export const generateQRLogin = (): Promise<{ success: boolean; session_id?: string; qr_code_url?: string; message?: string }> => {
return post('/qr-login/generate')
}
// 检查扫码登录状态
// 后端直接返回 { status: ..., message?: ..., account_info?: ... },没有 success 字段
export const checkQRLoginStatus = async (sessionId: string): Promise<{
success: boolean
status: 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'verification_required' | 'processing' | 'already_processed' | 'error'
message?: string
account_info?: {
account_id: string
is_new_account: boolean
}
}> => {
const result = await get<{
status: string
message?: string
account_info?: { account_id: string; is_new_account: boolean }
}>(`/qr-login/check/${sessionId}`)
// 后端没有返回 success 字段,根据 status 判断
return {
success: result.status !== 'error',
status: result.status as 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'verification_required' | 'processing' | 'already_processed' | 'error',
message: result.message,
account_info: result.account_info,
}
}
// 检查密码登录状态
export const checkPasswordLoginStatus = (sessionId: string): Promise<{
success: boolean
status: 'pending' | 'processing' | 'success' | 'failed' | 'verification_required'
message?: string
account_id?: string
}> => {
return get(`/password-login/status/${sessionId}`)
}
// AI 回复设置接口 - 与后端 AIReplySettings 模型对应
export interface AIReplySettings {
ai_enabled: boolean
model_name?: string
api_key?: string
base_url?: string
max_discount_percent?: number
max_discount_amount?: number
max_bargain_rounds?: number
custom_prompts?: string
// 兼容旧字段(前端内部使用)
enabled?: boolean
}
// 获取AI回复设置
export const getAIReplySettings = (cookieId: string): Promise<AIReplySettings> => {
return get(`/ai-reply-settings/${cookieId}`)
}
// 更新AI回复设置
export const updateAIReplySettings = (cookieId: string, settings: Partial<AIReplySettings>): Promise<ApiResponse> => {
// 转换字段名以匹配后端
const payload: Record<string, unknown> = {
ai_enabled: settings.ai_enabled ?? settings.enabled ?? false,
model_name: settings.model_name ?? 'qwen-plus',
api_key: settings.api_key ?? '',
base_url: settings.base_url ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1',
max_discount_percent: settings.max_discount_percent ?? 10,
max_discount_amount: settings.max_discount_amount ?? 100,
max_bargain_rounds: settings.max_bargain_rounds ?? 3,
custom_prompts: settings.custom_prompts ?? '',
}
return put(`/ai-reply-settings/${cookieId}`, payload)
}
// 获取所有账号的AI回复设置
export const getAllAIReplySettings = (): Promise<Record<string, AIReplySettings>> => {
return get('/ai-reply-settings')
}

228
src/api/admin.ts Normal file
View File

@ -0,0 +1,228 @@
import { del, get, post } from '@/utils/request'
import type { ApiResponse, User } from '@/types'
// ========== 用户管理 ==========
// 获取用户列表
export const getUsers = async (): Promise<{ success: boolean; data?: User[] }> => {
const result = await get<{ users: Array<{
id: number
username: string
email?: string
is_admin: boolean
cookie_count?: number
card_count?: number
}> }>('/admin/users')
// 后端返回 { users: [...] } 格式,转换字段名
const users: User[] = (result.users || []).map(u => ({
user_id: u.id,
username: u.username,
email: u.email,
is_admin: u.is_admin,
}))
return { success: true, data: users }
}
// TODO: 后端暂未实现 POST /admin/users 接口
// export const addUser = ...
// TODO: 后端暂未实现 PUT /admin/users/{userId} 接口
// export const updateUser = ...
// 删除用户
export const deleteUser = (userId: number): Promise<ApiResponse> => {
return del(`/admin/users/${userId}`)
}
// ========== 系统日志 ==========
export interface SystemLog {
id: string
level: 'info' | 'warning' | 'error'
message: string
module: string
created_at: string
}
// 获取系统日志
export const getSystemLogs = async (params?: { page?: number; limit?: number; level?: string }): Promise<{ success: boolean; data?: SystemLog[]; total?: number }> => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.limit) query.set('lines', String(params.limit)) // 后端用 lines 参数
if (params?.level) query.set('level', params.level.toUpperCase())
const result = await get<{ logs?: string[]; total?: number }>(`/admin/logs?${query.toString()}`)
// 后端返回 { logs: [...] } 格式,转换为 SystemLog 数组
const logs: SystemLog[] = (result.logs || []).map((log, index) => ({
id: String(index),
level: log.includes('ERROR') ? 'error' : log.includes('WARNING') ? 'warning' : 'info',
message: log,
module: 'system',
created_at: new Date().toISOString(),
}))
return { success: true, data: logs, total: result.total }
}
// 清空系统日志
export const clearSystemLogs = (): Promise<ApiResponse> => {
return post('/admin/logs/clear')
}
// ========== 风控日志 ==========
export interface RiskLog {
id: string
cookie_id: string
risk_type: string
message: string
processing_result: string
processing_status: string
error_message: string | null
created_at: string
updated_at: string
}
// 获取风控日志
export const getRiskLogs = async (params?: { page?: number; limit?: number; cookie_id?: string }): Promise<{ success: boolean; data?: RiskLog[]; total?: number }> => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.limit) query.set('limit', String(params.limit))
if (params?.cookie_id) query.set('cookie_id', params.cookie_id)
const result = await get<{ success: boolean; data?: Array<{
id: number
cookie_id: string
event_type: string
event_description: string
processing_result: string
processing_status: string
error_message: string | null
created_at: string
updated_at: string
cookie_name: string
}>; total?: number }>(`/admin/risk-control-logs?${query.toString()}`)
// 转换后端格式为前端格式
const logs: RiskLog[] = (result.data || []).map(item => ({
id: String(item.id),
cookie_id: item.cookie_id || item.cookie_name,
risk_type: item.event_type,
message: item.event_description || '',
processing_result: item.processing_result || '',
processing_status: item.processing_status || '',
error_message: item.error_message,
created_at: item.created_at,
updated_at: item.updated_at || '',
}))
return { success: true, data: logs, total: result.total }
}
// 清空风控日志
export const clearRiskLogs = async (cookieId?: string): Promise<ApiResponse> => {
const query = cookieId ? `?cookie_id=${cookieId}` : ''
return del(`/admin/risk-control-logs${query}`)
}
// ========== 数据管理 ==========
// 导出数据 - 后端只支持导出整个数据库
export const exportData = async (type: string): Promise<Blob> => {
// 后端 /admin/backup/download 不支持 type 参数,只能导出整个数据库
// 如果需要导出特定表数据,可以使用 /admin/data/{table_name} 获取后转换为 JSON
if (type === 'all') {
const token = localStorage.getItem('auth_token')
const response = await fetch(`/admin/backup/download?token=${token}`)
if (!response.ok) throw new Error('导出失败')
return response.blob()
}
// 导出特定表数据
const tableMap: Record<string, string> = {
accounts: 'cookies',
keywords: 'keywords',
items: 'item_info',
orders: 'orders',
cards: 'cards',
}
const tableName = tableMap[type] || type
const data = await get<{ data: unknown[] }>(`/admin/data/${tableName}`)
const jsonStr = JSON.stringify(data.data || [], null, 2)
return new Blob([jsonStr], { type: 'application/json' })
}
// 导入数据
export const importData = (formData: FormData): Promise<ApiResponse> => {
return post('/admin/backup/upload', formData)
}
// 清理数据
export const cleanupData = (type: string): Promise<ApiResponse> => {
// 清理类型映射表名
const tableMap: Record<string, string> = {
logs: 'logs',
orders: 'orders',
cards_used: 'cards',
all_data: 'all',
}
const tableName = tableMap[type] || type
// 如果是清空日志,使用通用接口
if (type === 'logs') {
return post('/logs/clear')
}
return del(`/admin/data/${tableName}`)
}
// 获取表数据
export interface TableData {
success: boolean
data: Record<string, unknown>[]
columns: string[]
count: number
}
export const getTableData = async (tableName: string): Promise<TableData> => {
return get<TableData>(`/admin/data/${tableName}`)
}
// 清空表数据
export const clearTableData = (tableName: string): Promise<ApiResponse> => {
return del(`/admin/data/${tableName}`)
}
// 删除表记录
export const deleteTableRecord = (tableName: string, recordId: string): Promise<ApiResponse> => {
return del(`/admin/data/${tableName}/${recordId}`)
}
// ========== 日志管理 ==========
// 获取日志文件列表
export const getLogFiles = async (): Promise<{ files: string[] }> => {
return get('/admin/log-files')
}
// 导出日志
export const exportLogs = (): string => {
const token = localStorage.getItem('auth_token')
return `/admin/logs/export?token=${token}`
}
// ========== 管理员统计 ==========
export interface AdminStats {
total_users: number
total_cookies: number
total_cards: number
total_keywords: number
total_orders: number
active_cookies: number
}
// 获取管理员统计数据
export const getAdminStats = async (): Promise<{ success: boolean; data?: AdminStats }> => {
try {
const data = await get<AdminStats>('/admin/stats')
return { success: true, data }
} catch {
return { success: false }
}
}

116
src/api/auth.ts Normal file
View File

@ -0,0 +1,116 @@
import { post, get } from '@/utils/request'
import type { LoginRequest, LoginResponse, ApiResponse } from '@/types'
// 用户登录
export const login = (data: LoginRequest): Promise<LoginResponse> => {
return post('/login', data)
}
// 验证 Token
export const verifyToken = (): Promise<{ authenticated: boolean; user_id?: number; username?: string; is_admin?: boolean }> => {
return get('/verify')
}
// 用户登出
export const logout = (): Promise<ApiResponse> => {
return post('/logout')
}
// 获取注册状态
export const getRegistrationStatus = async (): Promise<{ enabled: boolean }> => {
try {
const settings = await get<Record<string, any>>('/system-settings/public')
const value = settings.registration_enabled
return { enabled: value === true || value === 'true' || value === 1 || value === '1' }
} catch {
return { enabled: true }
}
}
// 获取登录信息显示状态
export const getLoginInfoStatus = async (): Promise<{ enabled: boolean }> => {
try {
const settings = await get<Record<string, any>>('/system-settings/public')
const value = settings.show_default_login_info
return { enabled: value === true || value === 'true' || value === 1 || value === '1' }
} catch {
return { enabled: true }
}
}
// 生成图形验证码
export const generateCaptcha = (sessionId: string): Promise<{ success: boolean; captcha_image?: string }> => {
return post('/generate-captcha', { session_id: sessionId })
}
// 验证图形验证码
export const verifyCaptcha = (sessionId: string, captchaCode: string): Promise<{ success: boolean }> => {
return post('/verify-captcha', { session_id: sessionId, captcha_code: captchaCode })
}
// 发送邮箱验证码
export const sendVerificationCode = (email: string, type: string, sessionId: string): Promise<ApiResponse> => {
return post('/send-verification-code', { email, type, session_id: sessionId })
}
// 用户注册
export const register = (data: {
username: string
password: string
email: string
verification_code: string
session_id: string
}): Promise<ApiResponse> => {
return post('/register', data)
}
// ==================== 极验滑动验证码 ====================
// 极验验证码初始化响应类型
export interface GeetestRegisterResponse {
success: boolean
code: number
message: string
data?: {
success: number
gt: string
challenge: string
new_captcha: boolean
}
}
// 极验二次验证响应类型
export interface GeetestValidateResponse {
success: boolean
code: number
message: string
}
// 获取极验验证码初始化参数
export const getGeetestRegister = (): Promise<GeetestRegisterResponse> => {
return get('/geetest/register')
}
// 极验二次验证
export const geetestValidate = (data: {
challenge: string
validate: string
seccode: string
}): Promise<GeetestValidateResponse> => {
return post('/geetest/validate', data)
}
// 获取登录验证码开关状态
export const getLoginCaptchaStatus = async (): Promise<{ enabled: boolean }> => {
try {
const settings = await get<Record<string, any>>('/system-settings/public')
const value = settings.login_captcha_enabled
// 如果没有设置,默认开启
if (value === undefined || value === null) {
return { enabled: true }
}
return { enabled: value === true || value === 'true' || value === 1 || value === '1' }
} catch {
return { enabled: true }
}
}

90
src/api/cards.ts Normal file
View File

@ -0,0 +1,90 @@
import { get, post, put, del } from '@/utils/request'
import type { ApiResponse } from '@/types'
// 卡券类型定义 - 与后端 AIReplySettings 模型对应
export interface CardData {
id?: number
name: string
type: 'api' | 'text' | 'data' | 'image'
description?: string
enabled?: boolean
delay_seconds?: number
is_multi_spec?: boolean
spec_name?: string
spec_value?: string
// 根据类型的不同配置
api_config?: {
url: string
method: string
timeout?: number
headers?: string
params?: string
}
text_content?: string
data_content?: string
image_url?: string
// 后端返回的额外字段
created_at?: string
updated_at?: string
user_id?: number
}
// 获取卡券列表
export const getCards = async (_accountId?: string): Promise<{ success: boolean; data?: CardData[] }> => {
const result = await get<CardData[] | { cards?: CardData[] }>('/cards')
// 后端可能返回数组或 { cards: [...] } 格式
const data = Array.isArray(result) ? result : (result.cards || [])
return { success: true, data }
}
// 获取单个卡券
export const getCard = (cardId: string): Promise<CardData> => {
return get(`/cards/${cardId}`)
}
// 创建卡券 - 匹配后端接口
export const createCard = (data: Omit<CardData, 'id' | 'created_at' | 'updated_at' | 'user_id'>): Promise<{ id: number; message: string }> => {
return post('/cards', data)
}
// 更新卡券
export const updateCard = (cardId: string, data: Partial<CardData>): Promise<ApiResponse> => {
return put(`/cards/${cardId}`, data)
}
// 删除卡券
export const deleteCard = (cardId: string): Promise<ApiResponse> => {
return del(`/cards/${cardId}`)
}
// 批量删除卡券
export const batchDeleteCards = (cardIds: number[]): Promise<ApiResponse> => {
return post('/cards/batch-delete', { ids: cardIds })
}
// 兼容旧接口 - 批量添加文本类型卡券
export const addCard = async (
_accountId: string,
data: { item_id: string; cards: string[] }
): Promise<ApiResponse> => {
// 为每个卡密创建一个文本类型卡券
const results = await Promise.all(
data.cards.map((cardContent, index) =>
createCard({
name: `商品${data.item_id}-卡密${index + 1}`,
type: 'text',
text_content: cardContent,
description: `商品ID: ${data.item_id}`,
enabled: true,
delay_seconds: 0,
})
)
)
return { success: true, message: `成功添加 ${results.length} 张卡券` }
}
// 导入卡券(从文本)
export const importCards = (accountId: string, data: { item_id: string; content: string }): Promise<ApiResponse> => {
const cards = data.content.split('\n').map(s => s.trim()).filter(Boolean)
return addCard(accountId, { item_id: data.item_id, cards })
}

30
src/api/delivery.ts Normal file
View File

@ -0,0 +1,30 @@
import { get, post, put, del } from '@/utils/request'
import type { ApiResponse, DeliveryRule } from '@/types'
// 获取发货规则列表
export const getDeliveryRules = async (): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
const result = await get<DeliveryRule[] | { rules?: DeliveryRule[] }>('/delivery-rules')
// 后端可能返回数组或 { rules: [...] } 格式
const data = Array.isArray(result) ? result : (result.rules || [])
return { success: true, data }
}
// 添加发货规则
export const addDeliveryRule = (data: Partial<DeliveryRule>): Promise<ApiResponse> => {
return post('/delivery-rules', data)
}
// 更新发货规则
export const updateDeliveryRule = (ruleId: string, data: Partial<DeliveryRule>): Promise<ApiResponse> => {
return put(`/delivery-rules/${ruleId}`, data)
}
// 删除发货规则
export const deleteDeliveryRule = (ruleId: string): Promise<ApiResponse> => {
return del(`/delivery-rules/${ruleId}`)
}
// 获取账号的发货规则
export const getDeliveryRulesByAccount = (accountId: string): Promise<DeliveryRule[]> => {
return get(`/delivery-rules/${accountId}`)
}

10
src/api/index.ts Normal file
View File

@ -0,0 +1,10 @@
export * from './auth'
export * from './accounts'
export * from './items'
export * from './orders'
export * from './keywords'
export * from './cards'
export * from './delivery'
export * from './notifications'
export * from './settings'
export * from './admin'

75
src/api/items.ts Normal file
View File

@ -0,0 +1,75 @@
import { get, post, put, del } from '@/utils/request'
import type { Item, ItemReply, ApiResponse } from '@/types'
// 获取商品列表
export const getItems = async (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
const url = cookieId ? `/items/cookie/${cookieId}` : '/items'
const result = await get<{ items?: Item[] } | Item[]>(url)
// 后端返回 { items: [...] } 或直接返回数组
const items = Array.isArray(result) ? result : (result.items || [])
return { success: true, data: items }
}
// 删除商品
export const deleteItem = (cookieId: string, itemId: string): Promise<ApiResponse> => {
return del(`/items/${cookieId}/${itemId}`)
}
// 批量删除商品
export const batchDeleteItems = (ids: { cookie_id: string; item_id: string }[]): Promise<ApiResponse> => {
return del('/items/batch', { data: { items: ids } })
}
// 从账号获取商品(分页)
export const fetchItemsFromAccount = (cookieId: string, page?: number): Promise<ApiResponse> => {
return post('/items/get-by-page', { cookie_id: cookieId, page: page || 1 })
}
// 获取账号所有页商品
export const fetchAllItemsFromAccount = (cookieId: string): Promise<ApiResponse> => {
return post('/items/get-all-from-account', { cookie_id: cookieId })
}
// 更新商品
export const updateItem = (cookieId: string, itemId: string, data: Partial<Item>): Promise<ApiResponse> => {
return put(`/items/${cookieId}/${itemId}`, data)
}
// 获取商品回复列表
export const getItemReplies = async (cookieId?: string): Promise<{ success: boolean; data: ItemReply[] }> => {
const params = cookieId ? `/cookie/${cookieId}` : ''
const result = await get<{ items?: ItemReply[] } | ItemReply[]>(`/itemReplays${params}`)
// 后端返回 { items: [...] } 格式
const items = Array.isArray(result) ? result : (result.items || [])
return { success: true, data: items }
}
// 添加商品回复
export const addItemReply = (cookieId: string, itemId: string, data: Partial<ItemReply>): Promise<ApiResponse> => {
return put(`/item-reply/${cookieId}/${itemId}`, data)
}
// 更新商品回复
export const updateItemReply = (cookieId: string, itemId: string, data: Partial<ItemReply>): Promise<ApiResponse> => {
return put(`/item-reply/${cookieId}/${itemId}`, data)
}
// 删除商品回复
export const deleteItemReply = (cookieId: string, itemId: string): Promise<ApiResponse> => {
return del(`/item-reply/${cookieId}/${itemId}`)
}
// 批量删除商品回复
export const batchDeleteItemReplies = (items: { cookie_id: string; item_id: string }[]): Promise<ApiResponse> => {
return del('/item-reply/batch', { data: { items } })
}
// 更新商品多数量发货状态
export const updateItemMultiQuantityDelivery = (cookieId: string, itemId: string, enabled: boolean): Promise<ApiResponse> => {
return put(`/items/${cookieId}/${itemId}/multi-quantity-delivery`, { multi_quantity_delivery: enabled })
}
// 更新商品多规格状态
export const updateItemMultiSpec = (cookieId: string, itemId: string, enabled: boolean): Promise<ApiResponse> => {
return put(`/items/${cookieId}/${itemId}/multi-spec`, { is_multi_spec: enabled })
}

157
src/api/keywords.ts Normal file
View File

@ -0,0 +1,157 @@
import { get, post, put } from '@/utils/request'
import type { Keyword, ApiResponse } from '@/types'
// 获取关键词列表(包含 item_id 和 type
export const getKeywords = (cookieId: string): Promise<Keyword[]> => {
return get(`/keywords-with-item-id/${cookieId}`)
}
// 保存关键词列表(替换整个列表)
// 后端接口: POST /keywords-with-item-id/{cid}
// 请求体: { keywords: [{ keyword, reply, item_id }, ...] }
export const saveKeywords = (cookieId: string, keywords: Keyword[]): Promise<ApiResponse> => {
// 只发送文本类型的关键词,图片类型通过单独接口处理
const textKeywords = keywords
.filter(k => k.type !== 'image')
.map(k => ({
keyword: k.keyword,
reply: k.reply || '',
item_id: k.item_id || ''
}))
return post(`/keywords-with-item-id/${cookieId}`, { keywords: textKeywords })
}
// 添加关键词(先获取列表,添加后保存)
export const addKeyword = async (cookieId: string, data: Partial<Keyword>): Promise<ApiResponse> => {
const keywords = await getKeywords(cookieId)
// 检查是否已存在
const exists = keywords.some(k =>
k.keyword === data.keyword &&
(k.item_id || '') === (data.item_id || '')
)
if (exists) {
return { success: false, message: '该关键词已存在' }
}
keywords.push({
keyword: data.keyword || '',
reply: data.reply || '',
item_id: data.item_id || '',
type: 'text'
} as Keyword)
return saveKeywords(cookieId, keywords)
}
// 更新关键词
export const updateKeyword = async (
cookieId: string,
oldKeyword: string,
oldItemId: string,
data: Partial<Keyword>
): Promise<ApiResponse> => {
const keywords = await getKeywords(cookieId)
const index = keywords.findIndex(k =>
k.keyword === oldKeyword &&
(k.item_id || '') === (oldItemId || '')
)
if (index === -1) {
return { success: false, message: '关键词不存在' }
}
// 检查新关键词是否与其他关键词重复
if (data.keyword !== oldKeyword || data.item_id !== oldItemId) {
const duplicate = keywords.some((k, i) =>
i !== index &&
k.keyword === data.keyword &&
(k.item_id || '') === (data.item_id || '')
)
if (duplicate) {
return { success: false, message: '该关键词已存在' }
}
}
keywords[index] = { ...keywords[index], ...data }
return saveKeywords(cookieId, keywords)
}
// 删除关键词
export const deleteKeyword = async (
cookieId: string,
keyword: string,
itemId: string
): Promise<ApiResponse> => {
const keywords = await getKeywords(cookieId)
const filtered = keywords.filter(k =>
!(k.keyword === keyword && (k.item_id || '') === (itemId || ''))
)
if (filtered.length === keywords.length) {
return { success: false, message: '关键词不存在' }
}
return saveKeywords(cookieId, filtered)
}
// 批量添加关键词
export const batchAddKeywords = (cookieId: string, keywords: Partial<Keyword>[]): Promise<ApiResponse> => {
return post(`/keywords/${cookieId}/batch`, { keywords })
}
// 批量删除关键词
export const batchDeleteKeywords = (cookieId: string, keywordIds: string[]): Promise<ApiResponse> => {
return post(`/keywords/${cookieId}/batch-delete`, { keyword_ids: keywordIds })
}
// 获取默认回复
export const getDefaultReply = (cookieId: string): Promise<{ enabled?: boolean; reply_content?: string; reply_once?: boolean; reply_image_url?: string }> => {
return get(`/default-reply/${cookieId}`)
}
// 更新默认回复
export const updateDefaultReply = (cookieId: string, replyContent: string, enabled: boolean = true, replyOnce: boolean = false, replyImageUrl: string = ''): Promise<ApiResponse> => {
return put(`/default-reply/${cookieId}`, {
enabled,
reply_content: replyContent,
reply_once: replyOnce,
reply_image_url: replyImageUrl
})
}
// 导出关键词Excel/模板),返回 Blob 供前端触发下载
export const exportKeywords = async (cookieId: string): Promise<Blob> => {
const token = localStorage.getItem('auth_token')
const response = await fetch(`/keywords-export/${cookieId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('导出失败')
}
return response.blob()
}
// 导入关键词Excel上传文件并返回导入结果
export const importKeywords = async (
cookieId: string,
file: File
): Promise<ApiResponse<{ added: number; updated: number }>> => {
const formData = new FormData()
formData.append('file', file)
return post<ApiResponse<{ added: number; updated: number }>>(`/keywords-import/${cookieId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
// 添加图片关键词
export const addImageKeyword = async (
cookieId: string,
keyword: string,
image: File,
itemId?: string
): Promise<ApiResponse<{ keyword: string; image_url: string; item_id?: string }>> => {
const formData = new FormData()
formData.append('keyword', keyword)
formData.append('image', image)
if (itemId) {
formData.append('item_id', itemId)
}
return post(`/keywords/${cookieId}/image`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}

144
src/api/notifications.ts Normal file
View File

@ -0,0 +1,144 @@
import { del, get, post, put } from '@/utils/request'
import type { ApiResponse, MessageNotification, NotificationChannel } from '@/types'
// ========== 通知渠道 ==========
// 后端返回的通知渠道格式
interface BackendChannel {
id: number
name: string
type: string
config: string
enabled: boolean
created_at?: string
updated_at?: string
}
// 将前端的 config 对象/字符串序列化为后端需要的字符串格式
const serializeChannelConfig = (config: NotificationChannel['config'] | unknown): string => {
if (typeof config === 'string') {
return config
}
if (config && typeof config === 'object') {
try {
return JSON.stringify(config)
} catch {
return '{}'
}
}
return '{}'
}
// 获取通知渠道列表
export const getNotificationChannels = async (): Promise<{ success: boolean; data?: NotificationChannel[] }> => {
const result = await get<BackendChannel[]>('/notification-channels')
// 后端直接返回数组,需要转换格式
const channels: NotificationChannel[] = (result || []).map((item) => {
let parsedConfig: Record<string, unknown> | undefined
if (item.config) {
if (typeof item.config === 'string') {
try {
parsedConfig = JSON.parse(item.config)
} catch {
// 兼容旧数据或非法 JSON避免单条配置导致整个列表加载失败
parsedConfig = undefined
}
} else if (typeof item.config === 'object') {
parsedConfig = item.config as unknown as Record<string, unknown>
}
}
return {
id: String(item.id),
name: item.name,
type: item.type as NotificationChannel['type'],
config: parsedConfig,
enabled: item.enabled,
created_at: item.created_at,
updated_at: item.updated_at,
}
})
return { success: true, data: channels }
}
// 添加通知渠道
export const addNotificationChannel = (data: Partial<NotificationChannel>): Promise<ApiResponse> => {
const payload = {
...data,
// 后端期望 config 为字符串
config: serializeChannelConfig(data.config),
}
return post('/notification-channels', payload)
}
// 更新通知渠道
export const updateNotificationChannel = (channelId: string, data: Partial<NotificationChannel>): Promise<ApiResponse> => {
const payload: Record<string, unknown> = {
...data,
}
if ('config' in data) {
payload.config = serializeChannelConfig(data.config)
}
return put(`/notification-channels/${channelId}`, payload)
}
// 删除通知渠道
export const deleteNotificationChannel = (channelId: string): Promise<ApiResponse> => {
return del(`/notification-channels/${channelId}`)
}
// 测试通知渠道 - 后端暂未实现此接口
export const testNotificationChannel = async (_channelId: string): Promise<ApiResponse> => {
// TODO: 后端暂未实现 POST /notification-channels/{id}/test 接口
return { success: false, message: '通知渠道测试功能暂未实现' }
}
// ========== 消息通知 ==========
// 后端返回格式: { cookie_id: [ { id, channel_id, enabled, channel_name, channel_type, channel_config } ] }
interface BackendNotification {
id: number
channel_id: number
enabled: boolean
channel_name?: string
channel_type?: string
channel_config?: string
}
// 获取所有消息通知配置
export const getMessageNotifications = async (): Promise<{ success: boolean; data?: MessageNotification[] }> => {
const result = await get<Record<string, BackendNotification[]>>('/message-notifications')
// 将嵌套对象转换为数组
const notifications: MessageNotification[] = []
for (const [cookieId, channelList] of Object.entries(result || {})) {
if (Array.isArray(channelList)) {
for (const item of channelList) {
notifications.push({
cookie_id: cookieId,
channel_id: item.channel_id,
channel_name: item.channel_name,
enabled: item.enabled,
})
}
}
}
return { success: true, data: notifications }
}
// 设置消息通知 - 后端接口需要 cookie_id 作为路径参数
export const setMessageNotification = (cookieId: string, channelId: number, enabled: boolean): Promise<ApiResponse> => {
return post(`/message-notifications/${cookieId}`, { channel_id: channelId, enabled })
}
// 删除消息通知
export const deleteMessageNotification = (notificationId: string): Promise<ApiResponse> => {
return del(`/message-notifications/${notificationId}`)
}
// 删除账号的所有消息通知
export const deleteAccountNotifications = (cookieId: string): Promise<ApiResponse> => {
return del(`/message-notifications/account/${cookieId}`)
}

69
src/api/orders.ts Normal file
View File

@ -0,0 +1,69 @@
import { get, del } from '@/utils/request'
import type { Order, ApiResponse } from '@/types'
// 订单详情类型
export interface OrderDetail extends Order {
spec_name?: string
spec_value?: string
}
// 获取订单列表(支持分页)
export const getOrders = async (
cookieId?: string,
status?: string,
page: number = 1,
pageSize: number = 20
): Promise<{ success: boolean; data: Order[]; total?: number; total_pages?: number }> => {
const params = new URLSearchParams()
if (cookieId) params.append('cookie_id', cookieId)
if (status) params.append('status', status)
params.append('page', String(page))
params.append('page_size', String(pageSize))
const queryString = params.toString()
try {
const result = await get<{ orders?: Order[]; data?: Order[]; total?: number; total_pages?: number }>(`/api/orders?${queryString}`)
const orders = result.orders || result.data || []
return {
success: true,
data: orders,
total: result.total || orders.length,
total_pages: result.total_pages || Math.ceil((result.total || orders.length) / pageSize)
}
} catch {
return { success: false, data: [], total: 0, total_pages: 0 }
}
}
// 获取订单详情
export const getOrderDetail = async (orderId: string): Promise<{ success: boolean; data?: OrderDetail }> => {
try {
const result = await get<{ order?: OrderDetail; data?: OrderDetail }>(`/api/orders/${orderId}`)
return {
success: true,
data: result.order || result.data
}
} catch {
return { success: false }
}
}
// 删除订单
export const deleteOrder = async (id: string): Promise<ApiResponse> => {
try {
await del(`/api/orders/${id}`)
return { success: true, message: '删除成功' }
} catch {
return { success: false, message: '删除失败' }
}
}
// 批量删除订单
export const batchDeleteOrders = async (_ids: string[]): Promise<ApiResponse> => {
return { success: false, message: '后端暂未实现批量删除订单接口' }
}
// 更新订单状态
export const updateOrderStatus = async (_id: string, _status: string): Promise<ApiResponse> => {
return { success: false, message: '后端暂未实现订单状态更新接口' }
}

35
src/api/search.ts Normal file
View File

@ -0,0 +1,35 @@
import { post } from '@/utils/request'
// 搜索结果项类型
export interface SearchResultItem {
item_id: string
title: string
price: string
seller_name?: string
item_url?: string
main_image?: string
publish_time?: string
tags?: string[]
area?: string
want_count?: number
}
// 搜索商品
export const searchItems = async (
keyword: string,
page: number = 1,
pageSize: number = 20
): Promise<{ success: boolean; data: SearchResultItem[]; total?: number; error?: string }> => {
const result = await post<{
success: boolean
data?: SearchResultItem[]
total?: number
error?: string
}>('/items/search', { keyword, page, page_size: pageSize })
return {
success: result.success,
data: result.data || [],
total: result.total,
error: result.error
}
}

185
src/api/settings.ts Normal file
View File

@ -0,0 +1,185 @@
import { get, post, put } from '@/utils/request'
import type { ApiResponse, SystemSettings } from '@/types'
// 获取系统设置
export const getSystemSettings = async (): Promise<{ success: boolean; data?: SystemSettings }> => {
const data = await get<Record<string, unknown>>('/system-settings')
// 将字符串 'true'/'false' 转换为布尔值
const booleanFields = ['registration_enabled', 'show_default_login_info', 'login_captcha_enabled', 'smtp_use_tls', 'smtp_use_ssl']
const converted: SystemSettings = {}
for (const [key, value] of Object.entries(data)) {
if (booleanFields.includes(key)) {
converted[key] = value === true || value === 'true'
} else {
converted[key] = value
}
}
return { success: true, data: converted }
}
// 更新系统设置
export const updateSystemSettings = async (data: Partial<SystemSettings>): Promise<ApiResponse> => {
// 逐个更新设置项,确保 value 是字符串
const promises = Object.entries(data)
.filter(([, value]) => value !== undefined && value !== null) // 过滤掉空值
.map(([key, value]) => {
// 将布尔值和数字转换为字符串
let stringValue: string
if (typeof value === 'boolean') {
stringValue = value ? 'true' : 'false'
} else if (typeof value === 'number') {
stringValue = String(value)
} else {
stringValue = String(value ?? '')
}
return put(`/system-settings/${key}`, { value: stringValue })
})
try {
await Promise.all(promises)
return { success: true, message: '设置已保存' }
} catch (error) {
console.error('保存设置失败:', error)
return { success: false, message: '保存设置失败' }
}
}
// 获取 AI 设置
export const getAISettings = (): Promise<{ success: boolean; data?: Record<string, unknown> }> => {
return get('/ai-reply-settings')
}
// 更新 AI 设置
export const updateAISettings = (data: Record<string, unknown>): Promise<ApiResponse> => {
return put('/ai-reply-settings', data)
}
// 测试 AI 连接 - 需要指定 cookie_id
export const testAIConnection = async (cookieId?: string): Promise<ApiResponse> => {
if (!cookieId) {
return { success: false, message: '请先选择一个账号进行测试' }
}
try {
const result = await post<{ success?: boolean; message?: string; reply?: string }>(`/ai-reply-test/${cookieId}`, {
message: '你好,这是一条测试消息',
})
if (result.reply) {
return { success: true, message: `AI 回复: ${result.reply}` }
}
return { success: result.success ?? true, message: result.message || 'AI 连接测试成功' }
} catch (error: unknown) {
// 提取后端返回的错误信息
const axiosError = error as { response?: { data?: { detail?: string; message?: string } } }
const detail = axiosError.response?.data?.detail || axiosError.response?.data?.message
return { success: false, message: detail || 'AI 连接测试失败' }
}
}
// 获取邮件设置
export const getEmailSettings = (): Promise<{ success: boolean; data?: Record<string, unknown> }> => {
return get('/system-settings')
}
// 更新邮件设置
export const updateEmailSettings = (data: Record<string, unknown>): Promise<ApiResponse> => {
const promises = Object.entries(data).map(([key, value]) =>
put(`/system-settings/${key}`, { value }),
)
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
}
// 测试邮件发送功能
export const testEmailSend = async (email: string): Promise<ApiResponse> => {
try {
const result = await post<ApiResponse>(`/system-settings/test-email?email=${encodeURIComponent(email)}`)
return result
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string; message?: string } } }
const detail = axiosError.response?.data?.detail || axiosError.response?.data?.message
return { success: false, message: detail || '发送测试邮件失败' }
}
}
// 修改密码(普通用户)
export const changePassword = async (data: { current_password: string; new_password: string }): Promise<ApiResponse> => {
return post('/change-password', data)
}
// 获取备份文件列表(管理员)
export const getBackupList = async (): Promise<{ backups: Array<{ filename: string; size: number; size_mb: number; modified_time: string }>; total: number }> => {
return get('/admin/backup/list')
}
// 下载数据库备份(管理员)
export const downloadDatabaseBackup = (): string => {
const token = localStorage.getItem('auth_token')
return `/admin/backup/download?token=${token}`
}
// 上传数据库备份(管理员)
export const uploadDatabaseBackup = async (file: File): Promise<ApiResponse> => {
const formData = new FormData()
formData.append('backup_file', file)
return post('/admin/backup/upload', formData)
}
// 刷新系统缓存
export const reloadSystemCache = async (): Promise<ApiResponse> => {
return post('/admin/reload-cache')
}
// 导出用户备份
export const exportUserBackup = (): string => {
const token = localStorage.getItem('auth_token')
return `/backup/export?token=${token}`
}
// 导入用户备份
export const importUserBackup = async (file: File): Promise<ApiResponse> => {
const formData = new FormData()
formData.append('file', file)
return post('/backup/import', formData)
}
// ========== 用户设置 ==========
export interface UserSettings {
[key: string]: {
value: string
description?: string
updated_at?: string
}
}
// 获取用户设置
export const getUserSettings = async (): Promise<{ success: boolean; data?: UserSettings }> => {
const data = await get<UserSettings>('/user-settings')
return { success: true, data }
}
// 获取单个用户设置
export const getUserSetting = async (key: string): Promise<{ success: boolean; value?: string }> => {
try {
const data = await get<{ value: string }>(`/user-settings/${key}`)
return { success: true, value: data.value }
} catch {
return { success: false }
}
}
// 更新用户设置
export const updateUserSetting = async (key: string, value: string, description?: string): Promise<ApiResponse> => {
return put(`/user-settings/${key}`, { value, description })
}
// 检查是否使用默认密码
export const checkDefaultPassword = async (): Promise<{ using_default: boolean }> => {
try {
const result = await get<{ using_default: boolean }>('/api/check-default-password')
console.log('checkDefaultPassword result:', result)
return result
} catch (error) {
console.error('checkDefaultPassword error:', error)
return { using_default: false }
}
}

View File

@ -0,0 +1,48 @@
/**
*
*
*
*/
export function DisclaimerContent() {
return (
<div className="space-y-6 text-slate-700 dark:text-slate-300">
{/* 数据存储说明 */}
<section>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-3">
</h3>
<p className="text-sm mb-3">
</p>
</section>
{/* 用户须知 */}
<section>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-3">
</h3>
<div className="space-y-2 text-sm">
<p><strong>1. </strong>访</p>
<p><strong>2. 使</strong>使使</p>
<p><strong>3. </strong></p>
<p><strong>4. </strong></p>
<p><strong>5. </strong></p>
<p><strong>6. </strong></p>
<p><strong>7. </strong></p>
</div>
</section>
{/* 隐私保护承诺 */}
<section>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-3">
</h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
</ul>
</section>
</div>
)
}

View File

@ -0,0 +1,92 @@
/**
*
*
* 使使
*/
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/utils/cn'
import { DisclaimerContent } from './DisclaimerContent'
interface DisclaimerModalProps {
isOpen: boolean
onAgree: () => void
onDisagree: () => void
}
export function DisclaimerModal({ isOpen, onAgree, onDisagree }: DisclaimerModalProps) {
const [checked, setChecked] = useState(false)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* 遮罩层 */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* 弹窗内容 */}
<div
className={cn(
'relative w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col',
'bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
'border border-slate-200 dark:border-slate-700'
)}
>
{/* 标题栏 */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<AlertTriangle className="w-6 h-6 text-amber-500" />
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
</h2>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<DisclaimerContent />
</div>
{/* 底部操作区 */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700">
{/* 勾选确认 */}
<label className="flex items-center gap-2 mb-4 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
</span>
</label>
{/* 按钮 */}
<div className="flex gap-3">
<button
onClick={onDisagree}
className={cn(
'flex-1 px-4 py-2.5 rounded-lg font-medium transition-colors',
'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600'
)}
>
</button>
<button
onClick={onAgree}
disabled={!checked}
className={cn(
'flex-1 px-4 py-2.5 rounded-lg font-medium transition-colors',
checked
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 dark:bg-slate-600 text-slate-400 dark:text-slate-500 cursor-not-allowed'
)}
>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,247 @@
/**
*
*
*
* 1.
* 2.
* 3.
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import { getGeetestRegister, geetestValidate } from '@/api/auth'
// 极验验证结果类型
export interface GeetestResult {
challenge: string
validate: string
seccode: string
}
interface GeetestCaptchaProps {
onSuccess: (result: GeetestResult) => void
onError?: (error: string) => void
disabled?: boolean
buttonText?: string
className?: string
}
declare global {
interface Window {
initGeetest?: (config: any, callback: (captchaObj: any) => void) => void
}
}
export function GeetestCaptcha({
onSuccess,
onError,
disabled = false,
buttonText = '点击进行验证',
className = ''
}: GeetestCaptchaProps) {
const [status, setStatus] = useState<'loading' | 'ready' | 'verified' | 'error'>('loading')
const [errorMsg, setErrorMsg] = useState('')
const captchaObjRef = useRef<any>(null)
const initedRef = useRef(false)
const onSuccessRef = useRef(onSuccess)
const onErrorRef = useRef(onError)
useEffect(() => {
onSuccessRef.current = onSuccess
onErrorRef.current = onError
}, [onSuccess, onError])
// 加载极验JS SDK
const loadScript = useCallback((): Promise<void> => {
return new Promise((resolve, reject) => {
if (window.initGeetest) {
resolve()
return
}
const existing = document.querySelector('script[src*="geetest.com"]')
if (existing) {
const check = setInterval(() => {
if (window.initGeetest) {
clearInterval(check)
resolve()
}
}, 100)
setTimeout(() => {
clearInterval(check)
window.initGeetest ? resolve() : reject(new Error('加载超时'))
}, 10000)
return
}
const script = document.createElement('script')
script.src = 'https://static.geetest.com/static/tools/gt.js'
script.async = true
script.onload = () => {
const check = setInterval(() => {
if (window.initGeetest) {
clearInterval(check)
resolve()
}
}, 50)
setTimeout(() => {
clearInterval(check)
window.initGeetest ? resolve() : reject(new Error('SDK初始化失败'))
}, 5000)
}
script.onerror = () => reject(new Error('脚本加载失败'))
document.head.appendChild(script)
})
}, [])
// 初始化
const init = useCallback(async () => {
if (initedRef.current) return
initedRef.current = true
try {
setStatus('loading')
setErrorMsg('')
await loadScript()
const res = await getGeetestRegister()
if (!res.success || !res.data) {
throw new Error(res.message || '获取参数失败')
}
const { gt, challenge, success, new_captcha } = res.data
if (!gt || !challenge) {
throw new Error('参数不完整')
}
window.initGeetest?.(
{
gt,
challenge,
offline: success === 0,
new_captcha,
product: 'bind',
width: '100%',
lang: 'zh-cn'
},
(obj: any) => {
captchaObjRef.current = obj
obj.onReady(() => {
setStatus('ready')
})
obj.onSuccess(async () => {
const result = obj.getValidate()
if (!result) return
try {
const validateRes = await geetestValidate({
challenge: result.geetest_challenge,
validate: result.geetest_validate,
seccode: result.geetest_seccode
})
if (validateRes.success) {
setStatus('verified')
onSuccessRef.current({
challenge: result.geetest_challenge,
validate: result.geetest_validate,
seccode: result.geetest_seccode
})
} else {
setErrorMsg(validateRes.message || '验证失败')
setStatus('error')
onErrorRef.current?.(validateRes.message || '验证失败')
obj.reset()
}
} catch {
setErrorMsg('验证异常')
setStatus('error')
onErrorRef.current?.('验证异常')
obj.reset()
}
})
obj.onError(() => {
setErrorMsg('加载失败')
setStatus('error')
onErrorRef.current?.('加载失败')
})
obj.onClose(() => {
// 用户关闭,不处理
})
}
)
} catch (err: any) {
initedRef.current = false
setErrorMsg(err.message || '初始化失败')
setStatus('error')
onErrorRef.current?.(err.message || '初始化失败')
}
}, [loadScript])
useEffect(() => {
init()
return () => {
captchaObjRef.current = null
initedRef.current = false
}
}, [init])
const handleClick = () => {
if (disabled) return
if (status === 'error') {
initedRef.current = false
init()
return
}
if (status === 'ready' && captchaObjRef.current) {
captchaObjRef.current.verify()
}
}
const btnClass = `w-full h-10 px-4 rounded-lg border transition-all duration-200 text-sm font-medium
${status === 'verified'
? 'bg-green-50 border-green-300 text-green-700 dark:bg-green-900/20 dark:border-green-700 dark:text-green-400'
: status === 'error'
? 'bg-red-50 border-red-300 text-red-700 dark:bg-red-900/20 dark:border-red-700 dark:text-red-400 cursor-pointer'
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-700'
}
${(disabled || status === 'loading') ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`
return (
<div className={className}>
<button
type="button"
onClick={handleClick}
disabled={disabled || status === 'loading'}
className={btnClass}
>
{status === 'loading' && (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
...
</span>
)}
{status === 'ready' && buttonText}
{status === 'verified' && (
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
)}
{status === 'error' && `${errorMsg},点击重试`}
</button>
</div>
)
}
export default GeetestCaptcha

View File

@ -0,0 +1,53 @@
import { motion } from 'framer-motion'
import { Loader2 } from 'lucide-react'
import { cn } from '@/utils/cn'
interface LoadingProps {
size?: 'sm' | 'md' | 'lg'
fullScreen?: boolean
text?: string
}
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
}
export function Loading({ size = 'md', fullScreen = false, text }: LoadingProps) {
const content = (
<div className="flex flex-col items-center justify-center gap-3">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<Loader2 className={cn('text-blue-500', sizes[size])} />
</motion.div>
{text && (
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">{text}</p>
)}
</div>
)
if (fullScreen) {
return (
<div className="fixed inset-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
{content}
</div>
)
}
return content
}
export function PageLoading() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text="加载中..." />
</div>
)
}
export function ButtonLoading() {
return <Loading size="sm" />
}

View File

@ -0,0 +1,160 @@
import { useState, useRef, useEffect } from 'react'
import { ChevronDown, Check } from 'lucide-react'
import { cn } from '@/utils/cn'
export interface SelectOption {
value: string
label: string
disabled?: boolean
}
interface SelectProps {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
disabled?: boolean
className?: string
}
export function Select({
value,
onChange,
options,
placeholder = '请选择',
disabled = false,
className,
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false)
const selectRef = useRef<HTMLDivElement>(null)
const selectedOption = options.find((opt) => opt.value === value)
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// 键盘导航
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
setIsOpen(!isOpen)
break
case 'Escape':
setIsOpen(false)
break
case 'ArrowDown':
e.preventDefault()
if (!isOpen) {
setIsOpen(true)
} else {
const currentIndex = options.findIndex((opt) => opt.value === value)
const nextIndex = Math.min(currentIndex + 1, options.length - 1)
onChange(options[nextIndex].value)
}
break
case 'ArrowUp':
e.preventDefault()
if (isOpen) {
const currentIndex = options.findIndex((opt) => opt.value === value)
const prevIndex = Math.max(currentIndex - 1, 0)
onChange(options[prevIndex].value)
}
break
}
}
return (
<div ref={selectRef} className={cn('relative', className)}>
{/* 触发器 */}
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
disabled={disabled}
className={cn(
'w-full flex items-center justify-between gap-2',
'px-3 py-2 rounded-md text-sm text-left',
'bg-white dark:bg-slate-700',
'border border-slate-300 dark:border-slate-600',
'hover:border-blue-400 dark:hover:border-blue-500',
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'transition-colors duration-150',
disabled && 'opacity-60 cursor-not-allowed bg-slate-100 dark:bg-slate-800',
isOpen && 'ring-2 ring-blue-500 border-transparent'
)}
>
<span className={cn(
'truncate',
selectedOption ? 'text-slate-900 dark:text-slate-100' : 'text-slate-400'
)}>
{selectedOption?.label || placeholder}
</span>
<ChevronDown
className={cn(
'w-4 h-4 text-slate-400 flex-shrink-0 transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</button>
{/* 下拉菜单 */}
{isOpen && (
<div className={cn(
'absolute z-50 w-full mt-1',
'bg-white dark:bg-slate-800',
'border border-slate-200 dark:border-slate-700',
'rounded-md shadow-lg',
'max-h-60 overflow-auto',
'animate-in fade-in-0 zoom-in-95 duration-100'
)}>
{options.length === 0 ? (
<div className="px-3 py-2 text-sm text-slate-400 text-center">
</div>
) : (
options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
if (!option.disabled) {
onChange(option.value)
setIsOpen(false)
}
}}
disabled={option.disabled}
className={cn(
'w-full flex items-center justify-between gap-2',
'px-3 py-2 text-sm text-left',
'transition-colors duration-100',
option.disabled
? 'text-slate-400 cursor-not-allowed'
: 'text-slate-700 dark:text-slate-200 hover:bg-blue-50 dark:hover:bg-slate-700',
option.value === value && 'bg-blue-50 dark:bg-slate-700 text-blue-600 dark:text-blue-400'
)}
>
<span className="truncate">{option.label}</span>
{option.value === value && (
<Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
)}
</button>
))
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,62 @@
import { motion, AnimatePresence } from 'framer-motion'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
import { useUIStore } from '@/store/uiStore'
import { cn } from '@/utils/cn'
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertCircle,
info: Info,
}
const colors = {
success: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200',
error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
warning: 'bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200',
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
}
const iconColors = {
success: 'text-emerald-500 dark:text-emerald-400',
error: 'text-red-500 dark:text-red-400',
warning: 'text-amber-500 dark:text-amber-400',
info: 'text-blue-500 dark:text-blue-400',
}
export function Toast() {
const { toasts, removeToast } = useUIStore()
return (
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-3">
<AnimatePresence>
{toasts.map((toast) => {
const Icon = icons[toast.type]
return (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
className={cn(
'flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg',
'backdrop-blur-sm min-w-[280px] max-w-[400px]',
'bg-white/95 dark:bg-slate-800/95',
colors[toast.type]
)}
>
<Icon className={cn('w-5 h-5 flex-shrink-0', iconColors[toast.type])} />
<p className="flex-1 text-sm font-medium">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>
</motion.div>
)
})}
</AnimatePresence>
</div>
)
}

View File

@ -0,0 +1,42 @@
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { TopNavbar } from './TopNavbar'
import { TabsBar } from './TabsBar'
import { Toast } from '@/components/common/Toast'
import { useUIStore } from '@/store/uiStore'
import { cn } from '@/utils/cn'
export function MainLayout() {
const { sidebarCollapsed } = useUIStore()
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 transition-colors duration-200">
<Sidebar />
{/* Main content area - 响应侧边栏收缩状态 */}
<div className={cn(
'min-h-screen flex flex-col transition-[margin] duration-200',
// <640px 无边距,>=640px 根据收缩状态调整16px / 56px
'ml-0 sm:ml-16',
!sidebarCollapsed && 'sm:ml-56'
)}>
{/* Fixed header area */}
<div className="sticky top-0 z-30 bg-slate-50 dark:bg-slate-900">
{/* Top navbar */}
<TopNavbar />
{/* Tabs bar */}
<TabsBar />
</div>
{/* Page content */}
<main className="flex-1 p-3 sm:p-4 lg:p-6 overflow-x-hidden">
<Outlet />
</main>
</div>
{/* Toast notifications */}
<Toast />
</div>
)
}

View File

@ -0,0 +1,240 @@
import { useEffect } from 'react'
import { NavLink } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
LayoutDashboard,
Users,
Package,
ShoppingCart,
MessageSquare,
CreditCard,
Truck,
Bell,
MessageCircle,
Settings,
UserCog,
FileText,
Shield,
Database,
Info,
Menu,
X,
PanelLeftClose,
PanelLeft,
AlertTriangle,
} from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { useUIStore } from '@/store/uiStore'
import { cn } from '@/utils/cn'
interface NavItem {
icon: React.ElementType
label: string
path: string
adminOnly?: boolean
}
const mainNavItems: NavItem[] = [
{ icon: LayoutDashboard, label: '仪表盘', path: '/dashboard' },
{ icon: Users, label: '账号管理', path: '/accounts' },
{ icon: Package, label: '商品管理', path: '/items' },
{ icon: ShoppingCart, label: '订单管理', path: '/orders' },
{ icon: MessageSquare, label: '自动回复', path: '/keywords' },
{ icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
{ icon: CreditCard, label: '卡券管理', path: '/cards' },
{ icon: Truck, label: '自动发货', path: '/delivery' },
{ icon: Bell, label: '通知渠道', path: '/notification-channels' },
{ icon: MessageCircle, label: '消息通知', path: '/message-notifications' },
// { icon: Search, label: '商品搜索', path: '/item-search' },
{ icon: Settings, label: '系统设置', path: '/settings' },
]
const adminNavItems: NavItem[] = [
{ icon: UserCog, label: '用户管理', path: '/admin/users', adminOnly: true },
{ icon: FileText, label: '系统日志', path: '/admin/logs', adminOnly: true },
{ icon: Shield, label: '风控日志', path: '/admin/risk-logs', adminOnly: true },
{ icon: Database, label: '数据管理', path: '/admin/data', adminOnly: true },
]
const bottomNavItems: NavItem[] = [
{ icon: AlertTriangle, label: '免责声明', path: '/disclaimer' },
{ icon: Info, label: '关于', path: '/about' },
]
export function Sidebar() {
const { user } = useAuthStore()
const { sidebarCollapsed, sidebarMobileOpen, setSidebarMobileOpen, setSidebarCollapsed } = useUIStore()
// 监听窗口大小变化:
// <640px 抽屉(不依赖 collapsed640-1024px 自动收缩;>1024px 展开
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth
if (width >= 640 && width < 1024) {
setSidebarCollapsed(true)
} else if (width >= 1024) {
setSidebarCollapsed(false)
}
}
handleResize() // 初始化
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [setSidebarCollapsed])
const closeMobileSidebar = () => {
setSidebarMobileOpen(false)
}
const NavItemComponent = ({ item }: { item: NavItem }) => {
const Icon = item.icon
// 移动端抽屉模式下,始终显示文字
const showLabel = sidebarMobileOpen || !sidebarCollapsed
return (
<NavLink
to={item.path}
onClick={closeMobileSidebar}
title={!showLabel ? item.label : undefined}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150',
!showLabel && 'justify-center px-2',
isActive
? 'bg-blue-600 text-white dark:text-white hover:text-white hover:bg-blue-700 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10'
)
}
>
<Icon className="w-4 h-4 flex-shrink-0" />
{showLabel && <span className="truncate">{item.label}</span>}
</NavLink>
)
}
return (
<>
{/* Mobile overlay - 点击关闭侧边栏(仅在 <640px 显示) */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: sidebarMobileOpen ? 1 : 0 }}
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/60 z-40 sm:hidden',
sidebarMobileOpen ? 'pointer-events-auto' : 'pointer-events-none'
)}
onClick={closeMobileSidebar}
/>
{/* Sidebar */}
<motion.aside
initial={false}
className={cn(
'fixed top-0 left-0 h-screen z-50',
'bg-white dark:bg-[#001529]',
'flex flex-col',
'transition-transform duration-200 ease-out',
'border-r border-slate-200 dark:border-slate-700',
// <640px 抽屉:根据 sidebarMobileOpen 控制显隐;>=640px 常驻
sidebarMobileOpen ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
// 宽度:移动端抽屉 288px桌面根据收缩状态
sidebarMobileOpen ? 'w-72' : sidebarCollapsed ? 'w-16' : 'w-56'
)}
>
{/* Header */}
<div className={cn(
'h-14 flex items-center border-b border-slate-200 dark:border-slate-700',
(!sidebarMobileOpen && sidebarCollapsed) ? 'justify-center px-2' : 'justify-between px-4'
)}>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center flex-shrink-0">
<MessageSquare className="w-4 h-4 text-white" />
</div>
{(sidebarMobileOpen || !sidebarCollapsed) && (
<span className="font-semibold text-sm text-slate-900 dark:text-white whitespace-nowrap"></span>
)}
</div>
{/* 移动端抽屉打开时显示关闭按钮 */}
{sidebarMobileOpen && (
<button
onClick={closeMobileSidebar}
className="sm:hidden p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Navigation */}
<nav className={cn(
'flex-1 overflow-y-auto py-3 space-y-0.5 sidebar-scrollbar',
(!sidebarMobileOpen && sidebarCollapsed) ? 'px-1.5' : 'px-2'
)}>
{mainNavItems.map((item) => (
<NavItemComponent key={item.path} item={item} />
))}
{/* Admin section */}
{user?.is_admin && (
<>
{(sidebarMobileOpen || !sidebarCollapsed) && (
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-slate-400 dark:text-gray-500 uppercase tracking-wider">
</p>
</div>
)}
{(!sidebarMobileOpen && sidebarCollapsed) && <div className="pt-2 border-t border-slate-200 dark:border-slate-700 mt-2" />}
{adminNavItems.map((item) => (
<NavItemComponent key={item.path} item={item} />
))}
</>
)}
{(sidebarMobileOpen || !sidebarCollapsed) && (
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
</p>
</div>
)}
{(!sidebarMobileOpen && sidebarCollapsed) && <div className="pt-2 border-t border-slate-200 dark:border-slate-700 mt-2" />}
{bottomNavItems.map((item) => (
<NavItemComponent key={item.path} item={item} />
))}
</nav>
{/* Collapse toggle button - 只在 lg 以上显示 */}
<div className="hidden lg:flex items-center justify-center p-2 border-t border-slate-200 dark:border-slate-700">
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-white/10 transition-colors text-slate-400 hover:text-slate-600 dark:hover:text-white"
title={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{sidebarCollapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
</motion.aside>
{/* Mobile toggle button - 只在 <640px 且侧边栏关闭时显示 */}
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{
opacity: sidebarMobileOpen ? 0 : 1,
scale: sidebarMobileOpen ? 0.9 : 1
}}
transition={{ duration: 0.15 }}
onClick={() => setSidebarMobileOpen(true)}
className={cn(
'fixed top-2.5 left-2.5 z-50 sm:hidden',
'w-8 h-8 rounded-md',
'bg-blue-500 text-white shadow-md',
'flex items-center justify-center',
'hover:bg-blue-600 active:scale-95 transition-all',
sidebarMobileOpen && 'pointer-events-none'
)}
>
<Menu className="w-4 h-4" />
</motion.button>
</>
)
}

View File

@ -0,0 +1,292 @@
import { useEffect, useState, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { X, Home } from 'lucide-react'
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { cn } from '@/utils/cn'
interface Tab {
path: string
title: string
closable: boolean
}
interface TabsStore {
tabs: Tab[]
activeTab: string
addTab: (tab: Tab) => void
removeTab: (path: string) => void
removeTabsToRight: (path: string) => void
removeTabsToLeft: (path: string) => void
removeAllTabs: () => void
setActiveTab: (path: string) => void
}
// 路由标题映射
const routeTitles: Record<string, string> = {
'/dashboard': '仪表盘',
'/accounts': '账号管理',
'/items': '商品管理',
'/keywords': '自动回复',
'/item-replies': '指定商品回复',
'/orders': '订单管理',
'/cards': '卡券管理',
'/delivery': '自动发货',
'/notification-channels': '通知渠道',
'/message-notifications': '消息通知',
'/item-search': '商品搜索',
'/settings': '系统设置',
'/admin/users': '用户管理',
'/admin/logs': '系统日志',
'/admin/risk-logs': '风控日志',
'/admin/data': '数据管理',
'/disclaimer': '免责声明',
'/about': '关于',
}
export const useTabsStore = create<TabsStore>()(
persist(
(set, get) => ({
tabs: [{ path: '/dashboard', title: '仪表盘', closable: false }],
activeTab: '/dashboard',
addTab: (tab) => {
const { tabs } = get()
const exists = tabs.find(t => t.path === tab.path)
if (!exists) {
set({ tabs: [...tabs, tab], activeTab: tab.path })
} else {
set({ activeTab: tab.path })
}
},
removeTab: (path) => {
const { tabs, activeTab } = get()
const newTabs = tabs.filter(t => t.path !== path)
if (activeTab === path && newTabs.length > 0) {
set({ tabs: newTabs, activeTab: newTabs[newTabs.length - 1].path })
} else {
set({ tabs: newTabs })
}
},
removeTabsToRight: (path) => {
const { tabs, activeTab } = get()
const index = tabs.findIndex(t => t.path === path)
if (index === -1) return
const newTabs = tabs.slice(0, index + 1)
const activeIndex = tabs.findIndex(t => t.path === activeTab)
if (activeIndex > index) {
set({ tabs: newTabs, activeTab: path })
} else {
set({ tabs: newTabs })
}
},
removeTabsToLeft: (path) => {
const { tabs, activeTab } = get()
const index = tabs.findIndex(t => t.path === path)
if (index === -1) return
// 保留仪表盘和当前标签及右侧的标签
const newTabs = [tabs[0], ...tabs.slice(index).filter(t => t.path !== '/dashboard')]
const activeIndex = tabs.findIndex(t => t.path === activeTab)
if (activeIndex < index && activeTab !== '/dashboard') {
set({ tabs: newTabs, activeTab: path })
} else {
set({ tabs: newTabs })
}
},
removeAllTabs: () => {
set({
tabs: [{ path: '/dashboard', title: '仪表盘', closable: false }],
activeTab: '/dashboard'
})
},
setActiveTab: (path) => set({ activeTab: path }),
}),
{
name: 'tabs-storage',
storage: createJSONStorage(() => localStorage),
}
)
)
interface ContextMenuState {
visible: boolean
x: number
y: number
targetPath: string
}
export function TabsBar() {
const location = useLocation()
const navigate = useNavigate()
const { tabs, activeTab, addTab, removeTab, removeTabsToRight, removeTabsToLeft, removeAllTabs, setActiveTab } = useTabsStore()
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
targetPath: ''
})
const menuRef = useRef<HTMLDivElement>(null)
// 监听路由变化,自动添加标签
useEffect(() => {
const path = location.pathname
const title = routeTitles[path]
if (title) {
addTab({
path,
title,
closable: path !== '/dashboard',
})
}
}, [location.pathname])
// 点击其他地方关闭右键菜单
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setContextMenu(prev => ({ ...prev, visible: false }))
}
}
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}, [])
const handleTabClick = (path: string) => {
setActiveTab(path)
navigate(path)
}
const handleTabClose = (e: React.MouseEvent, path: string) => {
e.stopPropagation()
removeTab(path)
if (activeTab === path) {
const remainingTabs = tabs.filter(t => t.path !== path)
if (remainingTabs.length > 0) {
navigate(remainingTabs[remainingTabs.length - 1].path)
}
}
}
const handleContextMenu = (e: React.MouseEvent, path: string) => {
e.preventDefault()
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
targetPath: path
})
}
const handleCloseCurrentTab = () => {
const { targetPath } = contextMenu
if (targetPath !== '/dashboard') {
removeTab(targetPath)
if (activeTab === targetPath) {
navigate('/dashboard')
}
}
setContextMenu(prev => ({ ...prev, visible: false }))
}
const handleCloseRightTabs = () => {
removeTabsToRight(contextMenu.targetPath)
setContextMenu(prev => ({ ...prev, visible: false }))
}
const handleCloseLeftTabs = () => {
removeTabsToLeft(contextMenu.targetPath)
setContextMenu(prev => ({ ...prev, visible: false }))
}
const handleCloseAllTabs = () => {
removeAllTabs()
navigate('/dashboard')
setContextMenu(prev => ({ ...prev, visible: false }))
}
const targetIndex = tabs.findIndex(t => t.path === contextMenu.targetPath)
const hasRightTabs = targetIndex < tabs.length - 1
const hasLeftTabs = targetIndex > 1 || (targetIndex === 1 && tabs[0].path === '/dashboard')
return (
<>
<div className="tabs-bar overflow-x-auto scrollbar-hide">
<div className="flex min-w-max">
{tabs.map((tab) => (
<div
key={tab.path}
onClick={() => handleTabClick(tab.path)}
onContextMenu={(e) => handleContextMenu(e, tab.path)}
className={cn(
activeTab === tab.path ? 'tab-item-active' : 'tab-item',
'whitespace-nowrap flex-shrink-0'
)}
>
{tab.path === '/dashboard' && <Home className="w-3.5 h-3.5" />}
<span className="text-xs sm:text-sm">{tab.title}</span>
{tab.closable && (
<button
onClick={(e) => handleTabClose(e, tab.path)}
className="tab-close"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
</div>
{/* 右键菜单 */}
{contextMenu.visible && (
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-md py-0.5 text-xs"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={handleCloseCurrentTab}
disabled={contextMenu.targetPath === '/dashboard'}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCloseRightTabs}
disabled={!hasRightTabs}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCloseLeftTabs}
disabled={!hasLeftTabs}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<div className="border-t border-gray-200 dark:border-gray-700 my-0.5" />
<button
onClick={handleCloseAllTabs}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-red-500"
>
</button>
</div>
)}
</>
)
}

View File

@ -0,0 +1,107 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Sun, Moon, LogOut, ChevronDown } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { cn } from '@/utils/cn'
export function TopNavbar() {
const navigate = useNavigate()
const { user, clearAuth } = useAuthStore()
const [isDark, setIsDark] = useState(false)
const [showUserMenu, setShowUserMenu] = useState(false)
// 初始化主题
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const shouldBeDark = savedTheme === 'dark' || (!savedTheme && prefersDark)
setIsDark(shouldBeDark)
document.documentElement.classList.toggle('dark', shouldBeDark)
}, [])
const toggleTheme = () => {
const newIsDark = !isDark
setIsDark(newIsDark)
document.documentElement.classList.toggle('dark', newIsDark)
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
}
const handleLogout = () => {
clearAuth()
navigate('/login')
}
return (
<div className="top-navbar">
{/* 左侧 - 面包屑或标题(<640px 留空间给汉堡菜单) */}
<div className="flex items-center gap-2 ml-12 sm:ml-0">
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline">使</span>
<span className="text-sm text-slate-500 dark:text-slate-400 sm:hidden"></span>
</div>
{/* 右侧 - 工具栏 */}
<div className="flex items-center gap-1 sm:gap-2">
{/* 主题切换 */}
<button
onClick={toggleTheme}
className="p-2 rounded-md text-slate-500 dark:text-slate-400
hover:bg-slate-100 dark:hover:bg-slate-700
hover:text-slate-700 dark:hover:text-slate-200
transition-colors duration-150"
title={isDark ? '切换到亮色模式' : '切换到暗色模式'}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{/* 用户菜单 */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 rounded-md
text-slate-700 dark:text-slate-200
hover:bg-slate-100 dark:hover:bg-slate-700
transition-colors duration-150"
>
<div className="w-7 h-7 rounded-full bg-blue-500 flex items-center justify-center text-white text-xs font-medium">
{(user?.username || 'U').charAt(0).toUpperCase()}
</div>
<span className="text-sm font-medium hidden sm:inline">{user?.username || '用户'}</span>
<ChevronDown className="w-4 h-4 text-slate-400 hidden sm:block" />
</button>
{/* 下拉菜单 */}
{showUserMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-slate-800
rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/10
py-1 z-50 animate-fade-in">
<div className="px-4 py-2 border-b border-slate-100 dark:border-slate-700">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{user?.username}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{user?.is_admin ? '管理员' : '普通用户'}
</p>
</div>
<button
onClick={handleLogout}
className={cn(
'w-full flex items-center gap-2 px-4 py-2 text-sm',
'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20',
'transition-colors duration-150'
)}
>
<LogOut className="w-4 h-4" />
退
</button>
</div>
</>
)}
</div>
</div>
</div>
)
}

23
src/main.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './styles/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30000,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)

533
src/pages/about/About.tsx Normal file
View File

@ -0,0 +1,533 @@
import { useCallback, useEffect, useState } from 'react'
import { ArrowUpCircle, BarChart3, Bell, Bot, CheckCircle, Code, FileText, Github, Globe, Heart, Loader2, MessageCircle, MessageSquare, RefreshCw, Truck, UserCheck, Users, X } from 'lucide-react'
interface UpdateInfo {
version: string
date?: string
changes?: string[]
download_url?: string
}
// 版本比较函数
function compareVersions(v1: string, v2: string): number {
const normalize = (v: string) => v.replace(/^v/, '').split('.').map(Number)
const parts1 = normalize(v1)
const parts2 = normalize(v2)
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
const p1 = parts1[i] || 0
const p2 = parts2[i] || 0
if (p1 > p2) return 1
if (p1 < p2) return -1
}
return 0
}
export function About() {
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [version, setVersion] = useState('加载中...')
const [totalUsers, setTotalUsers] = useState(0)
// 更新检查相关状态
const [latestVersion, setLatestVersion] = useState<string | null>(null)
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [hasUpdate, setHasUpdate] = useState(false)
const [checkingUpdate, setCheckingUpdate] = useState(false)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [showChangelogModal, setShowChangelogModal] = useState(false)
const [changelog, setChangelog] = useState<UpdateInfo[]>([])
const [changelogHtml, setChangelogHtml] = useState<string | null>(null)
const [loadingChangelog, setLoadingChangelog] = useState(false)
// 检查更新
const checkForUpdate = useCallback(async (showToast = false) => {
setCheckingUpdate(true)
try {
const response = await fetch('/api/version/check')
const result = await response.json()
if (result.error) {
if (showToast) {
console.error('获取版本信息失败:', result.message)
}
return
}
// 支持 {data: "v1.0.5"} 格式
const remoteVersion = result.data || result.version || result.latest_version
if (remoteVersion) {
setLatestVersion(remoteVersion)
setUpdateInfo({
version: remoteVersion,
date: result.date || result.release_date,
changes: result.changes || result.changelog || [],
download_url: result.download_url,
})
if (compareVersions(remoteVersion, version) > 0) {
setHasUpdate(true)
if (showToast) {
setShowUpdateModal(true)
}
} else if (showToast) {
// 已是最新版本的提示
setHasUpdate(false)
}
}
} catch (error) {
console.error('检查更新失败:', error)
} finally {
setCheckingUpdate(false)
}
}, [version])
// 获取更新日志
const loadChangelog = useCallback(async () => {
setLoadingChangelog(true)
setChangelogHtml(null)
setChangelog([])
try {
const response = await fetch('/api/version/changelog')
const result = await response.json()
if (result.error) {
console.error('获取更新日志失败:', result.message)
return
}
// 支持 {data: {updates: [...]}} 格式
if (result.data && result.data.updates && Array.isArray(result.data.updates)) {
// 将 updates 数组合并成 HTML 字符串
const htmlContent = result.data.updates.join('<br/>')
setChangelogHtml(htmlContent)
} else if (result.html) {
setChangelogHtml(result.html)
} else if (result.changelog) {
setChangelog(result.changelog)
} else if (Array.isArray(result)) {
setChangelog(result)
}
} catch (error) {
console.error('获取更新日志失败:', error)
} finally {
setLoadingChangelog(false)
}
}, [])
useEffect(() => {
// 获取版本信息
fetch('/static/version.txt')
.then(res => res.ok ? res.text() : null)
.then(text => {
if (text && text.trim().startsWith('v')) {
setVersion(text.trim())
}
})
.catch(() => {})
// 获取使用人数
fetch('/project-stats')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.total_users) {
setTotalUsers(data.total_users)
}
})
.catch(() => {})
// 自动检查更新
checkForUpdate(false)
}, [checkForUpdate])
return (
<div className="max-w-5xl mx-auto space-y-4">
{/* Header */}
<div className="text-center mb-6">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600 mx-auto mb-4 flex items-center justify-center shadow-md">
<MessageSquare className="w-8 h-8 text-white" />
</div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
</p>
{/* 版本和使用人数 */}
<div className="flex items-center justify-center gap-3 mt-3 flex-wrap">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gradient-to-r from-emerald-500/10 to-teal-500/10 text-emerald-600 dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400 border border-emerald-200/50 dark:border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>{version}</span>
</div>
{hasUpdate && latestVersion && (
<button
onClick={() => setShowUpdateModal(true)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gradient-to-r from-amber-500/10 to-orange-500/10 text-amber-600 dark:from-amber-500/20 dark:to-orange-500/20 dark:text-amber-400 border border-amber-200/50 dark:border-amber-500/30 hover:from-amber-500/20 hover:to-orange-500/20 transition-all cursor-pointer"
>
<ArrowUpCircle className="w-3.5 h-3.5" />
<span> {latestVersion}</span>
</button>
)}
{totalUsers > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gradient-to-r from-blue-500/10 to-cyan-500/10 text-blue-600 dark:from-blue-500/20 dark:to-cyan-500/20 dark:text-blue-400 border border-blue-200/50 dark:border-blue-500/30">
<Globe className="w-3.5 h-3.5" />
<span>{totalUsers.toLocaleString()} 使</span>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex items-center justify-center gap-2 mt-3">
<button
onClick={() => checkForUpdate(true)}
disabled={checkingUpdate}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors disabled:opacity-50"
>
{checkingUpdate ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
<span>{checkingUpdate ? '检查中...' : '检查更新'}</span>
</button>
<button
onClick={() => {
setShowChangelogModal(true)
loadChangelog()
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<FileText className="w-3.5 h-3.5" />
<span></span>
</button>
</div>
</div>
{/* Contact Groups */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<MessageCircle className="w-4 h-4 text-green-500" />
</h2>
</div>
<div className="vben-card-body text-center">
<div
className="w-[160px] h-[160px] mx-auto overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-green-400"
onClick={() => setPreviewImage('/static/wechat-group.png')}
>
<img
src="/static/wechat-group.png"
alt="微信群二维码"
className="w-full h-full object-cover object-center"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
const parent = (e.target as HTMLImageElement).parentElement
if (parent) {
parent.innerHTML = '<p class="text-slate-400 dark:text-slate-500 py-12 text-sm">二维码未配置</p>'
}
}}
/>
</div>
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400"></p>
</div>
</div>
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Users className="w-4 h-4 text-blue-500" />
QQ群
</h2>
</div>
<div className="vben-card-body text-center">
<div
className="w-[160px] h-[160px] mx-auto overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-blue-400"
onClick={() => setPreviewImage('/static/qq-group.png')}
>
<img
src="/static/qq-group.png"
alt="QQ群二维码"
className="w-full h-full object-cover object-center"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
const parent = (e.target as HTMLImageElement).parentElement
if (parent) {
parent.innerHTML = '<p class="text-slate-400 dark:text-slate-500 py-12 text-sm">二维码未配置</p>'
}
}}
/>
</div>
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">QQ技术交流群</p>
</div>
</div>
</div>
{/* Features */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title"></h2>
</div>
<div className="vben-card-body">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ title: '多账号管理', desc: '同时管理多个账号', icon: UserCheck, color: 'text-blue-500' },
{ title: '智能回复', desc: '关键词自动回复', icon: MessageSquare, color: 'text-green-500' },
{ title: 'AI 助手', desc: '智能处理复杂问题', icon: Bot, color: 'text-purple-500' },
{ title: '自动发货', desc: '支持卡密发货', icon: Truck, color: 'text-orange-500' },
{ title: '消息通知', desc: '多渠道推送', icon: Bell, color: 'text-pink-500' },
{ title: '数据统计', desc: '订单商品分析', icon: BarChart3, color: 'text-cyan-500' },
].map((feature, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800 flex items-center gap-3"
>
<div className={`w-10 h-10 rounded-lg bg-white dark:bg-slate-700 flex items-center justify-center shadow-sm ${feature.color}`}>
<feature.icon className="w-5 h-5" />
</div>
<div className="text-left">
<p className="font-medium text-sm text-slate-900 dark:text-slate-100">{feature.title}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{feature.desc}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Contributors */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Code className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body">
<div className="flex flex-wrap gap-3">
<a
href="https://github.com/zhinianboke"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">zhinianboke</span>
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
</a>
<a
href="https://github.com/legeling"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">legeling</span>
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
</a>
</div>
</div>
</div>
{/* Links */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title"></h2>
</div>
<div className="vben-card-body">
<div className="flex gap-3">
<a
href="https://github.com/zhinianboke/xianyu-auto-reply"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-900 text-white hover:bg-gray-800 transition-colors text-sm"
>
<Github className="w-4 h-4" />
<span>GitHub</span>
</a>
</div>
</div>
</div>
{/* Footer */}
<div className="text-center py-4 text-slate-500 dark:text-slate-400 text-sm">
<p className="flex items-center justify-center gap-1">
Made with <Heart className="w-3.5 h-3.5 text-red-500" /> by Open Source Community
</p>
<p className="mt-1 text-xs">
<a
href="https://www.hsykj.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 dark:text-blue-400 hover:underline ml-1"
>
</a>
</p>
</div>
{/* 图片预览弹窗 */}
{previewImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-[90vw] max-h-[90vh]">
<button
onClick={() => setPreviewImage(null)}
className="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
<img
src={previewImage}
alt="预览"
className="max-w-full max-h-[85vh] rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
{/* 更新详情弹窗 */}
{showUpdateModal && updateInfo && (
<div className="modal-overlay" onClick={() => setShowUpdateModal(false)}>
<div className="modal-content max-w-lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title flex items-center gap-2">
<ArrowUpCircle className="w-5 h-5 text-amber-500" />
</h2>
<button onClick={() => setShowUpdateModal(false)} className="modal-close">
<X className="w-4 h-4" />
</button>
</div>
<div className="modal-body space-y-4">
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 rounded-lg">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-lg font-bold text-slate-700 dark:text-slate-200">{version}</p>
</div>
<div className="text-2xl text-slate-400"></div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">{updateInfo.version}</p>
</div>
</div>
{updateInfo.date && (
<p className="text-sm text-slate-500 dark:text-slate-400">
{updateInfo.date}
</p>
)}
{updateInfo.changes && updateInfo.changes.length > 0 && (
<div>
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"></h3>
<ul className="space-y-1.5">
{updateInfo.changes.map((change, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-400">
<CheckCircle className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
<span>{change}</span>
</li>
))}
</ul>
</div>
)}
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-xs text-blue-600 dark:text-blue-400">
<strong></strong> GitHub 使 git pull
</p>
</div>
</div>
<div className="modal-footer">
<button onClick={() => setShowUpdateModal(false)} className="btn-ios-secondary">
</button>
<a
href={updateInfo.download_url || 'https://github.com/zhinianboke/xianyu-auto-reply/releases'}
target="_blank"
rel="noopener noreferrer"
className="btn-ios-primary"
>
<Github className="w-4 h-4" />
</a>
</div>
</div>
</div>
)}
{/* 更新日志弹窗 */}
{showChangelogModal && (
<div className="modal-overlay" onClick={() => setShowChangelogModal(false)}>
<div className="modal-content max-w-2xl max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-500" />
</h2>
<button onClick={() => setShowChangelogModal(false)} className="modal-close">
<X className="w-4 h-4" />
</button>
</div>
<div className="modal-body flex-1 overflow-y-auto">
{loadingChangelog ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
) : changelogHtml ? (
<div
className="changelog-html prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: changelogHtml }}
/>
) : changelog.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300 dark:text-slate-600" />
<p></p>
</div>
) : (
<div className="space-y-4">
{changelog.map((item, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-900 dark:text-slate-100">
{item.version}
</span>
{item.date && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{item.date}
</span>
)}
</div>
{item.changes && item.changes.length > 0 && (
<ul className="space-y-1">
{item.changes.map((change, changeIndex) => (
<li
key={changeIndex}
className="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-400"
>
<span className="text-emerald-500 mt-1"></span>
<span>{change}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={() => setShowChangelogModal(false)} className="btn-ios-secondary">
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
import { useState, useEffect } from 'react'
import { Database, RefreshCw, Trash2, Table } from 'lucide-react'
import { getTableData, clearTableData } from '@/api/admin'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
// 可选择的数据表
const tableOptions = [
{ value: 'default_replies', label: '默认回复表' },
{ value: 'keywords', label: '关键词表' },
{ value: 'cookies', label: '账号表' },
{ value: 'cards', label: '卡券表' },
{ value: 'orders', label: '订单表' },
{ value: 'item_info', label: '商品信息表' },
{ value: 'notification_channels', label: '通知渠道表' },
{ value: 'delivery_rules', label: '发货规则表' },
{ value: 'risk_control_logs', label: '风控日志表' },
]
export function DataManagement() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(false)
const [selectedTable, setSelectedTable] = useState('default_replies')
const [tableData, setTableData] = useState<Record<string, unknown>[]>([])
const [columns, setColumns] = useState<string[]>([])
const [count, setCount] = useState(0)
const [clearing, setClearing] = useState(false)
const loadTableData = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getTableData(selectedTable)
if (result.success) {
setTableData(result.data || [])
setColumns(result.columns || [])
setCount(result.count || 0)
} else {
addToast({ type: 'error', message: '加载数据失败' })
}
} catch {
addToast({ type: 'error', message: '加载数据失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (_hasHydrated && isAuthenticated && token) {
loadTableData()
}
}, [_hasHydrated, isAuthenticated, token, selectedTable])
const handleClearTable = async () => {
if (!confirm(`确定要清空 ${tableOptions.find(t => t.value === selectedTable)?.label} 吗?此操作不可恢复!`)) return
if (!confirm('再次确认:是否真的要清空该表的所有数据?')) return
try {
setClearing(true)
const result = await clearTableData(selectedTable)
if (result.success) {
addToast({ type: 'success', message: '清空成功' })
loadTableData()
} else {
addToast({ type: 'error', message: result.message || '清空失败' })
}
} catch {
addToast({ type: 'error', message: '清空失败' })
} finally {
setClearing(false)
}
}
if (!_hasHydrated) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* 数据表选择 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Table className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-4 items-end">
<div className="sm:col-span-6">
<label className="input-label mb-1"></label>
<Select
value={selectedTable}
onChange={setSelectedTable}
options={tableOptions}
placeholder="选择数据表"
/>
</div>
<div className="sm:col-span-2 text-center py-2 px-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{count}</p>
<p className="text-xs text-slate-400"></p>
</div>
<div className="sm:col-span-4 flex justify-end">
<button
onClick={loadTableData}
disabled={loading}
className="btn-ios-primary w-full sm:w-auto"
>
{loading ? <ButtonLoading /> : <RefreshCw className="w-4 h-4" />}
</button>
</div>
</div>
</div>
</div>
{/* 数据表展示 */}
<div className="vben-card">
<div className="vben-card-header flex items-center justify-between">
<h2 className="vben-card-title">
<Database className="w-4 h-4" />
{tableOptions.find(t => t.value === selectedTable)?.label || selectedTable}
</h2>
<button
onClick={handleClearTable}
disabled={clearing || count === 0}
className="btn-ios-danger text-sm"
>
{clearing ? <ButtonLoading /> : <Trash2 className="w-4 h-4" />}
</button>
</div>
<div className="vben-card-body p-0">
{loading ? (
<div className="p-8 text-center">
<ButtonLoading />
<p className="text-slate-500 mt-2">...</p>
</div>
) : tableData.length === 0 ? (
<div className="p-8 text-center">
<Database className="w-12 h-12 text-slate-300 dark:text-slate-600 mx-auto mb-3" />
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm table-fixed">
<thead className="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-700">
<tr>
{columns.map((col, index) => (
<th
key={col}
className={`px-4 py-3 text-left font-medium text-slate-700 dark:text-slate-300 ${
index === 0 ? 'w-32' : 'min-w-[120px]'
}`}
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{tableData.slice(0, 100).map((row, idx) => (
<tr key={idx} className="hover:bg-slate-50 dark:hover:bg-slate-800/30">
{columns.map((col) => (
<td
key={col}
className="px-4 py-3 text-slate-600 dark:text-slate-400 truncate"
title={String(row[col] ?? '')}
>
{String(row[col] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
{tableData.length > 100 && (
<div className="p-3 text-center text-sm text-slate-500 bg-slate-50 dark:bg-slate-800/30 border-t border-slate-200 dark:border-slate-700">
100 {tableData.length}
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}

188
src/pages/admin/Logs.tsx Normal file
View File

@ -0,0 +1,188 @@
import { useState, useEffect } from 'react'
import { FileText, RefreshCw, Trash2, AlertCircle, AlertTriangle, Info, Download } from 'lucide-react'
import { getSystemLogs, clearSystemLogs, exportLogs, type SystemLog } from '@/api/admin'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { cn } from '@/utils/cn'
const limitOptions = [
{ value: 50, label: '50 条' },
{ value: 100, label: '100 条' },
{ value: 200, label: '200 条' },
{ value: 500, label: '500 条' },
]
export function Logs() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [logs, setLogs] = useState<SystemLog[]>([])
const [levelFilter, setLevelFilter] = useState('')
const [limit, setLimit] = useState(100)
// 从后端获取最近 N 条日志(不按级别过滤)
const loadLogs = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getSystemLogs({ limit })
if (result.success) {
setLogs(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载系统日志失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated) return
if (!isAuthenticated || !token) return
loadLogs()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_hasHydrated, isAuthenticated, token, limit])
// 前端根据当前筛选级别过滤日志
const filteredLogs = levelFilter
? logs.filter((log) => log.level === levelFilter)
: logs
const handleClear = async () => {
if (!confirm('确定要清空所有系统日志吗?此操作不可恢复!')) return
try {
await clearSystemLogs()
addToast({ type: 'success', message: '日志已清空' })
loadLogs()
} catch {
addToast({ type: 'error', message: '清空失败' })
}
}
const getLevelIcon = (level: string) => {
switch (level) {
case 'error':
return <AlertCircle className="w-4 h-4 text-red-500" />
case 'warning':
return <AlertTriangle className="w-4 h-4 text-amber-500" />
default:
return <Info className="w-4 h-4 text-blue-500" />
}
}
const getLevelBadge = (level: string) => {
switch (level) {
case 'error':
return <span className="badge-danger"></span>
case 'warning':
return <span className="badge-warning"></span>
default:
return <span className="badge-info"></span>
}
}
if (loading && logs.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="page-header flex-between flex-wrap gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex gap-2">
<button
onClick={() => window.open(exportLogs(), '_blank')}
className="btn-ios-primary"
>
<Download className="w-4 h-4" />
</button>
<button onClick={handleClear} className="btn-ios-danger">
<Trash2 className="w-4 h-4" />
</button>
<button onClick={loadLogs} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Filter */}
<div className="flex flex-wrap items-center gap-4">
<div className="flex gap-2">
{['', 'info', 'warning', 'error'].map((level) => (
<button
key={level}
onClick={() => setLevelFilter(level)}
className={cn(
'px-4 py-2 rounded-lg text-sm font-medium transition-colors',
levelFilter === level
? 'bg-blue-500 text-white'
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
)}
>
{level === '' ? '全部' : level === 'info' ? '信息' : level === 'warning' ? '警告' : '错误'}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500 dark:text-slate-400">:</span>
<select
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
className="px-3 py-2 rounded-lg text-sm bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 border-0 focus:ring-2 focus:ring-blue-500"
>
{limitOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Logs List */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<FileText className="w-4 h-4" />
</h2>
<span className="badge-primary">{filteredLogs.length} </span>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700 max-h-[600px] overflow-y-auto">
{filteredLogs.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<FileText className="w-12 h-12 text-slate-300 dark:text-slate-600 mx-auto mb-4" />
<p></p>
</div>
) : (
filteredLogs.map((log) => (
<div key={log.id} className="px-6 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
<div className="flex items-start gap-3">
<div className="mt-0.5">{getLevelIcon(log.level)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{getLevelBadge(log.level)}
<span className="text-xs text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-0.5 rounded">
{log.module}
</span>
<span className="text-xs text-slate-400 dark:text-slate-500">
{new Date(log.created_at).toLocaleString()}
</span>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300 break-all">{log.message}</p>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,209 @@
import { useState, useEffect } from 'react'
import { ShieldAlert, RefreshCw, Trash2 } from 'lucide-react'
import { getRiskLogs, clearRiskLogs, type RiskLog } from '@/api/admin'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { Account } from '@/types'
export function RiskLogs() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [logs, setLogs] = useState<RiskLog[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const loadLogs = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getRiskLogs({ cookie_id: selectedAccount || undefined })
if (result.success) {
setLogs(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载风控日志失败' })
} finally {
setLoading(false)
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadLogs()
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadLogs()
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleClear = async () => {
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
try {
const result = await clearRiskLogs()
if (result.success) {
addToast({ type: 'success', message: '日志已清空' })
loadLogs()
} else {
addToast({ type: 'error', message: result.message || '清空失败' })
}
} catch {
addToast({ type: 'error', message: '清空失败' })
}
}
if (loading && logs.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex gap-3">
<button onClick={handleClear} className="btn-ios-danger ">
<Trash2 className="w-4 h-4" />
</button>
<button onClick={loadLogs} className="btn-ios-secondary ">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Filter */}
<div className="vben-card">
<div className="vben-card-body">
<div className="max-w-md">
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
</div>
</div>
</div>
{/* Logs List */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<ShieldAlert className="w-4 h-4 text-amber-500" />
</h2>
<span className="badge-primary">{logs.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-slate-500 dark:text-slate-400">
<div className="flex flex-col items-center gap-2">
<ShieldAlert className="w-12 h-12 text-slate-300 dark:text-slate-600" />
<p></p>
</div>
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{log.cookie_id}</td>
<td>
<span className="badge-danger">{log.risk_type}</span>
</td>
<td className="max-w-[200px] text-slate-500 dark:text-slate-400">
<span
className="block truncate cursor-help"
title={log.message}
>
{log.message || '-'}
</span>
</td>
<td className="max-w-[200px] text-slate-500 dark:text-slate-400">
<span
className="block truncate cursor-help"
title={log.processing_result}
>
{log.processing_result || '-'}
</span>
</td>
<td>
<span className={`text-xs px-2 py-1 rounded ${
log.processing_status === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
log.processing_status === 'failed' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
log.processing_status === 'processing' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}>
{log.processing_status === 'success' ? '成功' :
log.processing_status === 'failed' ? '失败' :
log.processing_status === 'processing' ? '处理中' :
log.processing_status || '-'}
</span>
</td>
<td className="max-w-[150px] text-red-500 dark:text-red-400">
<span
className="block truncate cursor-help"
title={log.error_message || ''}
>
{log.error_message || '-'}
</span>
</td>
<td className="text-slate-500 dark:text-slate-400 text-sm whitespace-nowrap">
{new Date(log.created_at).toLocaleString()}
</td>
<td className="text-slate-500 dark:text-slate-400 text-sm whitespace-nowrap">
{log.updated_at ? new Date(log.updated_at).toLocaleString() : '-'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)
}

148
src/pages/admin/Users.tsx Normal file
View File

@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import { Users as UsersIcon, RefreshCw, Plus, Trash2 } from 'lucide-react'
import { getUsers, deleteUser } from '@/api/admin'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { User } from '@/types'
export function Users() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [users, setUsers] = useState<User[]>([])
const loadUsers = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getUsers()
if (result.success) {
setUsers(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载用户列表失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadUsers()
}, [_hasHydrated, isAuthenticated, token])
// TODO: 后端暂未实现 PUT /admin/users/{user_id} 接口
const handleNotImplemented = (action: string) => {
addToast({ type: 'warning', message: `${action}功能后端暂未实现` })
}
const handleDelete = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?此操作不可恢复!')) return
try {
await deleteUser(userId)
addToast({ type: 'success', message: '删除成功' })
loadUsers()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
if (loading) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex gap-3">
{/* TODO: 后端暂未实现 POST /admin/users 接口 */}
<button onClick={() => handleNotImplemented('添加用户')} className="btn-ios-primary">
<Plus className="w-4 h-4" />
</button>
<button onClick={loadUsers} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Users List */}
<div className="vben-card">
<div className="vben-card-header flex items-center justify-between">
<h2 className="vben-card-title">
<UsersIcon className="w-4 h-4" />
</h2>
<span className="badge-primary">{users.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500 dark:text-slate-400">
<div className="flex flex-col items-center gap-2">
<UsersIcon className="w-12 h-12 text-slate-300 dark:text-slate-600" />
<p></p>
</div>
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.user_id}>
<td className="font-medium">{user.user_id}</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{user.username}</td>
<td className="text-slate-500 dark:text-slate-400">{user.email || '-'}</td>
<td>
{user.is_admin ? (
<span className="badge-warning"></span>
) : (
<span className="badge-gray"></span>
)}
</td>
<td>
<div className="flex gap-1">
<button
onClick={() => handleDelete(user.user_id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 提示信息 */}
<div className="vben-card">
<div className="vben-card-body">
<p className="text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
</div>
</div>
)
}

259
src/pages/auth/Login.tsx Normal file
View File

@ -0,0 +1,259 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { MessageSquare, User, Lock, Mail, KeyRound, Eye, EyeOff, Sun, Moon } from 'lucide-react'
import { login, verifyToken, getRegistrationStatus, getLoginInfoStatus, generateCaptcha, verifyCaptcha, sendVerificationCode, getLoginCaptchaStatus } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { useUIStore } from '@/store/uiStore'
import { cn } from '@/utils/cn'
import { ButtonLoading } from '@/components/common/Loading'
import { GeetestCaptcha, type GeetestResult } from '@/components/common/GeetestCaptcha'
type LoginType = 'username' | 'email-password' | 'email-code'
export function Login() {
const navigate = useNavigate()
const { setAuth, isAuthenticated } = useAuthStore()
const { addToast } = useUIStore()
const [loginType, setLoginType] = useState<LoginType>('username')
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [registrationEnabled, setRegistrationEnabled] = useState(true)
const [showDefaultLogin, setShowDefaultLogin] = useState(true)
const [isDark, setIsDark] = useState(false)
const [loginCaptchaEnabled, setLoginCaptchaEnabled] = useState(true)
// Form states
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [emailPassword, setEmailPassword] = useState('')
const [emailForCode, setEmailForCode] = useState('')
const [captchaCode, setCaptchaCode] = useState('')
const [verificationCode, setVerificationCode] = useState('')
// Captcha states
const [captchaImage, setCaptchaImage] = useState('')
const [sessionId] = useState(() => `session_${Math.random().toString(36).substr(2, 9)}_${Date.now()}`)
const [captchaVerified, setCaptchaVerified] = useState(false)
const [countdown, setCountdown] = useState(0)
const [verifying, setVerifying] = useState(false)
// 极验滑动验证码状态
const [geetestResult, setGeetestResult] = useState<GeetestResult | null>(null)
const [geetestKey, setGeetestKey] = useState(0)
// 重置滑动验证码
const resetGeetest = () => {
setGeetestResult(null)
setGeetestKey((k) => k + 1)
}
// 初始化主题
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
const shouldBeDark = savedTheme === 'dark'
setIsDark(shouldBeDark)
document.documentElement.classList.toggle('dark', shouldBeDark)
}, [])
const toggleTheme = () => {
const newIsDark = !isDark
setIsDark(newIsDark)
document.documentElement.classList.toggle('dark', newIsDark)
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
}
useEffect(() => {
if (isAuthenticated) {
navigate('/dashboard')
return
}
const token = localStorage.getItem('auth_token')
if (token) {
verifyToken()
.then((result) => {
if (result.authenticated) navigate('/dashboard')
})
.catch(() => localStorage.removeItem('auth_token'))
}
}, [isAuthenticated, navigate])
useEffect(() => {
getRegistrationStatus().then((result) => setRegistrationEnabled(result.enabled)).catch(() => {})
getLoginInfoStatus().then((result) => setShowDefaultLogin(result.enabled)).catch(() => {})
getLoginCaptchaStatus().then((result) => {
console.log('getLoginCaptchaStatus result:', result)
setLoginCaptchaEnabled(result.enabled)
}).catch((err) => {
console.error('getLoginCaptchaStatus error:', err)
})
}, [])
useEffect(() => {
if (loginType === 'email-code') loadCaptcha()
}, [loginType])
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [countdown])
useEffect(() => {
if (captchaCode.length === 4 && !captchaVerified && !verifying && loginType === 'email-code') {
handleVerifyCaptchaAuto()
}
}, [captchaCode])
const handleVerifyCaptchaAuto = async () => {
if (captchaCode.length !== 4 || verifying) return
setVerifying(true)
try {
const result = await verifyCaptcha(sessionId, captchaCode)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
} else {
setCaptchaVerified(false)
loadCaptcha()
addToast({ type: 'error', message: '验证码错误' })
}
} catch {
addToast({ type: 'error', message: '验证失败' })
} finally {
setVerifying(false)
}
}
const loadCaptcha = async () => {
try {
const result = await generateCaptcha(sessionId)
if (result.success && result.captcha_image) {
setCaptchaImage(result.captcha_image)
setCaptchaVerified(false)
setCaptchaCode('')
}
} catch {
addToast({ type: 'error', message: '加载验证码失败' })
}
}
const handleSendCode = async () => {
if (!captchaVerified || !emailForCode || countdown > 0) return
try {
const result = await sendVerificationCode(emailForCode, 'login', sessionId)
if (result.success) {
setCountdown(60)
addToast({ type: 'success', message: '验证码已发送' })
} else {
addToast({ type: 'error', message: result.message || '发送失败' })
}
} catch {
addToast({ type: 'error', message: '发送验证码失败' })
}
}
useEffect(() => { resetGeetest() }, [loginType])
const handleGeetestSuccess = (result: GeetestResult) => { setGeetestResult(result) }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
let loginData: any = {}
if (loginType === 'username') {
if (!username || !password) { addToast({ type: 'error', message: '请输入用户名和密码' }); setLoading(false); return }
if (loginCaptchaEnabled && !geetestResult) { addToast({ type: 'error', message: '请完成滑动验证' }); setLoading(false); return }
loginData = { username, password, geetest_challenge: geetestResult?.challenge, geetest_validate: geetestResult?.validate, geetest_seccode: geetestResult?.seccode }
} else if (loginType === 'email-password') {
if (!email || !emailPassword) { addToast({ type: 'error', message: '请输入邮箱和密码' }); setLoading(false); return }
if (loginCaptchaEnabled && !geetestResult) { addToast({ type: 'error', message: '请完成滑动验证' }); setLoading(false); return }
loginData = { email, password: emailPassword, geetest_challenge: geetestResult?.challenge, geetest_validate: geetestResult?.validate, geetest_seccode: geetestResult?.seccode }
} else {
if (!emailForCode || !verificationCode) { addToast({ type: 'error', message: '请输入邮箱和验证码' }); setLoading(false); return }
loginData = { email: emailForCode, verification_code: verificationCode }
}
const result = await login(loginData)
if (result.success && result.token) {
setAuth(result.token, { user_id: result.user_id!, username: result.username!, is_admin: result.is_admin! })
addToast({ type: 'success', message: '登录成功' })
navigate('/dashboard')
} else {
addToast({ type: 'error', message: result.message || '登录失败' })
resetGeetest()
}
} catch {
addToast({ type: 'error', message: '登录失败,请检查网络连接' })
resetGeetest()
} finally {
setLoading(false)
}
}
const fillDefaultCredentials = () => { setLoginType('username'); setUsername('admin'); setPassword('admin123') }
return (
<div className="min-h-screen flex bg-slate-50 dark:bg-slate-900 transition-colors duration-200">
<button onClick={toggleTheme} className="fixed top-4 right-4 z-50 p-2.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors duration-150" title={isDark ? '切换到亮色模式' : '切换到暗色模式'}>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.5 }} className="hidden lg:flex lg:w-1/2 bg-slate-900 dark:bg-slate-950 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/20 to-transparent" />
<div className="relative z-10 flex flex-col justify-center px-16">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.5 }} className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center"><MessageSquare className="w-6 h-6 text-white" /></div>
<span className="text-2xl font-bold text-white"></span>
</motion.div>
<motion.h1 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3, duration: 0.5 }} className="text-4xl font-bold text-white mb-4 leading-tight"><br /></motion.h1>
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4, duration: 0.5 }} className="text-slate-400 text-lg max-w-md"></motion.p>
</div>
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-blue-600/10" />
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full bg-blue-600/5" />
</motion.div>
<div className="flex-1 flex items-center justify-center p-4 sm:p-6">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} className="w-full max-w-md">
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1, duration: 0.4 }} className="lg:hidden text-center mb-8">
<div className="w-12 h-12 rounded-xl bg-blue-500 text-white mx-auto mb-4 flex items-center justify-center"><MessageSquare className="w-6 h-6" /></div>
<h1 className="text-xl font-bold text-slate-900 dark:text-white"></h1>
</motion.div>
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-5 sm:p-8">
<div className="mb-6">
<h2 className="text-xl vben-card-title text-slate-900 dark:text-white"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-4 sm:mb-6 overflow-x-auto scrollbar-hide">
{[{ type: 'username' as const, label: '账号登录' }, { type: 'email-password' as const, label: '邮箱密码' }, { type: 'email-code' as const, label: '验证码' }].map((tab) => (
<button key={tab.type} onClick={() => setLoginType(tab.type)} className={cn('px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap flex-shrink-0', loginType === tab.type ? 'text-blue-600 dark:text-blue-400 border-blue-600 dark:border-blue-400' : 'text-slate-500 dark:text-slate-400 border-transparent hover:text-slate-700 dark:hover:text-slate-300')}>{tab.label}</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{loginType === 'username' && (<>
<div className="input-group"><label className="input-label"></label><div className="relative"><User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /><input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名" className="input-ios pl-9" /></div></div>
<div className="input-group"><label className="input-label"></label><div className="relative"><Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /><input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码" className="input-ios pl-9 pr-9" /><button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</button></div></div>
{loginCaptchaEnabled && (<div className="input-group"><label className="input-label"></label><GeetestCaptcha key={`username-${geetestKey}`} onSuccess={handleGeetestSuccess} onError={(err) => addToast({ type: 'error', message: err })} disabled={loading} /></div>)}
</>)}
{loginType === 'email-password' && (<>
<div className="input-group"><label className="input-label"></label><div className="relative"><Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /><input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com" className="input-ios pl-9" /></div></div>
<div className="input-group"><label className="input-label"></label><div className="relative"><Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /><input type={showPassword ? 'text' : 'password'} value={emailPassword} onChange={(e) => setEmailPassword(e.target.value)} placeholder="请输入密码" className="input-ios pl-9 pr-9" /><button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</button></div></div>
{loginCaptchaEnabled && (<div className="input-group"><label className="input-label"></label><GeetestCaptcha key={`email-${geetestKey}`} onSuccess={handleGeetestSuccess} onError={(err) => addToast({ type: 'error', message: err })} disabled={loading} /></div>)}
</>)}
{loginType === 'email-code' && (<>
<div className="input-group"><label className="input-label"></label><div className="relative"><Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /><input type="email" value={emailForCode} onChange={(e) => setEmailForCode(e.target.value)} placeholder="name@example.com" className="input-ios pl-9" /></div></div>
<div className="input-group"><label className="input-label"></label><div className="flex gap-2"><input type="text" value={captchaCode} onChange={(e) => setCaptchaCode(e.target.value)} placeholder="输入验证码" maxLength={4} className="input-ios flex-1" disabled={captchaVerified} /><img src={captchaImage} alt="验证码" onClick={loadCaptcha} className="h-[38px] rounded border border-gray-300 cursor-pointer hover:opacity-80 transition-opacity" /></div><p className={cn('text-xs', captchaVerified ? 'text-green-600' : verifying ? 'text-blue-500' : 'text-gray-400')}>{captchaVerified ? '✓ 验证成功' : verifying ? '验证中...' : '点击图片更换验证码'}</p></div>
<div className="input-group"><label className="input-label"></label><div className="flex gap-2"><div className="relative flex-1"><KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /><input type="text" value={verificationCode} onChange={(e) => setVerificationCode(e.target.value)} placeholder="6位数字验证码" maxLength={6} className="input-ios pl-9" /></div><button type="button" onClick={handleSendCode} disabled={!captchaVerified || !emailForCode || countdown > 0} className="btn-ios-secondary whitespace-nowrap">{countdown > 0 ? `${countdown}s` : '发送'}</button></div></div>
</>)}
<button type="submit" disabled={loading} className="w-full btn-ios-primary">{loading ? <ButtonLoading /> : '登 录'}</button>
</form>
{registrationEnabled && (<p className="text-center mt-6 text-slate-500 dark:text-slate-400 text-sm">{' '}<Link to="/register" className="text-blue-600 dark:text-blue-400 font-medium hover:text-blue-700 dark:hover:text-blue-300"></Link></p>)}
{showDefaultLogin && (<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-700"><button type="button" onClick={fillDefaultCredentials} className="w-full flex items-center justify-between p-3 rounded-md bg-slate-50 dark:bg-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors text-sm"><div className="text-left"><p className="text-slate-500 dark:text-slate-400"></p><p className="text-slate-900 dark:text-white font-medium">admin / admin123</p></div><span className="text-blue-600 dark:text-blue-400"> </span></button></div>)}
</div>
<p className="text-center mt-6 text-slate-400 dark:text-slate-500 text-xs">© {new Date().getFullYear()} · <a href="https://www.hsykj.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400 ml-1 transition-colors">www.hsykj.com</a></p>
</motion.div>
</div>
</div>
)
}

372
src/pages/auth/Register.tsx Normal file
View File

@ -0,0 +1,372 @@
import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { MessageSquare, User, Lock, Mail, KeyRound, Eye, EyeOff } from 'lucide-react'
import { register, getRegistrationStatus, generateCaptcha, verifyCaptcha, sendVerificationCode } from '@/api/auth'
import { useUIStore } from '@/store/uiStore'
import { cn } from '@/utils/cn'
import { ButtonLoading } from '@/components/common/Loading'
export function Register() {
const navigate = useNavigate()
const { addToast } = useUIStore()
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [registrationEnabled, setRegistrationEnabled] = useState(true)
// Form states
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [captchaCode, setCaptchaCode] = useState('')
const [verificationCode, setVerificationCode] = useState('')
// Captcha states
const [captchaImage, setCaptchaImage] = useState('')
const [sessionId] = useState(() => `session_${Math.random().toString(36).substr(2, 9)}_${Date.now()}`)
const [captchaVerified, setCaptchaVerified] = useState(false)
const [countdown, setCountdown] = useState(0)
const [verifying, setVerifying] = useState(false)
useEffect(() => {
getRegistrationStatus()
.then((result) => {
setRegistrationEnabled(result.enabled)
if (!result.enabled) {
addToast({ type: 'warning', message: '注册功能已关闭' })
setTimeout(() => navigate('/login'), 1500)
}
})
.catch(() => {})
}, [navigate, addToast])
useEffect(() => {
loadCaptcha()
}, [])
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [countdown])
// 自动验证图形验证码
useEffect(() => {
if (captchaCode.length === 4 && !captchaVerified && !verifying) {
handleVerifyCaptchaAuto()
}
}, [captchaCode])
const handleVerifyCaptchaAuto = async () => {
if (captchaCode.length !== 4 || verifying) return
setVerifying(true)
try {
const result = await verifyCaptcha(sessionId, captchaCode)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
} else {
setCaptchaVerified(false)
loadCaptcha()
addToast({ type: 'error', message: '验证码错误' })
}
} catch {
addToast({ type: 'error', message: '验证失败' })
} finally {
setVerifying(false)
}
}
const loadCaptcha = async () => {
try {
const result = await generateCaptcha(sessionId)
if (result.success && result.captcha_image) {
setCaptchaImage(result.captcha_image)
setCaptchaVerified(false)
setCaptchaCode('')
}
} catch {
addToast({ type: 'error', message: '加载验证码失败' })
}
}
const handleSendCode = async () => {
// 验证表单数据
if (!username.trim()) {
addToast({ type: 'warning', message: '请先输入用户名' })
return
}
if (!email.trim()) {
addToast({ type: 'warning', message: '请先输入邮箱地址' })
return
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
addToast({ type: 'warning', message: '请输入正确的邮箱格式' })
return
}
if (!password) {
addToast({ type: 'warning', message: '请先输入密码' })
return
}
if (password.length < 6) {
addToast({ type: 'warning', message: '密码长度至少6位' })
return
}
if (!confirmPassword) {
addToast({ type: 'warning', message: '请先确认密码' })
return
}
if (password !== confirmPassword) {
addToast({ type: 'warning', message: '两次输入的密码不一致' })
return
}
if (!captchaVerified) {
addToast({ type: 'warning', message: '请先完成图形验证码验证' })
return
}
if (countdown > 0) return
try {
const result = await sendVerificationCode(email, 'register', sessionId)
if (result.success) {
setCountdown(60)
addToast({ type: 'success', message: '验证码已发送' })
} else {
addToast({ type: 'error', message: result.message || '发送失败' })
}
} catch {
addToast({ type: 'error', message: '发送验证码失败' })
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!username || !email || !password || !confirmPassword || !verificationCode) {
addToast({ type: 'error', message: '请填写所有必填项' })
return
}
if (password !== confirmPassword) {
addToast({ type: 'error', message: '两次输入的密码不一致' })
return
}
if (password.length < 6) {
addToast({ type: 'error', message: '密码长度至少6位' })
return
}
setLoading(true)
try {
const result = await register({
username,
email,
password,
verification_code: verificationCode,
session_id: sessionId,
})
if (result.success) {
addToast({ type: 'success', message: '注册成功,请登录' })
navigate('/login')
} else {
addToast({ type: 'error', message: result.message || '注册失败' })
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMsg = err?.response?.data?.detail || err?.response?.data?.message || '注册失败,请检查网络连接'
addToast({ type: 'error', message: errorMsg })
} finally {
setLoading(false)
}
}
if (!registrationEnabled) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-8 text-center max-w-sm">
<div className="w-14 h-14 rounded-full bg-amber-100 dark:bg-amber-900/30 mx-auto mb-4 flex items-center justify-center">
<span className="text-2xl">🚫</span>
</div>
<h1 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-2"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6"></p>
<Link to="/login" className="btn-ios-primary">
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-6 transition-colors">
<div className="w-full max-w-md">
{/* Mobile header */}
<div className="text-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-600 text-white mx-auto mb-4 flex items-center justify-center">
<MessageSquare className="w-6 h-6" />
</div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400">使</p>
</div>
{/* Register Card */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
className="input-ios pl-9"
/>
</div>
</div>
{/* Email */}
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="input-ios pl-9"
/>
</div>
</div>
{/* Password */}
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少6位字符"
className="input-ios pl-9 pr-9"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Confirm Password */}
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入密码"
className="input-ios pl-9"
/>
</div>
</div>
{/* Captcha */}
<div className="input-group">
<label className="input-label"></label>
<div className="flex gap-2">
<input
type="text"
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
placeholder="输入验证码"
maxLength={4}
className="input-ios flex-1"
disabled={captchaVerified}
/>
<img
src={captchaImage}
alt="验证码"
onClick={loadCaptcha}
className="h-[38px] rounded border border-slate-300 dark:border-slate-600 cursor-pointer hover:opacity-80 transition-opacity"
/>
</div>
<p className={cn(
'text-xs',
captchaVerified ? 'text-green-600 dark:text-green-400' : verifying ? 'text-blue-500' : 'text-slate-400'
)}>
{captchaVerified ? '✓ 验证成功' : verifying ? '验证中...' : '点击图片更换验证码'}
</p>
</div>
{/* Email code */}
<div className="input-group">
<label className="input-label"></label>
<div className="flex gap-2">
<div className="relative flex-1">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="6位数字验证码"
maxLength={6}
className="input-ios pl-9"
/>
</div>
<button
type="button"
onClick={handleSendCode}
disabled={!captchaVerified || !email || countdown > 0}
className="btn-ios-secondary whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '发送'}
</button>
</div>
</div>
{/* Submit button */}
<button
type="submit"
disabled={loading}
className="w-full btn-ios-primary"
>
{loading ? <ButtonLoading /> : '注 册'}
</button>
</form>
{/* Login link */}
<p className="text-center mt-6 text-slate-500 dark:text-slate-400 text-sm">
{' '}
<Link to="/login" className="text-blue-600 dark:text-blue-400 font-medium hover:text-indigo-700">
</Link>
</p>
</div>
{/* Footer */}
<p className="text-center mt-6 text-slate-400 text-xs">
© {new Date().getFullYear()} ·
<a href="https://www.hsykj.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:text-blue-400 ml-1 transition-colors">
www.hsykj.com
</a>
</p>
</div>
</div>
)
}

801
src/pages/cards/Cards.tsx Normal file
View File

@ -0,0 +1,801 @@
import { useState, useEffect, useRef } from 'react'
import type { FormEvent, ChangeEvent } from 'react'
import { Ticket, RefreshCw, Plus, Trash2, X, Loader2, Power, PowerOff, Edit2, Image } from 'lucide-react'
import { getCards, deleteCard, createCard, updateCard, type CardData } from '@/api/cards'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
import { useAuthStore } from '@/store/authStore'
import { Select } from '@/components/common/Select'
import { post } from '@/utils/request'
type ModalType = 'add' | 'edit' | null
// 卡券类型选项
const cardTypeOptions = [
{ value: '', label: '请选择类型' },
{ value: 'api', label: 'API接口' },
{ value: 'text', label: '固定文字' },
{ value: 'data', label: '批量数据' },
{ value: 'image', label: '图片' },
]
// 请求方法选项
const apiMethodOptions = [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
]
// 卡券类型标签样式
const cardTypeBadge: Record<string, string> = {
api: 'badge-info',
text: 'badge-success',
data: 'badge-warning',
image: 'badge-primary',
}
// 卡券类型标签文本
const cardTypeLabels: Record<string, string> = {
api: 'API',
text: '文本',
data: '批量',
image: '图片',
}
// POST 请求可用参数
const postParams = [
{ name: 'order_id', desc: '订单编号' },
{ name: 'item_id', desc: '商品编号' },
{ name: 'item_detail', desc: '商品详情' },
{ name: 'order_amount', desc: '订单金额' },
{ name: 'order_quantity', desc: '订单数量' },
{ name: 'spec_name', desc: '规格名称' },
{ name: 'spec_value', desc: '规格值' },
{ name: 'cookie_id', desc: 'cookies账号id' },
{ name: 'buyer_id', desc: '买家id' },
]
interface CardFormData {
name: string
type: 'api' | 'text' | 'data' | 'image' | ''
// API 配置
apiUrl: string
apiMethod: 'GET' | 'POST'
apiTimeout: number
apiHeaders: string
apiParams: string
// 文本配置
textContent: string
// 批量数据配置
dataContent: string
// 图片配置
imageFile: File | null
imageUrl: string
// 通用配置
delaySeconds: number
description: string
// 多规格配置
isMultiSpec: boolean
specName: string
specValue: string
}
const initialFormData: CardFormData = {
name: '',
type: '',
apiUrl: '',
apiMethod: 'GET',
apiTimeout: 10,
apiHeaders: '',
apiParams: '',
textContent: '',
dataContent: '',
imageFile: null,
imageUrl: '',
delaySeconds: 0,
description: '',
isMultiSpec: false,
specName: '',
specValue: '',
}
export function Cards() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [cards, setCards] = useState<CardData[]>([])
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [editingCardId, setEditingCardId] = useState<number | null>(null)
const [formData, setFormData] = useState<CardFormData>(initialFormData)
const [submitting, setSubmitting] = useState(false)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// 图片预览弹窗状态
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false)
const [previewImageUrl, setPreviewImageUrl] = useState('')
const loadCards = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getCards()
if (result.success) {
setCards(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载卡券列表失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadCards()
}, [_hasHydrated, isAuthenticated, token])
const handleDelete = async (id: number) => {
if (!confirm('确定要删除这张卡券吗?')) return
try {
await deleteCard(String(id))
addToast({ type: 'success', message: '删除成功' })
loadCards()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
const handleToggleEnabled = async (card: CardData) => {
try {
await updateCard(String(card.id), { enabled: !card.enabled })
addToast({ type: 'success', message: card.enabled ? '已禁用' : '已启用' })
loadCards()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
const handleEdit = (card: CardData) => {
setEditingCardId(card.id ?? null)
setFormData({
name: card.name || '',
type: card.type || '',
apiUrl: card.api_config?.url || '',
apiMethod: (card.api_config?.method as 'GET' | 'POST') || 'GET',
apiTimeout: card.api_config?.timeout || 10,
apiHeaders: card.api_config?.headers || '',
apiParams: card.api_config?.params || '',
textContent: card.text_content || '',
dataContent: card.data_content || '',
imageFile: null,
imageUrl: card.image_url || '',
delaySeconds: card.delay_seconds || 0,
description: card.description || '',
isMultiSpec: card.is_multi_spec || false,
specName: card.spec_name || '',
specValue: card.spec_value || '',
})
if (card.image_url) {
setImagePreview(card.image_url)
}
setActiveModal('edit')
}
const closeModal = () => {
setActiveModal(null)
setEditingCardId(null)
setFormData(initialFormData)
setImagePreview(null)
setSubmitting(false)
}
const updateFormField = <K extends keyof CardFormData>(field: K, value: CardFormData[K]) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (file.size > 5 * 1024 * 1024) {
addToast({ type: 'error', message: '图片大小不能超过5MB' })
return
}
updateFormField('imageFile', file)
const reader = new FileReader()
reader.onload = (e) => {
setImagePreview(e.target?.result as string)
}
reader.readAsDataURL(file)
}
}
const insertParam = (paramName: string) => {
const currentParams = formData.apiParams.trim()
let jsonObj: Record<string, string> = {}
if (currentParams && currentParams !== '{}') {
try {
jsonObj = JSON.parse(currentParams)
} catch {
// 解析失败,使用空对象
}
}
jsonObj[paramName] = `{${paramName}}`
updateFormField('apiParams', JSON.stringify(jsonObj, null, 2))
addToast({ type: 'success', message: `已添加参数 ${paramName}` })
}
const validateForm = (): boolean => {
if (!formData.name.trim()) {
addToast({ type: 'warning', message: '请输入卡券名称' })
return false
}
if (!formData.type) {
addToast({ type: 'warning', message: '请选择卡券类型' })
return false
}
if (formData.type === 'api' && !formData.apiUrl.trim()) {
addToast({ type: 'warning', message: '请输入API地址' })
return false
}
if (formData.type === 'text' && !formData.textContent.trim()) {
addToast({ type: 'warning', message: '请输入固定文字内容' })
return false
}
if (formData.type === 'data' && !formData.dataContent.trim()) {
addToast({ type: 'warning', message: '请输入批量数据' })
return false
}
if (formData.type === 'image' && !formData.imageFile && !formData.imageUrl) {
addToast({ type: 'warning', message: '请选择图片' })
return false
}
if (formData.isMultiSpec && (!formData.specName.trim() || !formData.specValue.trim())) {
addToast({ type: 'warning', message: '多规格卡券必须填写规格名称和规格值' })
return false
}
// 验证 JSON 格式
if (formData.apiHeaders.trim()) {
try {
JSON.parse(formData.apiHeaders)
} catch {
addToast({ type: 'warning', message: '请求头格式错误请输入有效的JSON' })
return false
}
}
if (formData.apiParams.trim()) {
try {
JSON.parse(formData.apiParams)
} catch {
addToast({ type: 'warning', message: '请求参数格式错误请输入有效的JSON' })
return false
}
}
return true
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setSubmitting(true)
try {
let imageUrl = formData.imageUrl
// 如果是图片类型且有新文件,先上传图片
if (formData.type === 'image' && formData.imageFile) {
const formDataUpload = new FormData()
formDataUpload.append('image', formData.imageFile)
const uploadResult = await post<{ image_url: string }>('/upload-image', formDataUpload, {
headers: { 'Content-Type': 'multipart/form-data' }
})
imageUrl = uploadResult.image_url
}
const cardData: Parameters<typeof createCard>[0] = {
name: formData.name.trim(),
type: formData.type as 'api' | 'text' | 'data' | 'image',
description: formData.description.trim() || undefined,
enabled: true,
delay_seconds: formData.delaySeconds,
is_multi_spec: formData.isMultiSpec,
spec_name: formData.isMultiSpec ? formData.specName.trim() : undefined,
spec_value: formData.isMultiSpec ? formData.specValue.trim() : undefined,
}
// 根据类型设置内容
if (formData.type === 'api') {
cardData.api_config = {
url: formData.apiUrl.trim(),
method: formData.apiMethod,
timeout: formData.apiTimeout,
headers: formData.apiHeaders.trim() || undefined,
params: formData.apiParams.trim() || undefined,
}
} else if (formData.type === 'text') {
cardData.text_content = formData.textContent.trim()
} else if (formData.type === 'data') {
cardData.data_content = formData.dataContent.trim()
} else if (formData.type === 'image') {
cardData.image_url = imageUrl
}
if (editingCardId) {
await updateCard(String(editingCardId), cardData)
addToast({ type: 'success', message: '卡券更新成功' })
} else {
await createCard(cardData)
addToast({ type: 'success', message: '卡券创建成功' })
}
closeModal()
loadCards()
} catch (error) {
addToast({ type: 'error', message: editingCardId ? '更新卡券失败' : '创建卡券失败' })
} finally {
setSubmitting(false)
}
}
if (loading && cards.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="page-header flex-between flex-wrap gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => setActiveModal('add')} className="btn-ios-primary">
<Plus className="w-4 h-4" />
</button>
<button onClick={loadCards} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* 统计信息 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="vben-card p-4">
<div className="text-2xl font-bold text-blue-600">{cards.length}</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="vben-card p-4">
<div className="text-2xl font-bold text-cyan-600">{cards.filter(c => c.type === 'api').length}</div>
<div className="text-sm text-gray-500">API类型</div>
</div>
<div className="vben-card p-4">
<div className="text-2xl font-bold text-green-600">{cards.filter(c => c.type === 'text').length}</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="vben-card p-4">
<div className="text-2xl font-bold text-amber-600">{cards.filter(c => c.type === 'data').length}</div>
<div className="text-sm text-gray-500"></div>
</div>
</div>
{/* Cards List */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Ticket className="w-4 h-4" />
</h2>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{cards.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<Ticket className="w-12 h-12 text-gray-300" />
<p></p>
</div>
</td>
</tr>
) : (
cards.map((card) => (
<tr key={card.id}>
<td className="font-medium">{card.name}</td>
<td>
<span className={cardTypeBadge[card.type] || 'badge-gray'}>
{cardTypeLabels[card.type] || card.type}
</span>
</td>
<td>
{card.type === 'image' ? (
card.image_url ? (
<button
onClick={() => {
setPreviewImageUrl(card.image_url || '')
setIsImagePreviewOpen(true)
}}
className="px-2 py-1 text-xs font-medium bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 rounded transition-colors flex items-center gap-1"
>
<Image className="w-3 h-3" />
</button>
) : (
<span className="text-gray-400 text-sm"></span>
)
) : (
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded max-w-[200px] truncate block">
{card.type === 'text' && (card.text_content || '-')}
{card.type === 'data' && (card.data_content ? `剩余 ${card.data_content.split('\n').filter((line: string) => line.trim()).length}` : '-')}
{card.type === 'api' && (card.api_config?.url || '-')}
{!['text', 'data', 'api', 'image'].includes(card.type) && '-'}
</code>
)}
</td>
<td>{card.delay_seconds || 0}</td>
<td>
{card.is_multi_spec ? (
<span className="text-xs text-blue-600">{card.spec_name}: {card.spec_value}</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td>
{card.enabled ? (
<span className="badge-success"></span>
) : (
<span className="badge-gray"></span>
)}
</td>
<td>
<div className="flex items-center gap-1">
<button
onClick={() => handleEdit(card)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500" />
</button>
<button
onClick={() => handleToggleEnabled(card)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title={card.enabled ? '禁用' : '启用'}
>
{card.enabled ? (
<Power className="w-4 h-4 text-green-500" />
) : (
<PowerOff className="w-4 h-4 text-gray-400" />
)}
</button>
<button
onClick={() => card.id && handleDelete(card.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 添加/编辑卡券弹窗 */}
{activeModal && (
<div className="modal-overlay">
<div className="modal-content max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="modal-header flex items-center justify-between sticky top-0 bg-white dark:bg-gray-900 z-10">
<h2 className="text-lg font-semibold">
{editingCardId ? '编辑卡券' : '添加卡券'}
</h2>
<button onClick={closeModal} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
{/* 基本信息 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<input
type="text"
value={formData.name}
onChange={(e) => updateFormField('name', e.target.value)}
className="input-ios"
placeholder="例如:游戏点卡、会员卡等"
/>
</div>
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<Select
value={formData.type}
onChange={(v) => updateFormField('type', v as CardFormData['type'])}
options={cardTypeOptions}
/>
</div>
</div>
{/* API 配置 */}
{formData.type === 'api' && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-4">
<h3 className="font-medium text-gray-900 dark:text-white">API配置</h3>
<div>
<label className="input-label">API地址</label>
<input
type="url"
value={formData.apiUrl}
onChange={(e) => updateFormField('apiUrl', e.target.value)}
className="input-ios"
placeholder="https://api.example.com/get-card"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="input-label"></label>
<Select
value={formData.apiMethod}
onChange={(v) => updateFormField('apiMethod', v as 'GET' | 'POST')}
options={apiMethodOptions}
/>
</div>
<div>
<label className="input-label">()</label>
<input
type="number"
value={formData.apiTimeout}
onChange={(e) => updateFormField('apiTimeout', parseInt(e.target.value) || 10)}
className="input-ios"
min={1}
max={60}
/>
</div>
</div>
<div>
<label className="input-label"> (JSON格式)</label>
<textarea
value={formData.apiHeaders}
onChange={(e) => updateFormField('apiHeaders', e.target.value)}
className="input-ios h-20 font-mono text-sm"
placeholder='{"Authorization": "Bearer token"}'
/>
</div>
<div>
<label className="input-label"> (JSON格式)</label>
<textarea
value={formData.apiParams}
onChange={(e) => updateFormField('apiParams', e.target.value)}
className="input-ios h-20 font-mono text-sm"
placeholder='{"type": "card", "count": 1}'
/>
{formData.apiMethod === 'POST' && (
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-sm text-blue-600 dark:text-blue-400 mb-2 font-medium">POST请求可用参数</p>
<div className="flex flex-wrap gap-2">
{postParams.map(p => (
<button
key={p.name}
type="button"
onClick={() => insertParam(p.name)}
className="px-2 py-1 bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-800 rounded text-xs hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
title={p.desc}
>
<code>{p.name}</code>
</button>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* 固定文字配置 */}
{formData.type === 'text' && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3"></h3>
<div>
<label className="input-label"></label>
<textarea
value={formData.textContent}
onChange={(e) => updateFormField('textContent', e.target.value)}
className="input-ios h-32"
placeholder="请输入要发送的固定文字内容..."
/>
</div>
</div>
)}
{/* 批量数据配置 */}
{formData.type === 'data' && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3"></h3>
<div>
<label className="input-label"> ()</label>
<textarea
value={formData.dataContent}
onChange={(e) => updateFormField('dataContent', e.target.value)}
className="input-ios h-40 font-mono text-sm"
placeholder="请输入数据,每行一个:&#10;卡号1:密码1&#10;卡号2:密码2&#10;或者&#10;兑换码1&#10;兑换码2"
/>
<p className="text-xs text-gray-500 mt-1">卡号:密码 </p>
</div>
</div>
)}
{/* 图片配置 */}
{formData.type === 'image' && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3"></h3>
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<input
ref={fileInputRef}
type="file"
onChange={handleImageChange}
accept="image/*"
className="input-ios"
/>
<p className="text-xs text-gray-500 mt-1">JPGPNGGIF格式5MB</p>
</div>
{imagePreview && (
<div className="mt-3">
<label className="input-label"></label>
<img
src={imagePreview}
alt="预览"
className="max-w-full max-h-48 rounded-lg border border-gray-200 dark:border-gray-700"
/>
</div>
)}
</div>
)}
{/* 延时发货时间 */}
<div>
<label className="input-label"></label>
<div className="flex items-center gap-2">
<input
type="number"
value={formData.delaySeconds}
onChange={(e) => updateFormField('delaySeconds', parseInt(e.target.value) || 0)}
className="input-ios w-32"
min={0}
max={3600}
/>
<span className="text-gray-500"></span>
</div>
<p className="text-xs text-gray-500 mt-1">03600(1)</p>
</div>
{/* 备注信息 */}
<div>
<label className="input-label"></label>
<textarea
value={formData.description}
onChange={(e) => updateFormField('description', e.target.value)}
className="input-ios h-20"
placeholder="可选的备注信息,支持变量替换:&#10;{DELIVERY_CONTENT} - 发货内容"
/>
<p className="text-xs text-gray-500 mt-1">
使 <code className="bg-gray-100 dark:bg-gray-800 px-1 rounded">{'{DELIVERY_CONTENT}'}</code>
</p>
</div>
{/* 多规格设置 */}
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id="isMultiSpec"
checked={formData.isMultiSpec}
onChange={(e) => updateFormField('isMultiSpec', e.target.checked)}
className="w-4 h-4 rounded border-gray-300"
/>
<label htmlFor="isMultiSpec" className="font-medium text-gray-900 dark:text-white">
</label>
</div>
<p className="text-xs text-gray-500 mb-3"></p>
{formData.isMultiSpec && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<input
type="text"
value={formData.specName}
onChange={(e) => updateFormField('specName', e.target.value)}
className="input-ios"
placeholder="例如:套餐类型、颜色、尺寸"
/>
</div>
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<input
type="text"
value={formData.specValue}
onChange={(e) => updateFormField('specValue', e.target.value)}
className="input-ios"
placeholder="例如30天、红色、XL"
/>
</div>
</div>
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm text-blue-600 dark:text-blue-400">
<strong></strong>
<ul className="list-disc list-inside mt-1 space-y-1">
<li></li>
<li>++</li>
<li>使</li>
</ul>
</div>
</>
)}
</div>
</div>
<div className="modal-footer sticky bottom-0 bg-white dark:bg-gray-900">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={submitting}>
</button>
<button type="submit" className="btn-ios-primary" disabled={submitting}>
{submitting ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{editingCardId ? '更新中...' : '创建中...'}
</span>
) : (
editingCardId ? '更新卡券' : '保存卡券'
)}
</button>
</div>
</form>
</div>
</div>
)}
{/* 图片预览弹窗 */}
{isImagePreviewOpen && (
<div className="modal-overlay" style={{ zIndex: 70 }} onClick={() => setIsImagePreviewOpen(false)}>
<div className="modal-content max-w-4xl p-4" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => setIsImagePreviewOpen(false)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex justify-center">
<img
src={previewImageUrl}
alt="预览"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,314 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Activity, MessageSquare, RefreshCw, Shield, ShoppingCart, Users } from 'lucide-react'
import { getAccountDetails } from '@/api/accounts'
import { getKeywords } from '@/api/keywords'
import { getOrders } from '@/api/orders'
import { type AdminStats, getAdminStats } from '@/api/admin'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { AccountDetail } from '@/types'
interface DashboardStats {
totalAccounts: number
totalKeywords: number
activeAccounts: number
totalOrders: number
}
export function Dashboard() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated, user } = useAuthStore()
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<DashboardStats>({
totalAccounts: 0,
totalKeywords: 0,
activeAccounts: 0,
totalOrders: 0,
})
const [accounts, setAccounts] = useState<AccountDetail[]>([])
const [adminStats, setAdminStats] = useState<AdminStats | null>(null)
const loadDashboard = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
// 获取账号详情
const accountsData = await getAccountDetails()
// 为每个账号获取关键词数量
const accountsWithKeywords = await Promise.all(
accountsData.map(async (account) => {
try {
const keywords = await getKeywords(account.id)
return {
...account,
keywordCount: keywords.length,
}
} catch {
return { ...account, keywordCount: 0 }
}
}),
)
// 计算统计数据
let totalKeywords = 0
let activeAccounts = 0
accountsWithKeywords.forEach((account) => {
const isEnabled = account.enabled !== false
if (isEnabled) {
activeAccounts++
totalKeywords += account.keywordCount || 0
}
})
// 获取订单数量
let ordersCount = 0
try {
const ordersResult = await getOrders()
if (ordersResult.success) {
ordersCount = ordersResult.data?.length || 0
}
} catch {
// ignore
}
setStats({
totalAccounts: accountsWithKeywords.length,
totalKeywords,
activeAccounts,
totalOrders: ordersCount,
})
setAccounts(accountsWithKeywords)
// 管理员获取全局统计
if (user?.is_admin) {
try {
const adminResult = await getAdminStats()
if (adminResult.success && adminResult.data) {
setAdminStats(adminResult.data)
}
} catch {
// ignore
}
}
} catch {
addToast({ type: 'error', message: '加载仪表盘数据失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadDashboard()
}, [_hasHydrated, isAuthenticated, token])
if (loading) {
return <PageLoading />
}
const statCards = [
{
icon: Users,
label: '总账号数',
value: stats.totalAccounts,
color: 'primary',
},
{
icon: MessageSquare,
label: '总关键词数',
value: stats.totalKeywords,
color: 'success',
},
{
icon: Activity,
label: '启用账号数',
value: stats.activeAccounts,
color: 'warning',
},
{
icon: ShoppingCart,
label: '总订单数',
value: stats.totalOrders,
color: 'info',
},
]
const colorClasses = {
primary: 'stat-icon-primary',
success: 'stat-icon-success',
warning: 'stat-icon-warning',
info: 'stat-icon-info',
}
return (
<div className="space-y-3 sm:space-y-4">
{/* Page header */}
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<button onClick={loadDashboard} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4">
{statCards.map((card, index) => {
const Icon = card.icon
return (
<motion.div
key={card.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1, duration: 0.3 }}
className="stat-card"
>
<div className={colorClasses[card.color as keyof typeof colorClasses]}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="stat-value">{card.value}</p>
<p className="stat-label">{card.label}</p>
</div>
</motion.div>
)
})}
</div>
{/* Admin Stats - 管理员专属 */}
{user?.is_admin && adminStats && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.3 }}
className="vben-card"
>
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<Shield className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-2xl font-bold text-blue-600">{adminStats.total_users}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-2xl font-bold text-green-600">{adminStats.total_cookies}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-2xl font-bold text-amber-600">{adminStats.active_cookies}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-2xl font-bold text-purple-600">{adminStats.total_cards}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-2xl font-bold text-cyan-600">{adminStats.total_keywords}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
<p className="text-2xl font-bold text-rose-600">{adminStats.total_orders}</p>
<p className="text-sm text-slate-500"></p>
</div>
</div>
</div>
</motion.div>
)}
{/* Accounts table */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.3 }}
className="vben-card"
>
<div className="vben-card-header">
<h2 className="vben-card-title"></h2>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr>
<td colSpan={4}>
<div className="empty-state py-8">
<Users className="empty-state-icon" />
<p className="text-gray-500"></p>
</div>
</td>
</tr>
) : (
accounts.map((account) => {
const isEnabled = account.enabled !== false
const keywordCount = account.keywordCount || 0
return (
<tr key={account.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
<td>{keywordCount}</td>
<td>
{(() => {
const statusClass = !isEnabled
? 'text-gray-400'
: keywordCount > 0
? 'text-green-600'
: 'text-gray-500'
const dotClass = !isEnabled
? 'status-dot-danger'
: keywordCount > 0
? 'status-dot-success'
: 'bg-gray-300'
const statusText = !isEnabled
? '已禁用'
: keywordCount > 0
? '活跃'
: '无关键词'
return (
<span className={`inline-flex items-center gap-1.5 ${statusClass}`}>
<span className={`status-dot ${dotClass}`} />
{statusText}
</span>
)
})()}
</td>
<td className="text-gray-500">
{account.updated_at
? new Date(account.updated_at).toLocaleString()
: '-'}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</motion.div>
</div>
)
}

View File

@ -0,0 +1,357 @@
import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { Truck, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getDeliveryRules, deleteDeliveryRule, updateDeliveryRule, addDeliveryRule } from '@/api/delivery'
import { getCards, type CardData } from '@/api/cards'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { DeliveryRule } from '@/types'
export function Delivery() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [rules, setRules] = useState<DeliveryRule[]>([])
const [cards, setCards] = useState<CardData[]>([])
// 弹窗状态
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
const [formKeyword, setFormKeyword] = useState('')
const [formCardId, setFormCardId] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formEnabled, setFormEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const loadRules = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getDeliveryRules()
if (result.success) {
setRules(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载发货规则失败' })
} finally {
setLoading(false)
}
}
const loadCards = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const result = await getCards()
if (result.success) {
setCards(result.data || [])
}
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadCards()
loadRules()
}, [_hasHydrated, isAuthenticated, token])
const handleToggleEnabled = async (rule: DeliveryRule) => {
try {
await updateDeliveryRule(String(rule.id), { enabled: !rule.enabled })
addToast({ type: 'success', message: rule.enabled ? '规则已禁用' : '规则已启用' })
loadRules()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定要删除这条规则吗?')) return
try {
await deleteDeliveryRule(String(id))
addToast({ type: 'success', message: '删除成功' })
loadRules()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
const openAddModal = () => {
setEditingRule(null)
setFormKeyword('')
setFormCardId('')
setFormDescription('')
setFormEnabled(true)
setIsModalOpen(true)
}
const openEditModal = (rule: DeliveryRule) => {
setEditingRule(rule)
setFormKeyword(rule.keyword)
setFormCardId(String(rule.card_id))
setFormDescription(rule.description || '')
setFormEnabled(rule.enabled)
setIsModalOpen(true)
}
const closeModal = () => {
setIsModalOpen(false)
setEditingRule(null)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formKeyword.trim()) {
addToast({ type: 'warning', message: '请输入触发关键词' })
return
}
if (!formCardId) {
addToast({ type: 'warning', message: '请选择卡券' })
return
}
setSaving(true)
try {
const data = {
keyword: formKeyword.trim(),
card_id: Number(formCardId),
delivery_count: 1, // 固定为1
description: formDescription || undefined,
enabled: formEnabled,
}
if (editingRule) {
await updateDeliveryRule(String(editingRule.id), data)
addToast({ type: 'success', message: '规则已更新' })
} else {
await addDeliveryRule(data)
addToast({ type: 'success', message: '规则已添加' })
}
closeModal()
loadRules()
} catch {
addToast({ type: 'error', message: '保存失败' })
} finally {
setSaving(false)
}
}
if (loading && rules.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex gap-3">
<button onClick={openAddModal} className="btn-ios-primary ">
<Plus className="w-4 h-4" />
</button>
<button onClick={loadRules} className="btn-ios-secondary ">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Rules List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="vben-card"
>
<div className="vben-card-header
flex items-center justify-between">
<h2 className="vben-card-title ">
<Truck className="w-4 h-4" />
</h2>
<span className="badge-primary">{rules.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rules.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<Truck className="w-12 h-12 text-gray-300" />
<p></p>
</div>
</td>
</tr>
) : (
rules.map((rule) => {
// 查找关联的卡券以获取规格信息
const relatedCard = cards.find(c => c.id === rule.card_id)
return (
<tr key={rule.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.keyword}</td>
<td className="text-sm">{rule.card_name || `卡券ID: ${rule.card_id}`}</td>
<td>
{relatedCard?.is_multi_spec ? (
<span className="text-xs text-blue-600 dark:text-blue-400">
{relatedCard.spec_name}: {relatedCard.spec_value}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="text-center text-slate-500">{rule.delivery_times || 0}</td>
<td>
{rule.enabled ? (
<span className="badge-success"></span>
) : (
<span className="badge-danger"></span>
)}
</td>
<td>
<div className="">
<button
onClick={() => handleToggleEnabled(rule)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={rule.enabled ? '禁用' : '启用'}
>
{rule.enabled ? (
<PowerOff className="w-4 h-4 text-amber-500" />
) : (
<Power className="w-4 h-4 text-emerald-500" />
)}
</button>
<button
onClick={() => openEditModal(rule)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<button
onClick={() => handleDelete(rule.id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</motion.div>
{/* 添加/编辑规则弹窗 */}
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-lg">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold">
{editingRule ? '编辑发货规则' : '添加发货规则'}
</h2>
<button onClick={closeModal} className="p-1 hover:bg-gray-100 rounded-lg">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div>
<label className="input-label"> *</label>
<input
type="text"
value={formKeyword}
onChange={(e) => setFormKeyword(e.target.value)}
className="input-ios"
placeholder="输入触发自动发货的关键词"
required
/>
</div>
<div className="input-group">
<label className="input-label"> *</label>
<Select
value={formCardId}
onChange={setFormCardId}
options={[
{ value: '', label: '请选择卡券' },
...cards.map((card) => ({
value: String(card.id),
label: card.is_multi_spec
? `${card.name} [${card.spec_name}: ${card.spec_value}]`
: card.name || card.text_content?.substring(0, 20) || `卡券 ${card.id}`,
})),
]}
placeholder="请选择卡券"
/>
</div>
<div>
<label className="input-label"></label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
className="input-ios h-20 resize-none"
placeholder="规则描述,方便识别"
/>
</div>
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200"></span>
<button
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
</button>
<button type="submit" className="btn-ios-primary" disabled={saving}>
{saving ? (
<span className="">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'保存'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,19 @@
/**
*
*/
import { DisclaimerContent } from '@/components/common/DisclaimerContent'
export function Disclaimer() {
return (
<div className="max-w-4xl mx-auto">
<div className="vben-card">
<div className="vben-card-header">
<h1 className="vben-card-title"></h1>
</div>
<div className="vben-card-body">
<DisclaimerContent />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,332 @@
import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { MessageCircle, RefreshCw, Plus, Edit2, Trash2, X, Loader2 } from 'lucide-react'
import { getItemReplies, deleteItemReply, addItemReply, updateItemReply } from '@/api/items'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { ItemReply, Account } from '@/types'
export function ItemReplies() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [replies, setReplies] = useState<ItemReply[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingReply, setEditingReply] = useState<ItemReply | null>(null)
const [formItemId, setFormItemId] = useState('')
const [formTitle, setFormTitle] = useState('')
const [formReply, setFormReply] = useState('')
const [saving, setSaving] = useState(false)
const loadReplies = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getItemReplies(selectedAccount || undefined)
if (result.success) {
setReplies(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载商品回复列表失败' })
} finally {
setLoading(false)
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadReplies()
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadReplies()
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleDelete = async (reply: ItemReply) => {
if (!confirm('确定要删除这条商品回复吗?')) return
try {
await deleteItemReply(reply.cookie_id, reply.item_id)
addToast({ type: 'success', message: '删除成功' })
loadReplies()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
const openAddModal = () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
setEditingReply(null)
setFormItemId('')
setFormTitle('')
setFormReply('')
setIsModalOpen(true)
}
const openEditModal = (reply: ItemReply) => {
setEditingReply(reply)
setFormItemId(reply.item_id)
setFormTitle(reply.title || '')
setFormReply(reply.reply)
setIsModalOpen(true)
}
const closeModal = () => {
setIsModalOpen(false)
setEditingReply(null)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formItemId.trim()) {
addToast({ type: 'warning', message: '请输入商品ID' })
return
}
if (!formReply.trim()) {
addToast({ type: 'warning', message: '请输入回复内容' })
return
}
setSaving(true)
try {
const data = {
cookie_id: editingReply?.cookie_id || selectedAccount,
item_id: formItemId.trim(),
title: formTitle.trim() || undefined,
reply_content: formReply.trim(), // 后端期望的字段名是 reply_content
}
if (editingReply) {
await updateItemReply(editingReply.cookie_id, editingReply.item_id, data)
addToast({ type: 'success', message: '回复已更新' })
} else {
await addItemReply(selectedAccount, formItemId.trim(), data)
addToast({ type: 'success', message: '回复已添加' })
}
closeModal()
loadReplies()
} catch {
addToast({ type: 'error', message: '保存失败' })
} finally {
setSaving(false)
}
}
if (loading && replies.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex gap-3">
<button onClick={openAddModal} className="btn-ios-primary ">
<Plus className="w-4 h-4" />
</button>
<button onClick={loadReplies} className="btn-ios-secondary ">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Filter */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="vben-card-body">
<div className="max-w-xs">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="选择账号"
/>
</div>
</div>
</motion.div>
{/* Replies List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="vben-card"
>
<div className="vben-card-header
flex items-center justify-between">
<h2 className="vben-card-title ">
<MessageCircle className="w-4 h-4" />
</h2>
<span className="badge-primary">{replies.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{replies.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<MessageCircle className="w-12 h-12 text-gray-300" />
<p></p>
</div>
</td>
</tr>
) : (
replies.map((reply) => (
<tr key={reply.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{reply.cookie_id}</td>
<td className="text-sm">{reply.item_id}</td>
<td className="max-w-[150px] truncate">{reply.title || '-'}</td>
<td className="max-w-[200px] truncate text-gray-500">{reply.reply}</td>
<td className="text-gray-500 text-sm">
{reply.created_at ? new Date(reply.created_at).toLocaleString() : '-'}
</td>
<td>
<div className="">
<button
onClick={() => openEditModal(reply)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<button
onClick={() => handleDelete(reply)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</motion.div>
{/* 添加/编辑弹窗 */}
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-lg">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold">
{editingReply ? '编辑商品回复' : '添加商品回复'}
</h2>
<button onClick={closeModal} className="p-1 hover:bg-gray-100 rounded-lg">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div>
<label className="input-label"></label>
<input
type="text"
value={editingReply?.cookie_id || selectedAccount}
disabled
className="input-ios bg-gray-100 cursor-not-allowed"
/>
</div>
<div>
<label className="input-label">ID</label>
<input
type="text"
value={formItemId}
onChange={(e) => setFormItemId(e.target.value)}
className="input-ios"
placeholder="请输入商品ID"
/>
</div>
<div>
<label className="input-label"></label>
<input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
className="input-ios"
placeholder="用于备注商品名称"
/>
</div>
<div>
<label className="input-label"></label>
<textarea
value={formReply}
onChange={(e) => setFormReply(e.target.value)}
className="input-ios h-28 resize-none"
placeholder="请输入自动回复内容"
/>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
</button>
<button type="submit" className="btn-ios-primary" disabled={saving}>
{saving ? (
<span className="">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'保存'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

500
src/pages/items/Items.tsx Normal file
View File

@ -0,0 +1,500 @@
import { useEffect, useState } from 'react'
import { CheckSquare, Download, Edit2, ExternalLink, Loader2, Package, RefreshCw, Search, Square, Trash2, X } from 'lucide-react'
import { batchDeleteItems, deleteItem, fetchAllItemsFromAccount, getItems, updateItem, updateItemMultiQuantityDelivery, updateItemMultiSpec } from '@/api/items'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
import { useAuthStore } from '@/store/authStore'
import { Select } from '@/components/common/Select'
import type { Account, Item } from '@/types'
export function Items() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [items, setItems] = useState<Item[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set())
const [fetching, setFetching] = useState(false)
// 编辑弹窗状态
const [editingItem, setEditingItem] = useState<Item | null>(null)
const [editDetail, setEditDetail] = useState('')
const [editSaving, setEditSaving] = useState(false)
const loadItems = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
setLoading(true)
const result = await getItems(selectedAccount || undefined)
if (result.success) {
setItems(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载商品列表失败' })
} finally {
setLoading(false)
}
}
const handleFetchItems = async () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号后再获取商品' })
return
}
setFetching(true)
try {
// 使用获取所有页的接口,后端会自动遍历所有页
const result = await fetchAllItemsFromAccount(selectedAccount)
if (result.success) {
const totalCount = (result as { total_count?: number }).total_count || 0
const savedCount = (result as { saved_count?: number }).saved_count || 0
addToast({ type: 'success', message: `成功获取商品,共 ${totalCount} 件,保存 ${savedCount}` })
await loadItems()
} else {
addToast({ type: 'error', message: (result as { message?: string }).message || '获取商品失败' })
}
} catch {
addToast({ type: 'error', message: '获取商品失败' })
} finally {
setFetching(false)
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
const data = await getAccounts()
setAccounts(data)
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadItems()
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadItems()
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleDelete = async (item: Item) => {
if (!confirm('确定要删除这个商品吗?')) return
try {
await deleteItem(item.cookie_id, item.item_id)
addToast({ type: 'success', message: '删除成功' })
loadItems()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
// 批量选择相关
const toggleSelect = (id: string | number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const toggleSelectAll = () => {
if (selectedIds.size === filteredItems.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredItems.map((item) => item.id)))
}
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) {
addToast({ type: 'warning', message: '请先选择要删除的商品' })
return
}
if (!confirm(`确定要删除选中的 ${selectedIds.size} 个商品吗?`)) return
try {
// 将选中的 ID 转换为 { cookie_id, item_id } 格式
const itemsToDelete = items
.filter((item) => selectedIds.has(item.id))
.map((item) => ({ cookie_id: item.cookie_id, item_id: item.item_id }))
await batchDeleteItems(itemsToDelete)
addToast({ type: 'success', message: `成功删除 ${selectedIds.size} 个商品` })
setSelectedIds(new Set())
loadItems()
} catch {
addToast({ type: 'error', message: '批量删除失败' })
}
}
// 切换多数量发货状态
const handleToggleMultiQuantity = async (item: Item) => {
try {
const newStatus = !item.multi_quantity_delivery
await updateItemMultiQuantityDelivery(item.cookie_id, item.item_id, newStatus)
addToast({ type: 'success', message: `多数量发货已${newStatus ? '开启' : '关闭'}` })
loadItems()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
// 切换多规格状态
const handleToggleMultiSpec = async (item: Item) => {
try {
const newStatus = !(item.is_multi_spec || item.has_sku)
await updateItemMultiSpec(item.cookie_id, item.item_id, newStatus)
addToast({ type: 'success', message: `多规格已${newStatus ? '开启' : '关闭'}` })
loadItems()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
// 打开编辑弹窗
const handleEdit = (item: Item) => {
setEditingItem(item)
setEditDetail(item.item_detail || item.desc || '')
}
// 保存编辑
const handleSaveEdit = async () => {
if (!editingItem) return
setEditSaving(true)
try {
await updateItem(editingItem.cookie_id, editingItem.item_id, {
item_detail: editDetail,
})
addToast({ type: 'success', message: '商品详情已更新' })
setEditingItem(null)
loadItems()
} catch {
addToast({ type: 'error', message: '更新失败' })
} finally {
setEditSaving(false)
}
}
const filteredItems = items.filter((item) => {
if (!searchKeyword) return true
const keyword = searchKeyword.toLowerCase()
const title = item.item_title || item.title || ''
const desc = item.item_detail || item.desc || ''
return (
title.toLowerCase().includes(keyword) ||
desc.toLowerCase().includes(keyword) ||
item.item_id?.includes(keyword)
)
})
if (loading) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="page-header flex-between flex-wrap gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex flex-wrap gap-2">
{selectedIds.size > 0 && (
<button onClick={handleBatchDelete} className="btn-ios-danger">
<Trash2 className="w-4 h-4" />
({selectedIds.size})
</button>
)}
<button
onClick={handleFetchItems}
disabled={fetching}
className="btn-ios-primary"
>
{fetching ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
<>
<Download className="w-4 h-4" />
</>
)}
</button>
<button onClick={loadItems} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Filters */}
<div className="vben-card">
<div className="vben-card-body">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索商品标题或详情..."
className="input-ios pl-9"
/>
</div>
</div>
</div>
</div>
</div>
{/* Items List */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title ">
<Package className="w-4 h-4" />
</h2>
<span className="badge-primary">{filteredItems.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios min-w-[900px]">
<thead>
<tr>
<th className="w-10 whitespace-nowrap">
<button
onClick={toggleSelectAll}
className="p-1 hover:bg-gray-100 rounded"
title={selectedIds.size === filteredItems.length ? '取消全选' : '全选'}
>
{selectedIds.size === filteredItems.length && filteredItems.length > 0 ? (
<CheckSquare className="w-4 h-4 text-blue-600 dark:text-blue-400" />
) : (
<Square className="w-4 h-4 text-gray-400" />
)}
</button>
</th>
<th className="whitespace-nowrap">ID</th>
<th className="whitespace-nowrap">ID</th>
<th className="whitespace-nowrap"></th>
<th className="whitespace-nowrap"></th>
<th className="whitespace-nowrap"></th>
<th className="whitespace-nowrap"></th>
<th className="whitespace-nowrap"></th>
<th className="whitespace-nowrap sticky right-0 bg-slate-50 dark:bg-slate-800"></th>
</tr>
</thead>
<tbody>
{filteredItems.length === 0 ? (
<tr>
<td colSpan={9}>
<div className="empty-state py-8">
<Package className="empty-state-icon" />
<p className="text-gray-500"></p>
</div>
</td>
</tr>
) : (
filteredItems.map((item) => (
<tr key={item.id} className={selectedIds.has(item.id) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}>
<td>
<button
onClick={() => toggleSelect(item.id)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
>
{selectedIds.has(item.id) ? (
<CheckSquare className="w-4 h-4 text-blue-600 dark:text-blue-400" />
) : (
<Square className="w-4 h-4 text-gray-400" />
)}
</button>
</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{item.cookie_id}</td>
<td className="text-xs text-gray-500">
<a
href={`https://www.goofish.com/item?id=${item.item_id}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-500 flex items-center gap-1"
>
{item.item_id}
<ExternalLink className="w-3 h-3" />
</a>
</td>
<td className="max-w-[280px]">
<div
className="font-medium line-clamp-2 cursor-help"
title={item.item_title || item.title || '-'}
>
{item.item_title || item.title || '-'}
</div>
{(item.item_detail || item.desc) && (
<div
className="text-xs text-gray-400 line-clamp-1 mt-0.5 cursor-help"
title={item.item_detail || item.desc}
>
{item.item_detail || item.desc}
</div>
)}
</td>
<td className="text-amber-600 font-medium">
{item.item_price || (item.price ? `¥${item.price}` : '-')}
</td>
<td>
<button
onClick={() => handleToggleMultiSpec(item)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
(item.is_multi_spec || item.has_sku)
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'
}`}
title={(item.is_multi_spec || item.has_sku) ? '点击关闭多规格' : '点击开启多规格'}
>
{(item.is_multi_spec || item.has_sku) ? '已开启' : '已关闭'}
</button>
</td>
<td>
<button
onClick={() => handleToggleMultiQuantity(item)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
item.multi_quantity_delivery
? 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'
}`}
title={item.multi_quantity_delivery ? '点击关闭多数量发货' : '点击开启多数量发货'}
>
{item.multi_quantity_delivery ? '已开启' : '已关闭'}
</button>
</td>
<td className="text-gray-500 text-xs">
{item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}
</td>
<td className="sticky right-0 bg-white dark:bg-slate-900">
<div className="flex gap-1">
<button
onClick={() => handleEdit(item)}
className="table-action-btn hover:!bg-blue-50"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500" />
</button>
<button
onClick={() => handleDelete(item)}
className="table-action-btn hover:!bg-red-50"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* 编辑弹窗 */}
{editingItem && (
<div className="modal-overlay">
<div className="modal-content max-w-lg">
<div className="modal-header">
<h2 className="modal-title"></h2>
<button onClick={() => setEditingItem(null)} className="modal-close">
<X className="w-4 h-4" />
</button>
</div>
<div className="modal-body space-y-4">
<div className="input-group">
<label className="input-label">ID</label>
<input
type="text"
value={editingItem.item_id}
disabled
className="input-ios bg-slate-100 dark:bg-slate-700"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={editingItem.item_title || editingItem.title || ''}
disabled
className="input-ios bg-slate-100 dark:bg-slate-700"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<textarea
value={editDetail}
onChange={(e) => setEditDetail(e.target.value)}
className="input-ios h-32 resize-none"
placeholder="输入商品详情..."
/>
</div>
</div>
<div className="modal-footer">
<button
type="button"
onClick={() => setEditingItem(null)}
className="btn-ios-secondary"
disabled={editSaving}
>
</button>
<button
onClick={handleSaveEdit}
className="btn-ios-primary"
disabled={editSaving}
>
{editSaving ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'保存'
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,800 @@
import { useState, useEffect, useRef } from 'react'
import type { FormEvent, ChangeEvent } from 'react'
import { motion } from 'framer-motion'
import { MessageSquare, RefreshCw, Plus, Edit2, Trash2, Upload, Download, Info, Image } from 'lucide-react'
import { getKeywords, deleteKeyword, addKeyword, updateKeyword, exportKeywords, importKeywords as importKeywordsApi, addImageKeyword } from '@/api/keywords'
import { getAccounts } from '@/api/accounts'
import { getItems } from '@/api/items'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
import { useAuthStore } from '@/store/authStore'
import { Select } from '@/components/common/Select'
import type { Keyword, Account, Item } from '@/types'
export function Keywords() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [keywords, setKeywords] = useState<Keyword[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [items, setItems] = useState<Item[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null)
const [keywordText, setKeywordText] = useState('')
const [replyText, setReplyText] = useState('')
const [itemIdText, setItemIdText] = useState('') // 绑定的商品ID
const [saving, setSaving] = useState(false)
const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState(false)
const importInputRef = useRef<HTMLInputElement | null>(null)
// 图片关键词相关状态
const [isImageModalOpen, setIsImageModalOpen] = useState(false)
const [imageKeyword, setImageKeyword] = useState('')
const [imageItemId, setImageItemId] = useState('')
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string>('')
const [savingImage, setSavingImage] = useState(false)
const imageInputRef = useRef<HTMLInputElement | null>(null)
// 图片预览弹窗状态
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false)
const [previewImageUrl, setPreviewImageUrl] = useState('')
const loadKeywords = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
if (!selectedAccount) {
setKeywords([])
setLoading(false)
return
}
try {
setLoading(true)
const data = await getKeywords(selectedAccount)
// 确保 data 是数组,防止后端返回非数组或请求失败时出错
setKeywords(Array.isArray(data) ? data : [])
} catch {
setKeywords([])
addToast({ type: 'error', message: '加载关键词列表失败' })
} finally {
setLoading(false)
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
setLoading(true)
const data = await getAccounts()
setAccounts(data)
if (data.length > 0) {
if (!selectedAccount) {
setSelectedAccount(data[0].id)
}
} else {
setSelectedAccount('')
}
} catch {
// ignore
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
if (selectedAccount) {
loadKeywords()
loadItems()
}
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const loadItems = async () => {
if (!selectedAccount) {
setItems([])
return
}
try {
const result = await getItems(selectedAccount)
setItems(result.data || [])
} catch {
setItems([])
}
}
const openAddModal = () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
setEditingKeyword(null)
setKeywordText('')
setReplyText('')
setItemIdText('')
setIsModalOpen(true)
}
const openEditModal = (keyword: Keyword) => {
// 图片关键词不支持编辑
if (keyword.type === 'image') {
addToast({ type: 'warning', message: '图片关键词不支持编辑,请删除后重新添加' })
return
}
setEditingKeyword(keyword)
setKeywordText(keyword.keyword)
setReplyText(keyword.reply)
setItemIdText(keyword.item_id || '')
setIsModalOpen(true)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
if (!keywordText.trim()) {
addToast({ type: 'warning', message: '请输入关键词' })
return
}
if (!replyText.trim()) {
addToast({ type: 'warning', message: '请输入回复内容' })
return
}
try {
setSaving(true)
if (editingKeyword) {
const result = await updateKeyword(
selectedAccount,
editingKeyword.keyword,
editingKeyword.item_id || '',
{
keyword: keywordText.trim(),
reply: replyText.trim(),
item_id: itemIdText.trim(),
}
)
if (result.success === false) {
addToast({ type: 'error', message: result.message || '更新失败' })
return
}
addToast({ type: 'success', message: '关键词已更新' })
} else {
const result = await addKeyword(selectedAccount, {
keyword: keywordText.trim(),
reply: replyText.trim(),
item_id: itemIdText.trim(),
})
if (result.success === false) {
addToast({ type: 'error', message: result.message || '添加失败' })
return
}
addToast({ type: 'success', message: '关键词已添加' })
}
await loadKeywords()
setIsModalOpen(false)
} catch {
addToast({ type: 'error', message: '保存关键词失败' })
} finally {
setSaving(false)
}
}
const handleExport = async () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
try {
setExporting(true)
const blob = await exportKeywords(selectedAccount)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
const date = new Date().toISOString().split('T')[0]
a.href = url
a.download = `keywords_${selectedAccount}_${date}.xlsx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
addToast({ type: 'success', message: '关键词导出成功' })
} catch {
addToast({ type: 'error', message: '关键词导出失败' })
} finally {
setExporting(false)
}
}
const handleImportButtonClick = () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
importInputRef.current?.click()
}
const handleImportFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
setImporting(true)
const result = await importKeywordsApi(selectedAccount, file)
// 后端返回 { msg, total, added, updated } 格式
const resultData = result as unknown as { msg?: string; added?: number; updated?: number; success?: boolean; message?: string }
if (resultData.msg || resultData.added !== undefined) {
addToast({
type: 'success',
message: `导入成功:新增 ${resultData.added ?? 0} 条,更新 ${resultData.updated ?? 0}`,
})
await loadKeywords()
} else if (resultData.success === false) {
addToast({ type: 'error', message: resultData.message || '导入失败' })
} else {
addToast({ type: 'error', message: '导入失败' })
}
} catch {
addToast({ type: 'error', message: '导入关键词失败' })
} finally {
setImporting(false)
event.target.value = ''
}
}
const handleDelete = async (keyword: Keyword) => {
if (!confirm('确定要删除这个关键词吗?')) return
try {
await deleteKeyword(selectedAccount, keyword.keyword, keyword.item_id || '')
addToast({ type: 'success', message: '删除成功' })
loadKeywords()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
// 图片关键词功能
const openImageModal = () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
setImageKeyword('')
setImageItemId('')
setImageFile(null)
setImagePreview('')
setIsImageModalOpen(true)
}
const handleImageFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
addToast({ type: 'error', message: '请选择图片文件' })
return
}
// 验证文件大小 (5MB)
if (file.size > 5 * 1024 * 1024) {
addToast({ type: 'error', message: '图片大小不能超过5MB' })
return
}
setImageFile(file)
// 生成预览
const reader = new FileReader()
reader.onload = (event) => {
setImagePreview(event.target?.result as string)
}
reader.readAsDataURL(file)
}
const handleImageSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!imageKeyword.trim()) {
addToast({ type: 'warning', message: '请输入关键词' })
return
}
if (!imageFile) {
addToast({ type: 'warning', message: '请选择图片' })
return
}
setSavingImage(true)
try {
const result = await addImageKeyword(
selectedAccount,
imageKeyword.trim(),
imageFile,
imageItemId.trim() || undefined
)
// 后端返回 { msg, keyword, image_url, item_id }
if (result && (result as unknown as { keyword?: string }).keyword) {
addToast({ type: 'success', message: '图片关键词添加成功' })
setIsImageModalOpen(false)
loadKeywords()
} else {
addToast({ type: 'error', message: '添加失败' })
}
} catch (err) {
const error = err as { response?: { data?: { detail?: string } } }
addToast({ type: 'error', message: error.response?.data?.detail || '添加图片关键词失败' })
} finally {
setSavingImage(false)
}
}
if (loading && accounts.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={openAddModal}
className="btn-ios-primary"
>
<Plus className="w-4 h-4" />
</button>
<button
type="button"
onClick={openImageModal}
className="btn-ios-primary"
>
<Image className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleExport}
disabled={!selectedAccount || exporting}
className="btn-ios-secondary"
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleImportButtonClick}
disabled={!selectedAccount || importing}
className="btn-ios-secondary "
>
<Upload className="w-4 h-4" />
</button>
<button onClick={loadKeywords} className="btn-ios-secondary ">
<RefreshCw className="w-4 h-4" />
</button>
<input
ref={importInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleImportFileChange}
/>
</div>
</div>
{/* Account Select */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="vben-card-body">
<div className="max-w-md">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={
accounts.length === 0
? [{ value: '', label: '暂无账号' }]
: accounts.map((account) => ({
value: account.id,
label: account.id,
}))
}
placeholder="选择账号"
/>
</div>
</div>
</motion.div>
{/* 变量提示说明 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="vben-card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
>
<div className="vben-card-body py-3">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-blue-700 dark:text-blue-300 mb-1"></p>
<div className="text-blue-600 dark:text-blue-400 space-y-0.5">
<p><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_user_name}'}</code> - </p>
<p><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_user_id}'}</code> - ID</p>
<p><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_message}'}</code> - </p>
</div>
</div>
</div>
</div>
</motion.div>
{/* Keywords List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="vben-card"
>
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
</h2>
<span className="badge-primary">{keywords.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th></th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{!selectedAccount ? (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-500">
</td>
</tr>
) : loading ? (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-500">
...
</td>
</tr>
) : keywords.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<MessageSquare className="w-12 h-12 text-gray-300" />
<p></p>
</div>
</td>
</tr>
) : (
keywords.map((keyword, index) => (
<tr key={keyword.id || `keyword-${index}`}>
<td className="font-medium">
<code className="bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded">
{keyword.keyword}
</code>
</td>
<td>
{keyword.item_id ? (
<span className="text-xs text-slate-500 dark:text-slate-400">{keyword.item_id}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="max-w-[300px]">
{keyword.type === 'image' ? (
<button
onClick={() => {
setPreviewImageUrl(keyword.image_url || '')
setIsImagePreviewOpen(true)
}}
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
</button>
) : (
<p className="truncate text-slate-600 dark:text-slate-300" title={keyword.reply}>
{keyword.reply || <span className="text-gray-400"></span>}
</p>
)}
</td>
<td>
{keyword.type === 'image' ? (
<span className="badge-primary"></span>
) : (
<span className="badge-gray"></span>
)}
</td>
<td>
<div className="">
<button
onClick={() => openEditModal(keyword)}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<button
onClick={() => handleDelete(keyword)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</motion.div>
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold">
{editingKeyword ? '编辑关键词' : '添加关键词'}
</h2>
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="modal-body space-y-4 overflow-y-auto">
<div>
<label className="input-label"></label>
<input
type="text"
value={selectedAccount}
disabled
className="input-ios bg-slate-100 dark:bg-slate-700 cursor-not-allowed"
/>
</div>
<div>
<label className="input-label"></label>
<input
type="text"
value={keywordText}
onChange={(e) => setKeywordText(e.target.value)}
className="input-ios"
placeholder="请输入关键词"
/>
</div>
<div>
<label className="input-label">ID</label>
<select
value={itemIdText}
onChange={(e) => setItemIdText(e.target.value)}
className="input-ios"
>
<option value=""></option>
{items.map((item) => (
<option key={item.item_id} value={item.item_id}>
{item.item_id} - {item.title || item.item_title || '未命名商品'}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
ID后
</p>
</div>
<div>
<label className="input-label"></label>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
className="input-ios h-28 resize-none"
placeholder="请输入自动回复内容,留空表示不回复"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
</p>
</div>
</div>
<div className="modal-footer">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="btn-ios-secondary"
disabled={saving}
>
</button>
<button
type="submit"
className="btn-ios-primary"
disabled={saving}
>
{saving ? '保存中...' : '保存'}
</button>
</div>
</form>
</div>
</div>
)}
{/* 图片关键词弹窗 */}
{isImageModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-lg">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Image className="w-5 h-5 text-blue-500" />
</h2>
<button
type="button"
onClick={() => setIsImageModalOpen(false)}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleImageSubmit} className="flex flex-col flex-1 min-h-0">
<div className="modal-body space-y-4 overflow-y-auto">
{/* 关键词输入 */}
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<input
type="text"
value={imageKeyword}
onChange={(e) => setImageKeyword(e.target.value)}
className="input-ios"
placeholder="例如:图片、照片"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
{/* 图片上传区域 */}
<div>
<label className="input-label"> <span className="text-red-500">*</span></label>
<div
className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-4 text-center hover:border-blue-400 dark:hover:border-blue-500 transition-colors cursor-pointer"
onClick={() => imageInputRef.current?.click()}
>
{imagePreview ? (
<div className="flex flex-col items-center">
<img src={imagePreview} alt="预览" className="max-h-32 rounded-lg mb-2" />
<p className="text-sm text-slate-600 dark:text-slate-400">{imageFile?.name}</p>
<p className="text-xs text-blue-500 mt-1"></p>
</div>
) : (
<div className="py-4">
<Image className="w-10 h-10 text-slate-400 mx-auto mb-2" />
<p className="text-sm text-slate-600 dark:text-slate-400"></p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1"> JPGPNGGIF 5MB</p>
</div>
)}
</div>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFileChange}
/>
</div>
{/* 关联商品 */}
<div>
<label className="input-label"></label>
<select
value={imageItemId}
onChange={(e) => setImageItemId(e.target.value)}
className="input-ios"
>
<option value=""></option>
{items.map((item) => (
<option key={item.item_id} value={item.item_id}>
{item.item_id} - {item.title || item.item_title || '未命名商品'}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">ID后</p>
</div>
{/* 说明提示 */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1"></p>
<ul className="list-disc list-inside space-y-0.5 text-xs">
<li></li>
<li></li>
<li></li>
</ul>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
onClick={() => setIsImageModalOpen(false)}
className="btn-ios-secondary"
disabled={savingImage}
>
</button>
<button
type="submit"
className="btn-ios-primary"
disabled={savingImage}
>
{savingImage ? '添加中...' : '添加图片关键词'}
</button>
</div>
</form>
</div>
</div>
)}
{/* 图片预览弹窗 */}
{isImagePreviewOpen && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setIsImagePreviewOpen(false)}
>
<div className="relative max-w-4xl max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setIsImagePreviewOpen(false)}
className="absolute -top-10 right-0 text-white hover:text-gray-300 text-sm"
>
</button>
<img
src={previewImageUrl}
alt="关键词图片"
className="max-w-full max-h-[90vh] object-contain rounded-lg"
/>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,310 @@
import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { Mail, RefreshCw, Plus, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getMessageNotifications, setMessageNotification, getNotificationChannels } from '@/api/notifications'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { MessageNotification, NotificationChannel, Account } from '@/types'
export function MessageNotifications() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [notifications, setNotifications] = useState<MessageNotification[]>([])
const [channels, setChannels] = useState<NotificationChannel[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [formAccountId, setFormAccountId] = useState('')
const [formChannelId, setFormChannelId] = useState('')
const [formEnabled, setFormEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const loadNotifications = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getMessageNotifications()
if (result.success) {
setNotifications(result.data || [])
}
} catch {
addToast({ type: 'error', message: '加载消息通知失败' })
} finally {
setLoading(false)
}
}
const loadChannels = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const result = await getNotificationChannels()
if (result.success) {
setChannels(result.data || [])
}
} catch {
// ignore
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadChannels()
loadAccounts()
loadNotifications()
}, [_hasHydrated, isAuthenticated, token])
const handleToggleEnabled = async (notification: MessageNotification) => {
try {
await setMessageNotification(notification.cookie_id, notification.channel_id, !notification.enabled)
addToast({ type: 'success', message: notification.enabled ? '通知已禁用' : '通知已启用' })
loadNotifications()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
const handleDelete = async (notification: MessageNotification) => {
if (!confirm('确定要删除这个消息通知吗?')) return
try {
// 后端删除接口需要 notification_id但我们没有这个字段
// 改为禁用该通知
await setMessageNotification(notification.cookie_id, notification.channel_id, false)
addToast({ type: 'success', message: '通知已禁用' })
loadNotifications()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
const openAddModal = () => {
setFormAccountId('')
setFormChannelId('')
setFormEnabled(true)
setIsModalOpen(true)
}
const closeModal = () => {
setIsModalOpen(false)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formAccountId) {
addToast({ type: 'warning', message: '请选择账号' })
return
}
if (!formChannelId) {
addToast({ type: 'warning', message: '请选择通知渠道' })
return
}
setSaving(true)
try {
await setMessageNotification(formAccountId, Number(formChannelId), formEnabled)
addToast({ type: 'success', message: '通知已添加' })
closeModal()
loadNotifications()
} catch {
addToast({ type: 'error', message: '保存失败' })
} finally {
setSaving(false)
}
}
if (loading) {
return <PageLoading />
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<div className="flex gap-3">
<button onClick={openAddModal} className="btn-ios-primary ">
<Plus className="w-4 h-4" />
</button>
<button onClick={loadNotifications} className="btn-ios-secondary ">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Notifications List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="vben-card-header
flex items-center justify-between">
<h2 className="vben-card-title ">
<Mail className="w-4 h-4" />
</h2>
<span className="badge-primary">{notifications.length} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{notifications.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<Mail className="w-12 h-12 text-gray-300" />
<p></p>
</div>
</td>
</tr>
) : (
notifications.map((notification) => (
<tr key={`${notification.cookie_id}-${notification.channel_id}`}>
<td className="font-medium text-blue-600 dark:text-blue-400">{notification.cookie_id}</td>
<td className="text-sm">
{notification.channel_name || `渠道 ${notification.channel_id}`}
</td>
<td>
{notification.enabled ? (
<span className="badge-success"></span>
) : (
<span className="badge-danger"></span>
)}
</td>
<td>
<div className="flex gap-1">
<button
onClick={() => handleToggleEnabled(notification)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
title={notification.enabled ? '禁用' : '启用'}
>
{notification.enabled ? (
<PowerOff className="w-4 h-4 text-amber-500" />
) : (
<Power className="w-4 h-4 text-emerald-500" />
)}
</button>
<button
onClick={() => handleDelete(notification)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</motion.div>
{/* 添加通知弹窗 */}
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-md">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<button onClick={closeModal} className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div className="input-group">
<label className="input-label"> *</label>
<Select
value={formAccountId}
onChange={setFormAccountId}
options={[
{ value: '', label: '请选择账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="请选择账号"
/>
</div>
<div className="input-group">
<label className="input-label"> *</label>
<Select
value={formChannelId}
onChange={setFormChannelId}
options={[
{ value: '', label: '请选择通知渠道' },
...channels.map((channel) => ({
value: String(channel.id),
label: channel.name || channel.channel_name || `渠道 ${channel.id}`,
})),
]}
placeholder="请选择通知渠道"
/>
</div>
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200"></span>
<button
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
</button>
<button type="submit" className="btn-ios-primary" disabled={saving}>
{saving ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'保存'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,428 @@
import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { Bell, RefreshCw, Plus, Edit2, Trash2, Send, Settings, MessageCircle, Mail, Link, Smartphone, X, Loader2 } from 'lucide-react'
import { getNotificationChannels, deleteNotificationChannel, updateNotificationChannel, testNotificationChannel, addNotificationChannel } from '@/api/notifications'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { NotificationChannel } from '@/types'
// 所有支持的渠道类型配置
const channelTypes = [
{ type: 'dingtalk', label: '钉钉通知', desc: '钉钉机器人消息', icon: Bell, placeholder: '{"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=..."}' },
{ type: 'feishu', label: '飞书通知', desc: '飞书机器人消息', icon: Send, placeholder: '{"webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/..."}' },
{ type: 'bark', label: 'Bark通知', desc: 'iOS推送通知', icon: Smartphone, placeholder: '{"device_key": "xxx", "server_url": "https://api.day.app"}' },
{ type: 'email', label: '邮件通知', desc: 'SMTP邮件发送', icon: Mail, placeholder: '{"smtp_server": "...", "smtp_port": 587, "email_user": "...", "email_password": "...", "recipient_email": "..."}' },
{ type: 'webhook', label: 'Webhook', desc: '自定义HTTP请求', icon: Link, placeholder: '{"webhook_url": "https://..."}' },
{ type: 'wechat', label: '微信通知', desc: '企业微信机器人', icon: MessageCircle, placeholder: '{"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."}' },
{ type: 'telegram', label: 'Telegram', desc: 'Telegram机器人', icon: Send, placeholder: '{"bot_token": "...", "chat_id": "..."}' },
] as const
type ChannelType = typeof channelTypes[number]['type']
const channelTypeLabels: Record<string, string> = Object.fromEntries(
channelTypes.map(c => [c.type, c.label])
)
export function NotificationChannels() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [channels, setChannels] = useState<NotificationChannel[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null)
const [selectedType, setSelectedType] = useState<ChannelType | null>(null)
const [formName, setFormName] = useState('')
const [formConfig, setFormConfig] = useState('')
const [formEnabled, setFormEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const loadChannels = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
setLoading(false)
return
}
try {
setLoading(true)
const result = await getNotificationChannels()
if (result.success) {
setChannels(result.data || [])
}
} catch (err) {
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { status?: number } }
if (axiosErr.response?.status === 401) {
return
}
}
addToast({ type: 'error', message: '加载通知渠道失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated) return
if (!isAuthenticated || !token) {
setLoading(false)
return
}
loadChannels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_hasHydrated, isAuthenticated, token])
// 根据类型查找已配置的渠道
const getChannelByType = (type: string) => {
return channels.find(c => c.type === type)
}
const handleToggleEnabled = async (channel: NotificationChannel) => {
try {
await updateNotificationChannel(channel.id, {
name: channel.name,
config: channel.config,
enabled: !channel.enabled,
})
addToast({ type: 'success', message: channel.enabled ? '渠道已禁用' : '渠道已启用' })
loadChannels()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
const handleTest = async (id: string) => {
try {
const result = await testNotificationChannel(id)
if (result.success) {
addToast({ type: 'success', message: '测试消息发送成功' })
} else {
addToast({ type: 'error', message: result.message || '测试失败' })
}
} catch {
addToast({ type: 'error', message: '测试失败' })
}
}
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个通知渠道吗?')) return
try {
await deleteNotificationChannel(id)
addToast({ type: 'success', message: '删除成功' })
loadChannels()
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
// 打开配置弹窗(新建)
const openConfigModal = (type: ChannelType) => {
const typeConfig = channelTypes.find(c => c.type === type)
setSelectedType(type)
setEditingChannel(null)
setFormName(typeConfig?.label || '')
setFormConfig('')
setFormEnabled(true)
setIsModalOpen(true)
}
// 打开编辑弹窗
const openEditModal = (channel: NotificationChannel) => {
setSelectedType(channel.type as ChannelType)
setEditingChannel(channel)
setFormName(channel.name)
setFormConfig(JSON.stringify(channel.config || {}, null, 2))
setFormEnabled(channel.enabled)
setIsModalOpen(true)
}
const closeModal = () => {
setIsModalOpen(false)
setEditingChannel(null)
setSelectedType(null)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formName.trim()) {
addToast({ type: 'warning', message: '请输入渠道名称' })
return
}
if (!selectedType) return
setSaving(true)
try {
let config = {}
if (formConfig.trim()) {
try {
config = JSON.parse(formConfig)
} catch {
addToast({ type: 'error', message: '配置JSON格式错误' })
setSaving(false)
return
}
}
const data = {
name: formName.trim(),
type: selectedType,
config,
enabled: formEnabled,
}
if (editingChannel) {
await updateNotificationChannel(editingChannel.id, data)
addToast({ type: 'success', message: '渠道已更新' })
} else {
await addNotificationChannel(data)
addToast({ type: 'success', message: '渠道已添加' })
}
closeModal()
loadChannels()
} catch {
addToast({ type: 'error', message: '保存失败' })
} finally {
setSaving(false)
}
}
// 获取当前类型的配置提示
const getConfigHint = (type: ChannelType) => {
switch (type) {
case 'bark': return 'Bark是iOS推送通知服务需要填写设备密钥'
case 'dingtalk': return '请设置钉钉机器人Webhook URL可选填加签密钥'
case 'feishu': return '请设置飞书机器人Webhook URL'
case 'email': return '需要填写SMTP服务器、端口、发送邮箱、密码和接收邮箱'
case 'wechat': return '请设置企业微信机器人Webhook URL'
case 'telegram': return '需要填写Bot Token和Chat ID'
case 'webhook': return '填写自定义Webhook URL'
default: return ''
}
}
if (loading) {
return <PageLoading />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description">QQ通知等多种方式</p>
</div>
<button onClick={loadChannels} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* 渠道类型网格 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Settings className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body">
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4"></p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{channelTypes.map((ct, index) => {
const existingChannel = getChannelByType(ct.type)
const Icon = ct.icon
return (
<motion.div
key={ct.type}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className={`relative p-4 rounded-xl border-2 transition-all ${
existingChannel
? existingChannel.enabled
? 'border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/20'
: 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50'
: 'border-slate-200 dark:border-slate-700 hover:border-blue-300 dark:hover:border-blue-600'
}`}
>
<div className="text-center">
<div className={`w-10 h-10 mx-auto mb-2 rounded-lg flex items-center justify-center ${
existingChannel?.enabled ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400' : 'bg-slate-100 dark:bg-slate-700 text-slate-500'
}`}>
<Icon className="w-5 h-5" />
</div>
<h3 className="font-medium text-slate-900 dark:text-slate-100 text-sm">{ct.label}</h3>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{ct.desc}</p>
{existingChannel ? (
<div className="mt-3 flex items-center justify-center gap-1">
<button
onClick={() => openEditModal(existingChannel)}
className="text-xs px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800"
>
</button>
<button
onClick={() => handleToggleEnabled(existingChannel)}
className={`text-xs px-2 py-1 rounded ${
existingChannel.enabled
? 'bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400'
: 'bg-slate-100 dark:bg-slate-700 text-slate-500'
}`}
>
{existingChannel.enabled ? '已启用' : '已禁用'}
</button>
</div>
) : (
<button
onClick={() => openConfigModal(ct.type)}
className="mt-3 text-xs px-3 py-1 rounded border border-blue-300 dark:border-blue-600 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30"
>
<Plus className="w-3 h-3 inline mr-1" />
</button>
)}
</div>
</motion.div>
)
})}
</div>
</div>
</div>
{/* 已配置的渠道列表 */}
{channels.length > 0 && (
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Bell className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body">
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{channels.map(channel => (
<div key={channel.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
channel.enabled ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-600' : 'bg-slate-100 dark:bg-slate-700 text-slate-500'
}`}>
<Bell className="w-4 h-4" />
</div>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{channel.name}</p>
<p className="text-xs text-slate-500">{channelTypeLabels[channel.type] || channel.type}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded ${
channel.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400' : 'bg-slate-100 text-slate-500 dark:bg-slate-700'
}`}>
{channel.enabled ? '启用' : '禁用'}
</span>
<button
onClick={() => handleTest(channel.id)}
className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500"
title="测试"
>
<Send className="w-4 h-4" />
</button>
<button
onClick={() => openEditModal(channel)}
className="p-1.5 rounded hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-500"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(channel.id)}
className="p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-500"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* 配置弹窗 */}
{isModalOpen && selectedType && (
<div className="modal-overlay">
<div className="modal-content max-w-lg">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold">
{editingChannel ? '编辑' : '配置'}{channelTypeLabels[selectedType]}
</h2>
<button onClick={closeModal} className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg">
<X className="w-4 h-4 text-slate-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div>
<label className="input-label"></label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="input-ios"
placeholder={`如:我的${channelTypeLabels[selectedType]}`}
/>
</div>
<div>
<label className="input-label"> (JSON)</label>
<textarea
value={formConfig}
onChange={(e) => setFormConfig(e.target.value)}
className="input-ios h-32 resize-none font-mono text-sm"
placeholder={channelTypes.find(c => c.type === selectedType)?.placeholder}
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{getConfigHint(selectedType)}
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200"></span>
<button
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
</button>
<button type="submit" className="btn-ios-primary" disabled={saving}>
{saving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
...
</>
) : (
'保存'
)}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

457
src/pages/orders/Orders.tsx Normal file
View File

@ -0,0 +1,457 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { ShoppingCart, RefreshCw, Search, Trash2, Eye, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { getOrders, deleteOrder, getOrderDetail, type OrderDetail } from '@/api/orders'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { Order, Account } from '@/types'
const statusMap: Record<string, { label: string; class: string }> = {
processing: { label: '处理中', class: 'badge-warning' },
pending_ship: { label: '待发货', class: 'badge-info' },
processed: { label: '已处理', class: 'badge-info' },
shipped: { label: '已发货', class: 'badge-success' },
completed: { label: '已完成', class: 'badge-success' },
refunding: { label: '退款中', class: 'badge-warning' },
refund_cancelled: { label: '退款撤销', class: 'badge-info' },
cancelled: { label: '已关闭', class: 'badge-danger' },
unknown: { label: '未知', class: 'badge-gray' },
}
export function Orders() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [orders, setOrders] = useState<Order[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [selectedStatus, setSelectedStatus] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [detailModalOpen, setDetailModalOpen] = useState(false)
const [orderDetail, setOrderDetail] = useState<OrderDetail | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const loadOrders = async (page: number = currentPage) => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined, page, pageSize)
if (result.success) {
setOrders(result.data || [])
setTotal(result.total || 0)
setTotalPages(result.total_pages || 0)
setCurrentPage(page)
}
} catch {
addToast({ type: 'error', message: '加载订单列表失败' })
} finally {
setLoading(false)
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadOrders(1)
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
setCurrentPage(1)
loadOrders(1)
}, [_hasHydrated, isAuthenticated, token, selectedAccount, selectedStatus])
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个订单吗?')) return
try {
const result = await deleteOrder(id)
if (result.success) {
addToast({ type: 'success', message: '删除成功' })
loadOrders()
} else {
addToast({ type: 'error', message: result.message || '删除失败' })
}
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
const handleShowDetail = async (orderNo: string) => {
setLoadingDetail(true)
setDetailModalOpen(true)
try {
const result = await getOrderDetail(orderNo)
if (result.success && result.data) {
setOrderDetail(result.data)
} else {
addToast({ type: 'error', message: '获取订单详情失败' })
setDetailModalOpen(false)
}
} catch {
addToast({ type: 'error', message: '获取订单详情失败' })
setDetailModalOpen(false)
} finally {
setLoadingDetail(false)
}
}
const filteredOrders = orders.filter((order) => {
if (!searchKeyword) return true
const keyword = searchKeyword.toLowerCase()
return (
order.order_id?.toLowerCase().includes(keyword) ||
order.item_id?.toLowerCase().includes(keyword) ||
order.buyer_id?.toLowerCase().includes(keyword)
)
})
if (loading && orders.length === 0) {
return <PageLoading />
}
return (
<div className="space-y-3 sm:space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<button onClick={() => loadOrders(currentPage)} className="btn-ios-secondary w-full sm:w-auto">
<RefreshCw className="w-4 h-4" />
</button>
</div>
{/* Filters */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="vben-card-body">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedStatus}
onChange={setSelectedStatus}
options={[
{ value: '', label: '所有状态' },
{ value: 'processing', label: '处理中' },
{ value: 'pending_ship', label: '待发货' },
{ value: 'shipped', label: '已发货' },
{ value: 'completed', label: '已完成' },
{ value: 'refunding', label: '退款中' },
{ value: 'cancelled', label: '已关闭' },
]}
placeholder="所有状态"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索订单ID或商品ID..."
className="input-ios pl-9"
/>
</div>
</div>
</div>
</div>
</motion.div>
{/* Orders List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="vben-card"
>
<div className="vben-card-header flex items-center justify-between">
<h2 className="vben-card-title">
<ShoppingCart className="w-4 h-4" />
</h2>
<span className="badge-primary"> {total} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th>ID</th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>ID</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredOrders.length === 0 ? (
<tr>
<td colSpan={10} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<ShoppingCart className="w-12 h-12 text-gray-300" />
<p></p>
</div>
</td>
</tr>
) : (
filteredOrders.map((order) => {
const status = statusMap[order.status] || statusMap.unknown
return (
<tr key={order.id}>
<td className="font-mono text-sm">{order.order_id}</td>
<td className="text-sm">{order.item_id}</td>
<td className="text-sm">{order.buyer_id}</td>
<td>{order.quantity}</td>
<td className="text-amber-600 font-medium">¥{order.amount}</td>
<td>
<span className={status.class}>{status.label}</span>
</td>
<td>
{order.is_bargain ? (
<span className="badge-warning"></span>
) : (
<span className="badge-gray"></span>
)}
</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{order.cookie_id}</td>
<td className="text-sm text-gray-500">
{order.created_at ? new Date(order.created_at).toLocaleString('zh-CN') : '-'}
</td>
<td>
<div className="flex items-center gap-1">
<button
onClick={() => handleShowDetail(order.order_id)}
className="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
title="查看详情"
>
<Eye className="w-4 h-4 text-blue-500" />
</button>
<button
onClick={() => handleDelete(order.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* 分页 */}
{totalPages > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500">
{currentPage} {totalPages} {total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => loadOrders(currentPage - 1)}
disabled={currentPage <= 1 || loading}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="上一页"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (currentPage <= 3) {
pageNum = i + 1
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = currentPage - 2 + i
}
return (
<button
key={pageNum}
onClick={() => loadOrders(pageNum)}
disabled={loading}
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
currentPage === pageNum
? 'bg-blue-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{pageNum}
</button>
)
})}
</div>
<button
onClick={() => loadOrders(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="下一页"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</motion.div>
{/* 订单详情弹窗 */}
{detailModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-2xl">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={() => setDetailModalOpen(false)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="modal-body">
{loadingDetail ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-2 text-gray-500">...</span>
</div>
) : orderDetail ? (
<div className="space-y-4">
{/* 基本信息 */}
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span className="font-mono">{orderDetail.order_id}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span>{orderDetail.item_id || '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span>{orderDetail.buyer_id || '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span className="text-blue-600">{orderDetail.cookie_id || '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span className={statusMap[orderDetail.status]?.class || 'badge-gray'}>
{statusMap[orderDetail.status]?.label || '未知'}
</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
{orderDetail.is_bargain ? (
<span className="badge-warning"></span>
) : (
<span className="badge-gray"></span>
)}
</div>
</div>
</div>
{/* 商品信息 */}
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.spec_name || '无'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.spec_value || '无'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.quantity || 1}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span className="text-amber-600 font-medium">¥{orderDetail.amount || '0.00'}</span>
</div>
</div>
</div>
{/* 时间信息 */}
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.created_at ? new Date(orderDetail.created_at).toLocaleString('zh-CN') : '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.updated_at ? new Date(orderDetail.updated_at).toLocaleString('zh-CN') : '未知'}</span>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500"></div>
)}
</div>
<div className="modal-footer">
<button onClick={() => setDetailModalOpen(false)} className="btn-ios-secondary">
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,193 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Search, ShoppingBag, ExternalLink, MapPin, Heart } from 'lucide-react'
import { searchItems, SearchResultItem } from '@/api/search'
import { useUIStore } from '@/store/uiStore'
import { ButtonLoading } from '@/components/common/Loading'
export function ItemSearch() {
const { addToast } = useUIStore()
const [loading, setLoading] = useState(false)
const [keyword, setKeyword] = useState('')
const [results, setResults] = useState<SearchResultItem[]>([])
const [total, setTotal] = useState(0)
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!keyword.trim()) {
addToast({ type: 'warning', message: '请输入搜索关键词' })
return
}
addToast({ type: 'info', message: '正在搜索中,请稍候...' })
try {
setLoading(true)
setResults([])
const result = await searchItems(keyword.trim())
if (result.success) {
setResults(result.data || [])
setTotal(result.total || result.data.length)
if ((result.data || []).length === 0) {
addToast({ type: 'info', message: '未找到相关商品' })
} else {
addToast({ type: 'success', message: `搜索完成,找到 ${result.data.length} 件商品` })
}
if (result.error) {
addToast({ type: 'warning', message: result.error })
}
}
} catch {
addToast({ type: 'error', message: '搜索失败,请稍后重试' })
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
{total > 0 && (
<span className="badge-primary"> {total} </span>
)}
</div>
{/* Search Bar */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="vben-card-body">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 z-10" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="输入关键词搜索商品..."
className="input-ios pl-12"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-ios-primary w-full md:w-32 flex items-center justify-center"
>
{loading ? <ButtonLoading /> : '搜索'}
</button>
</form>
</div>
</motion.div>
{/* Results */}
{results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
{results.map((item, index) => (
<motion.a
key={item.item_id || index}
href={item.item_url || `https://www.goofish.com/item?id=${item.item_id}`}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="vben-card group hover:shadow-lg transition-all duration-300 overflow-hidden"
>
{/* 商品图片 */}
<div className="aspect-square bg-slate-100 dark:bg-slate-800 relative overflow-hidden">
{item.main_image ? (
<img
src={item.main_image}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-slate-300 dark:text-slate-600">
<ShoppingBag className="w-12 h-12" />
</div>
)}
{/* 外链图标 */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="bg-black/50 rounded-full p-1.5">
<ExternalLink className="w-3.5 h-3.5 text-white" />
</div>
</div>
</div>
{/* 商品信息 */}
<div className="p-3">
<h3 className="font-medium text-slate-900 dark:text-slate-100 line-clamp-2 text-sm mb-2 min-h-[2.5rem]">
{item.title}
</h3>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold text-red-500">{item.price}</span>
{item.want_count && item.want_count > 0 && (
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Heart className="w-3 h-3" />
{item.want_count}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span className="truncate max-w-[60%]">{item.seller_name || '-'}</span>
{item.area && (
<span className="flex items-center gap-0.5">
<MapPin className="w-3 h-3" />
{item.area}
</span>
)}
</div>
{/* 标签 */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{item.tags.slice(0, 3).map((tag, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 rounded text-slate-600 dark:text-slate-300">
{tag}
</span>
))}
</div>
)}
</div>
</motion.a>
))}
</motion.div>
)}
{/* Empty State */}
{!loading && results.length === 0 && (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<ShoppingBag className="w-16 h-16 text-slate-300 dark:text-slate-600 mx-auto mb-4" />
<p></p>
</div>
)}
{/* Loading State */}
{loading && (
<div className="text-center py-12">
<div className="inline-flex items-center gap-2 text-blue-500">
<ButtonLoading />
<span>...</span>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,747 @@
import { useState, useEffect, useRef } from 'react'
import { Settings as SettingsIcon, Save, Bot, Mail, RefreshCw, Key, Download, Upload, Archive, Eye, EyeOff, Copy } from 'lucide-react'
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend, changePassword, downloadDatabaseBackup, uploadDatabaseBackup, reloadSystemCache, exportUserBackup, importUserBackup } from '@/api/settings'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { SystemSettings, Account } from '@/types'
export function Settings() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated, user } = useAuthStore()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState<SystemSettings | null>(null)
// 密码修改状态
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [changingPassword, setChangingPassword] = useState(false)
// 备份管理状态
const [uploadingBackup, setUploadingBackup] = useState(false)
const [reloadingCache, setReloadingCache] = useState(false)
const backupFileRef = useRef<HTMLInputElement>(null)
const userBackupFileRef = useRef<HTMLInputElement>(null)
// AI 测试账号选择
const [accounts, setAccounts] = useState<Account[]>([])
const [testAccountId, setTestAccountId] = useState('')
const [testingAI, setTestingAI] = useState(false)
// SMTP密码显示状态
const [showSmtpPassword, setShowSmtpPassword] = useState(false)
// API Key 显示状态
const [showApiKey, setShowApiKey] = useState(false)
// 密码修改显示状态
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const loadSettings = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getSystemSettings()
if (result.success && result.data) {
setSettings(result.data)
}
} catch {
addToast({ type: 'error', message: '加载系统设置失败' })
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadSettings()
}, [_hasHydrated, isAuthenticated, token])
const handleSave = async () => {
if (!settings) return
try {
setSaving(true)
const result = await updateSystemSettings(settings)
if (result.success) {
addToast({ type: 'success', message: '设置保存成功' })
} else {
addToast({ type: 'error', message: result.message || '保存失败' })
}
} catch {
addToast({ type: 'error', message: '保存设置失败' })
} finally {
setSaving(false)
}
}
// 加载账号列表
const loadAccounts = async () => {
try {
const data = await getAccounts()
setAccounts(data)
if (data.length > 0 && !testAccountId) {
setTestAccountId(data[0].id)
}
} catch {
// ignore
}
}
useEffect(() => {
if (_hasHydrated && isAuthenticated && token) {
loadAccounts()
}
}, [_hasHydrated, isAuthenticated, token])
const handleTestAI = async () => {
if (!testAccountId) {
addToast({ type: 'warning', message: '请先选择一个账号' })
return
}
setTestingAI(true)
try {
// 测试前先保存当前设置,确保使用最新配置
if (settings) {
await updateSystemSettings(settings)
}
const result = await testAIConnection(testAccountId)
if (result.success) {
addToast({ type: 'success', message: result.message || 'AI 连接测试成功' })
} else {
addToast({ type: 'error', message: result.message || 'AI 连接测试失败' })
}
} catch {
addToast({ type: 'error', message: 'AI 连接测试失败' })
} finally {
setTestingAI(false)
}
}
const handleTestEmail = async () => {
const email = prompt('请输入测试邮箱地址:')
if (!email) return
try {
const result = await testEmailSend(email)
if (result.success) {
addToast({ type: 'success', message: '测试邮件发送成功' })
} else {
addToast({ type: 'error', message: result.message || '发送测试邮件失败' })
}
} catch {
addToast({ type: 'error', message: '发送测试邮件失败' })
}
}
// 修改密码
const handleChangePassword = async () => {
if (!currentPassword) {
addToast({ type: 'warning', message: '请输入当前密码' })
return
}
if (!newPassword) {
addToast({ type: 'warning', message: '请输入新密码' })
return
}
if (newPassword !== confirmPassword) {
addToast({ type: 'warning', message: '两次输入的密码不一致' })
return
}
if (newPassword.length < 6) {
addToast({ type: 'warning', message: '新密码长度不能少于6位' })
return
}
try {
setChangingPassword(true)
const result = await changePassword({ current_password: currentPassword, new_password: newPassword })
if (result.success) {
addToast({ type: 'success', message: '密码修改成功' })
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} else {
addToast({ type: 'error', message: result.message || '密码修改失败' })
}
} catch {
addToast({ type: 'error', message: '密码修改失败' })
} finally {
setChangingPassword(false)
}
}
// 下载数据库备份(管理员)
const handleDownloadBackup = () => {
const url = downloadDatabaseBackup()
window.open(url, '_blank')
}
// 上传数据库备份(管理员)
const handleUploadBackup = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.name.endsWith('.db')) {
addToast({ type: 'error', message: '只支持 .db 格式的数据库文件' })
return
}
if (!confirm('警告:恢复数据库将覆盖所有当前数据!确定要继续吗?')) {
e.target.value = ''
return
}
try {
setUploadingBackup(true)
const result = await uploadDatabaseBackup(file)
if (result.success) {
addToast({ type: 'success', message: '数据库恢复成功' })
} else {
addToast({ type: 'error', message: result.message || '数据库恢复失败' })
}
} catch {
addToast({ type: 'error', message: '数据库恢复失败' })
} finally {
setUploadingBackup(false)
e.target.value = ''
}
}
// 刷新系统缓存
const handleReloadCache = async () => {
try {
setReloadingCache(true)
const result = await reloadSystemCache()
if (result.success) {
addToast({ type: 'success', message: '系统缓存已刷新' })
} else {
addToast({ type: 'error', message: result.message || '刷新缓存失败' })
}
} catch {
addToast({ type: 'error', message: '刷新缓存失败' })
} finally {
setReloadingCache(false)
}
}
// 导出用户备份
const handleExportUserBackup = () => {
const url = exportUserBackup()
window.open(url, '_blank')
}
// 导入用户备份
const handleImportUserBackup = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.name.endsWith('.json')) {
addToast({ type: 'error', message: '只支持 .json 格式的备份文件' })
return
}
try {
const result = await importUserBackup(file)
if (result.success) {
addToast({ type: 'success', message: '备份导入成功' })
} else {
addToast({ type: 'error', message: result.message || '备份导入失败' })
}
} catch {
addToast({ type: 'error', message: '备份导入失败' })
} finally {
e.target.value = ''
}
}
if (loading) {
return <PageLoading />
}
const isAdmin = user?.is_admin
return (
<div className="space-y-4">
{/* Header */}
<div className="page-header flex-between flex-wrap gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description">{isAdmin ? '配置系统全局设置' : '修改个人密码'}</p>
</div>
<div className="flex gap-2">
{isAdmin && (
<>
<button onClick={loadSettings} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
<button onClick={handleSave} disabled={saving} className="btn-ios-primary">
{saving ? <ButtonLoading /> : <Save className="w-4 h-4" />}
</button>
</>
)}
</div>
</div>
{/* 双列布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 左列 - 仅管理员可见 */}
{isAdmin && (
<div className="space-y-4">
{/* General Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<SettingsIcon className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={Boolean(settings?.registration_enabled ?? false)}
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={Boolean(settings?.show_default_login_info ?? false)}
onChange={(e) => setSettings(s => s ? { ...s, show_default_login_info: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="flex items-center justify-between py-3 border-t border-slate-100 dark:border-slate-700">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={Boolean(settings?.login_captcha_enabled ?? true)}
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
</div>
{/* AI Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Bot className="w-4 h-4" />
AI
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="input-group">
<label className="input-label">API </label>
<input
type="text"
value={settings?.ai_api_url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_url: e.target.value } : null)}
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1"> /chat/completions</p>
</div>
<div className="input-group">
<label className="input-label">API Key</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={settings?.ai_api_key || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
placeholder="sk-..."
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showApiKey ? '隐藏' : '显示'}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (settings?.ai_api_key) {
navigator.clipboard.writeText(settings.ai_api_key)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={settings?.ai_model || 'qwen-plus'}
onChange={(e) => setSettings(s => s ? { ...s, ai_model: e.target.value } : null)}
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1">: qwen-plusqwen-turbogpt-3.5-turbogpt-4</p>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="input-label"></label>
<Select
value={testAccountId}
onChange={setTestAccountId}
options={accounts.map(a => ({ value: a.id, label: a.id }))}
placeholder="选择账号"
/>
</div>
<button
onClick={handleTestAI}
className="btn-ios-secondary"
disabled={testingAI || !testAccountId}
>
{testingAI ? '测试中...' : '测试 AI 连接'}
</button>
</div>
<div className="bg-slate-50 dark:bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500 dark:text-slate-400">
<p className="font-medium mb-1"> AI :</p>
<ul className="space-y-0.5 list-disc list-inside">
<li>阿里云通义千问: https://dashscope.aliyuncs.com/compatible-mode/v1</li>
<li>OpenAI: https://api.openai.com/v1</li>
<li>国内中转: 使用服务商提供的 API </li>
</ul>
</div>
</div>
</div>
</div>
)}
{/* 右列 */}
<div className="space-y-4">
{/* Email Settings - 仅管理员可见 */}
{isAdmin && (
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Mail className="w-4 h-4" />
SMTP邮件配置
</h2>
</div>
<div className="vben-card-body space-y-4">
<p className="text-sm text-slate-500 dark:text-slate-400">SMTP服务器用于发送注册验证码等邮件通知</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label">SMTP服务器</label>
<input
type="text"
value={settings?.smtp_server || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_server: e.target.value } : null)}
placeholder="smtp.qq.com"
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1">smtp.qq.comsmtp.gmail.com</p>
</div>
<div className="input-group">
<label className="input-label">SMTP端口</label>
<input
type="number"
value={settings?.smtp_port || 587}
onChange={(e) => setSettings(s => s ? { ...s, smtp_port: parseInt(e.target.value) } : null)}
placeholder="587"
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1">587(TLS)465(SSL)</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label"></label>
<input
type="email"
value={settings?.smtp_user || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_user: e.target.value } : null)}
placeholder="your-email@qq.com"
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1"></p>
</div>
<div className="input-group">
<label className="input-label">/</label>
<div className="relative">
<input
type={showSmtpPassword ? 'text' : 'password'}
value={settings?.smtp_password || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
placeholder="输入密码或授权码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showSmtpPassword ? '隐藏' : '显示'}
>
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (settings?.smtp_password) {
navigator.clipboard.writeText(settings.smtp_password)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1">(QQ邮箱需要授权码)</p>
</div>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={settings?.smtp_from || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_from: e.target.value } : null)}
placeholder="闲鱼自动回复系统"
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1">使</p>
</div>
<button onClick={handleTestEmail} className="btn-ios-secondary">
</button>
</div>
</div>
)}
{/* 密码修改 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Key className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="请输入当前密码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showCurrentPassword ? '隐藏' : '显示'}
>
{showCurrentPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (currentPassword) {
navigator.clipboard.writeText(currentPassword)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showNewPassword ? '隐藏' : '显示'}
>
{showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (newPassword) {
navigator.clipboard.writeText(newPassword)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showConfirmPassword ? '隐藏' : '显示'}
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (confirmPassword) {
navigator.clipboard.writeText(confirmPassword)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<button
onClick={handleChangePassword}
disabled={changingPassword}
className="btn-ios-primary"
>
{changingPassword ? <ButtonLoading /> : <Key className="w-4 h-4" />}
</button>
</div>
</div>
{/* 数据备份 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Archive className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
{/* 用户数据备份 */}
<div>
<p className="font-medium text-slate-900 dark:text-slate-100 mb-1"></p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2"></p>
<div className="flex flex-wrap gap-2">
<button onClick={handleExportUserBackup} className="btn-ios-primary">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
<Upload className="w-4 h-4" />
<input
ref={userBackupFileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportUserBackup}
/>
</label>
</div>
</div>
{/* 管理员数据库备份 */}
{user?.is_admin && (
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<span className="text-xs bg-slate-500 text-white px-1.5 py-0.5 rounded"></span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2"></p>
<div className="flex flex-wrap gap-2 mb-2">
<button onClick={handleDownloadBackup} className="btn-ios-primary">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
{uploadingBackup ? <ButtonLoading /> : <Upload className="w-4 h-4" />}
<input
ref={backupFileRef}
type="file"
accept=".db"
className="hidden"
onChange={handleUploadBackup}
disabled={uploadingBackup}
/>
</label>
<button
onClick={handleReloadCache}
disabled={reloadingCache}
className="btn-ios-secondary"
>
{reloadingCache ? <ButtonLoading /> : <RefreshCw className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}

74
src/store/authStore.ts Normal file
View File

@ -0,0 +1,74 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import type { User } from '@/types'
interface AuthState {
token: string | null
user: User | null
isAuthenticated: boolean
_hasHydrated: boolean
setAuth: (token: string, user: User) => void
clearAuth: () => void
updateUser: (user: Partial<User>) => void
setHasHydrated: (state: boolean) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
isAuthenticated: false,
_hasHydrated: false,
setAuth: (token, user) => {
localStorage.setItem('auth_token', token)
localStorage.setItem('user_info', JSON.stringify(user))
set({ token, user, isAuthenticated: true })
},
clearAuth: () => {
localStorage.removeItem('auth_token')
localStorage.removeItem('user_info')
set({ token: null, user: null, isAuthenticated: false })
},
updateUser: (userData) => {
set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
}))
},
setHasHydrated: (hydrated) => {
set({ _hasHydrated: hydrated })
},
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
token: state.token,
user: state.user,
isAuthenticated: state.isAuthenticated
}),
onRehydrateStorage: () => {
// 返回一个回调函数,在 hydration 完成后执行
return (state) => {
// 确保 localStorage 中的 auth_token 与 store 同步
// axios 拦截器从 localStorage.getItem('auth_token') 读取
if (state?.token) {
localStorage.setItem('auth_token', state.token)
}
if (state?.user) {
localStorage.setItem('user_info', JSON.stringify(state.user))
}
// 延迟设置,确保 store 已完全初始化
setTimeout(() => {
useAuthStore.setState({ _hasHydrated: true })
}, 0)
}
},
}
)
)

65
src/store/uiStore.ts Normal file
View File

@ -0,0 +1,65 @@
import { create } from 'zustand'
interface Toast {
id: string
message: string
type: 'success' | 'error' | 'warning' | 'info'
duration?: number
}
interface UIState {
sidebarCollapsed: boolean
sidebarMobileOpen: boolean
loading: boolean
toasts: Toast[]
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
setSidebarMobileOpen: (open: boolean) => void
setLoading: (loading: boolean) => void
addToast: (toast: Omit<Toast, 'id'>) => void
removeToast: (id: string) => void
}
export const useUIStore = create<UIState>((set) => ({
sidebarCollapsed: false,
sidebarMobileOpen: false,
loading: false,
toasts: [],
toggleSidebar: () => {
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }))
},
setSidebarCollapsed: (collapsed) => {
set({ sidebarCollapsed: collapsed })
},
setSidebarMobileOpen: (open) => {
set({ sidebarMobileOpen: open })
},
setLoading: (loading) => {
set({ loading })
},
addToast: (toast) => {
const id = Math.random().toString(36).substr(2, 9)
set((state) => ({
toasts: [...state.toasts, { ...toast, id }],
}))
// 自动移除 toast
const duration = toast.duration ?? 3000
setTimeout(() => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
}, duration)
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
}))
},
}))

782
src/styles/globals.css Normal file
View File

@ -0,0 +1,782 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ========================================
蓝白色系设计系统 - 支持暗黑模式
======================================== */
/* 基础样式 */
@layer base {
:root {
/* 主题色 - 蓝色系 */
--color-primary: 59 130 246; /* Blue-500 */
--color-primary-hover: 37 99 235; /* Blue-600 */
--color-primary-light: 219 234 254; /* Blue-100 */
--color-success: 34 197 94; /* Green-500 */
--color-warning: 245 158 11; /* Amber-500 */
--color-danger: 239 68 68; /* Red-500 */
--color-info: 14 165 233; /* Sky-500 */
/* 侧边栏 */
--color-sidebar: 15 23 42; /* Slate-900 */
/* 背景色 */
--color-bg: 248 250 252; /* Slate-50 */
--color-bg-elevated: 255 255 255; /* White */
/* 文字色 */
--color-text: 30 41 59; /* Slate-800 */
--color-text-secondary: 100 116 139; /* Slate-500 */
/* 边框色 */
--color-border: 226 232 240; /* Slate-200 */
}
.dark {
--color-primary: 96 165 250; /* Blue-400 */
--color-primary-hover: 59 130 246; /* Blue-500 */
--color-primary-light: 30 58 138; /* Blue-900 */
--color-success: 74 222 128; /* Green-400 */
--color-warning: 251 191 36; /* Amber-400 */
--color-danger: 248 113 113; /* Red-400 */
--color-info: 56 189 248; /* Sky-400 */
--color-sidebar: 15 23 42; /* Slate-900 */
--color-bg: 15 23 42; /* Slate-900 */
--color-bg-elevated: 30 41 59; /* Slate-800 */
--color-text: 241 245 249; /* Slate-100 */
--color-text-secondary: 148 163 184; /* Slate-400 */
--color-border: 51 65 85; /* Slate-700 */
}
* {
@apply border-slate-200 dark:border-slate-700;
}
html {
@apply scroll-smooth;
}
body {
@apply bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-100 antialiased transition-colors duration-200;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
}
/* 滚动条 */
::-webkit-scrollbar {
@apply w-1.5 h-1.5;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-300/60 dark:bg-slate-600/60 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-400/80 dark:bg-slate-500/80;
}
/* 侧边栏滚动条 - 隐藏 */
.sidebar-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.sidebar-scrollbar::-webkit-scrollbar {
display: none; /* Chrome/Safari/Opera */
}
}
/* ========================================
组件样式 - 支持暗黑模式
======================================== */
@layer components {
/* ==================== 卡片系统 ==================== */
.vben-card {
@apply bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-100 dark:border-slate-700;
}
.vben-card-header {
@apply px-5 py-4 border-b border-slate-100 dark:border-slate-700 flex items-center justify-between;
}
.vben-card-title {
@apply text-base font-semibold text-slate-800 dark:text-slate-100 flex items-center gap-2;
}
.vben-card-body {
@apply p-5;
}
/* ==================== 开关组件 ==================== */
.switch-ios {
@apply relative inline-flex items-center cursor-pointer;
}
.switch-ios input {
@apply sr-only;
}
.switch-ios .switch-slider {
@apply w-11 h-6 bg-slate-200 dark:bg-slate-600 rounded-full
transition-colors duration-200 ease-in-out
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-slate-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all after:duration-200;
}
.switch-ios input:checked + .switch-slider {
@apply bg-blue-500;
}
.switch-ios input:checked + .switch-slider::after {
transform: translateX(100%);
border-color: white;
}
.switch-ios input:focus + .switch-slider {
@apply ring-2 ring-blue-300 dark:ring-blue-800 ring-offset-2 dark:ring-offset-slate-800;
}
/* ==================== 勾选框组件 ==================== */
.checkbox-ios {
@apply w-4 h-4 rounded border-slate-300 dark:border-slate-600
text-blue-500 bg-white dark:bg-slate-700
focus:ring-2 focus:ring-blue-500 focus:ring-offset-0
transition-colors cursor-pointer;
}
.checkbox-ios:checked {
@apply bg-blue-500 border-blue-500;
}
/* 勾选框标签组合 */
.checkbox-label {
@apply flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer;
}
/* 兼容旧类名 */
.glass-card {
@apply vben-card;
}
/* ==================== 按钮系统 ==================== */
.btn-ios {
@apply inline-flex items-center justify-center gap-2
px-4 py-2 rounded-md text-sm font-medium
transition-all duration-150 ease-in-out
focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-slate-800
disabled:opacity-60 disabled:cursor-not-allowed disabled:pointer-events-none;
}
.btn-ios-primary {
@apply btn-ios bg-blue-500 text-white
hover:bg-blue-600 active:bg-blue-700
focus:ring-blue-500 shadow-sm;
}
.btn-ios-secondary {
@apply btn-ios bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-200
border border-slate-300 dark:border-slate-600
hover:bg-slate-50 dark:hover:bg-slate-600 active:bg-slate-100
focus:ring-blue-500;
}
.btn-ios-danger {
@apply btn-ios bg-red-500 text-white
hover:bg-red-600 active:bg-red-700
focus:ring-red-500 shadow-sm;
}
.btn-ios-success {
@apply btn-ios bg-green-500 text-white
hover:bg-green-600 active:bg-green-700
focus:ring-green-500 shadow-sm;
}
.btn-ios-warning {
@apply btn-ios bg-amber-500 text-white
hover:bg-amber-600 active:bg-amber-700
focus:ring-amber-500 shadow-sm;
}
.btn-ios-ghost {
@apply btn-ios bg-transparent text-slate-600 dark:text-slate-300
hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-white
focus:ring-slate-500;
}
.btn-ios-link {
@apply btn-ios bg-transparent text-blue-500 dark:text-blue-400
hover:text-blue-600 dark:hover:text-blue-300 hover:underline
focus:ring-0 p-0;
}
/* 按钮尺寸 */
.btn-sm {
@apply px-3 py-1.5 text-xs rounded;
}
.btn-lg {
@apply px-6 py-3 text-base rounded-lg;
}
/* ==================== 输入框系统 ==================== */
.input-ios {
@apply w-full px-3 py-2 text-sm
text-slate-900 dark:text-slate-100
bg-white dark:bg-slate-800
border border-slate-300 dark:border-slate-600 rounded-md
placeholder:text-slate-400 dark:placeholder:text-slate-500
transition-colors duration-150
hover:border-slate-400 dark:hover:border-slate-500
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500
disabled:bg-slate-100 dark:disabled:bg-slate-700 disabled:text-slate-500 disabled:cursor-not-allowed;
}
.input-ios-error {
@apply input-ios border-red-500 focus:border-red-500 focus:ring-red-500;
}
/* 自定义下拉框样式 */
select.input-ios {
@apply appearance-none cursor-pointer
bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2364748b%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')]
bg-[length:1.25rem_1.25rem]
bg-[right_0.5rem_center]
bg-no-repeat
pr-10;
}
.dark select.input-ios {
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2394a3b8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E');
}
/* 输入框组 */
.input-group {
@apply space-y-1.5;
}
.input-label {
@apply block text-sm font-medium text-slate-700 dark:text-slate-300;
}
.input-hint {
@apply text-xs text-slate-500 dark:text-slate-400 mt-1;
}
.input-error {
@apply text-xs text-red-600 dark:text-red-400 mt-1;
}
/* ==================== 表格系统 ==================== */
.vben-table-wrapper {
@apply bg-white dark:bg-slate-800 rounded-lg shadow-sm overflow-hidden;
}
.table-ios {
@apply w-full text-sm;
}
.table-ios thead {
@apply bg-slate-50 dark:bg-slate-700/50;
}
.table-ios th {
@apply px-4 py-3 text-left text-xs font-semibold text-slate-600 dark:text-slate-300 uppercase tracking-wider
border-b border-slate-200 dark:border-slate-600;
}
.table-ios td {
@apply px-4 py-3 text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-slate-700;
}
.table-ios tbody tr {
@apply transition-colors duration-150;
}
.table-ios tbody tr:hover {
@apply bg-blue-50/50 dark:bg-blue-900/20;
}
.table-ios tbody tr:last-child td {
@apply border-b-0;
}
/* 表格操作列 */
.table-actions {
@apply flex items-center gap-1;
}
.table-action-btn {
@apply p-1.5 rounded-md text-slate-500 dark:text-slate-400
hover:text-slate-700 dark:hover:text-slate-200
hover:bg-slate-100 dark:hover:bg-slate-700
transition-colors duration-150;
}
/* ==================== 徽章系统 ==================== */
.badge-ios {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium;
}
.badge-success {
@apply badge-ios bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400;
}
.badge-warning {
@apply badge-ios bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400;
}
.badge-danger {
@apply badge-ios bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-400;
}
.badge-info {
@apply badge-ios bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400;
}
.badge-gray {
@apply badge-ios bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-300;
}
.badge-primary {
@apply badge-ios bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400;
}
/* 状态点 */
.status-dot {
@apply w-2 h-2 rounded-full;
}
.status-dot-success {
@apply status-dot bg-green-500;
}
.status-dot-danger {
@apply status-dot bg-red-500;
}
.status-dot-warning {
@apply status-dot bg-amber-500;
}
/* ==================== 模态框系统 ==================== */
.modal-overlay {
@apply fixed inset-0 bg-black/50 dark:bg-black/70 z-50
flex items-center justify-center p-4
animate-fade-in;
}
.modal-content {
@apply bg-white dark:bg-slate-800 rounded-lg shadow-2xl w-full max-w-lg max-h-[85vh]
flex flex-col overflow-hidden
animate-slide-up;
}
.modal-content > form {
@apply flex flex-col flex-1 min-h-0;
}
.modal-header {
@apply flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700
flex-shrink-0;
}
.modal-title {
@apply text-lg font-semibold text-slate-900 dark:text-slate-100;
}
.modal-close {
@apply p-1 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-200
hover:bg-slate-100 dark:hover:bg-slate-700
transition-colors duration-150;
}
.modal-body {
@apply flex-1 overflow-y-auto p-6 min-h-0;
}
.modal-footer {
@apply flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-200 dark:border-slate-700
bg-slate-50 dark:bg-slate-800/50 flex-shrink-0;
}
/* ==================== 统计卡片 ==================== */
.stat-card {
@apply bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-100 dark:border-slate-700 p-5 flex items-center gap-4;
}
.stat-icon {
@apply w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0;
}
.stat-icon-primary {
@apply stat-icon bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400;
}
.stat-icon-success {
@apply stat-icon bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400;
}
.stat-icon-warning {
@apply stat-icon bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400;
}
.stat-icon-info {
@apply stat-icon bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400;
}
.stat-value {
@apply text-2xl font-bold text-slate-900 dark:text-slate-100;
}
.stat-label {
@apply text-sm text-slate-500 dark:text-slate-400;
}
/* ==================== 页面头部 ==================== */
.page-header {
@apply mb-6;
}
.page-title {
@apply text-xl font-semibold text-slate-900 dark:text-slate-100;
}
.page-description {
@apply text-sm text-slate-500 dark:text-slate-400 mt-1;
}
/* ==================== 顶部导航栏 ==================== */
.top-navbar {
@apply h-14 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700
flex items-center justify-between px-4 sticky top-0 z-40;
}
/* ==================== 多标签栏 ==================== */
.tabs-bar {
@apply h-10 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700
flex items-center gap-1 px-2 overflow-x-auto;
}
.tab-item {
@apply inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md
text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100
hover:bg-slate-100 dark:hover:bg-slate-700
transition-colors duration-150 cursor-pointer whitespace-nowrap;
}
.tab-item-active {
@apply tab-item bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400;
}
.tab-close {
@apply ml-1 p-0.5 rounded hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors;
}
/* ==================== 工具栏 ==================== */
.toolbar {
@apply flex flex-wrap items-center gap-3 mb-4;
}
.toolbar-left {
@apply flex items-center gap-3;
}
.toolbar-right {
@apply flex items-center gap-3 ml-auto;
}
/* ==================== 空状态 ==================== */
.empty-state {
@apply flex flex-col items-center justify-center py-12 text-center;
}
.empty-state-icon {
@apply w-16 h-16 text-gray-300 mb-4;
}
.empty-state-title {
@apply text-lg font-medium text-gray-900 mb-1;
}
.empty-state-description {
@apply text-sm text-gray-500;
}
/* ==================== 下拉菜单 ==================== */
.dropdown-menu {
@apply absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black/5
py-1 z-50;
}
.dropdown-item {
@apply block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100
transition-colors duration-150 cursor-pointer;
}
.dropdown-item-danger {
@apply dropdown-item text-red-600 hover:bg-red-50;
}
/* ==================== 选项卡 ==================== */
.tabs {
@apply flex border-b border-gray-200;
}
.tab {
@apply px-4 py-2.5 text-sm font-medium text-gray-500 border-b-2 border-transparent
hover:text-gray-700 hover:border-gray-300
transition-colors duration-150 cursor-pointer -mb-px;
}
.tab-active {
@apply tab text-blue-600 dark:text-blue-400 border-blue-600 dark:border-blue-400;
}
/* ==================== 分页 ==================== */
.pagination {
@apply flex items-center gap-1;
}
.pagination-btn {
@apply px-3 py-1.5 text-sm rounded-md border border-gray-300
text-gray-700 hover:bg-gray-50
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors duration-150;
}
.pagination-btn-active {
@apply pagination-btn bg-blue-600 text-white border-blue-600
hover:bg-blue-700;
}
}
/* ========================================
工具类
======================================== */
@layer utilities {
/* 文本工具 */
.text-truncate {
@apply truncate;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Flex 工具 */
.flex-center {
@apply flex items-center justify-center;
}
.flex-between {
@apply flex items-center justify-between;
}
/* 动画 */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.animate-fade-in {
animation: fade-in 0.15s ease-out;
}
.animate-slide-up {
animation: slide-up 0.2s ease-out;
}
.animate-scale-in {
animation: scale-in 0.15s ease-out;
}
/* 安全区域 */
.safe-area-inset {
padding-bottom: env(safe-area-inset-bottom);
}
/* 响应式工具类 */
.hide-mobile {
@apply hidden sm:block;
}
.show-mobile {
@apply block sm:hidden;
}
.hide-tablet {
@apply hidden md:block;
}
.show-tablet {
@apply block md:hidden;
}
/* 响应式文字 */
.text-responsive {
@apply text-sm sm:text-base;
}
.text-responsive-sm {
@apply text-xs sm:text-sm;
}
/* 响应式间距 */
.gap-responsive {
@apply gap-2 sm:gap-3 md:gap-4;
}
.p-responsive {
@apply p-3 sm:p-4 md:p-6;
}
/* 响应式表格 */
.table-responsive-wrapper {
@apply overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0;
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* 移动端优化 */
@media (max-width: 640px) {
/* 卡片优化 */
.vben-card {
@apply rounded-lg border;
}
.vben-card-body {
@apply p-3;
}
.vben-card-header {
@apply px-3 py-2.5;
}
/* 模态框优化 */
.modal-content {
@apply max-w-[calc(100%-1rem)] mx-2 rounded-lg max-h-[90vh] overflow-y-auto;
}
/* 页面头部优化 */
.page-header {
@apply flex-col items-start gap-3;
}
.page-title {
@apply text-lg;
}
.page-description {
@apply text-xs;
}
/* 统计卡片优化 */
.stat-card {
@apply p-3 flex-row items-center gap-3;
}
.stat-icon {
@apply w-10 h-10;
}
.stat-value {
@apply text-xl;
}
.stat-label {
@apply text-xs;
}
/* 表格优化 */
.table-ios th,
.table-ios td {
@apply px-2 py-2 text-xs;
}
.table-ios th {
@apply text-[10px];
}
/* 按钮优化 */
.btn-ios {
@apply px-3 py-2 text-xs;
}
.btn-ios-primary,
.btn-ios-secondary {
@apply text-xs;
}
/* 输入框优化 */
.input-ios {
@apply text-sm py-2;
}
.input-label {
@apply text-xs;
}
/* 徽章优化 */
.badge-ios {
@apply text-[10px] px-1.5 py-0.5;
}
/* 顶部导航优化 */
.top-navbar {
@apply px-3 h-12;
}
/* 标签栏优化 */
.tabs-bar {
@apply px-2;
}
.tab-item,
.tab-item-active {
@apply px-2 py-1.5 text-xs gap-1;
}
}
/* 阴影 */
.shadow-vben {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.03),
0 1px 6px -1px rgb(0 0 0 / 0.02),
0 2px 4px 0 rgb(0 0 0 / 0.02);
}
.shadow-vben-lg {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.07),
0 2px 4px -2px rgb(0 0 0 / 0.05);
}
}

232
src/types/index.ts Normal file
View File

@ -0,0 +1,232 @@
// 用户相关类型
export interface User {
user_id: number
username: string
is_admin: boolean
email?: string
}
export interface LoginRequest {
username?: string
password?: string
email?: string
verification_code?: string
}
export interface LoginResponse {
success: boolean
message?: string
token?: string
user_id?: number
username?: string
is_admin?: boolean
}
// 账号相关类型
export interface Account {
id: string
cookie: string
remark?: string
enabled: boolean
use_ai_reply: boolean
use_default_reply: boolean
auto_confirm: boolean
note?: string
pause_duration?: number
created_at?: string
updated_at?: string
}
export interface AccountDetail extends Account {
keywords?: Keyword[]
keywordCount?: number
username?: string
login_password?: string
show_browser?: boolean
}
// 关键词相关类型
export interface Keyword {
id?: string
cookie_id?: string
keyword: string
reply: string
item_id?: string // 绑定的商品ID空表示通用关键词
type?: 'text' | 'image' | 'item' | 'normal' // 关键词类型
image_url?: string // 图片类型关键词的图片URL
fuzzy_match?: boolean
created_at?: string
updated_at?: string
}
// 商品相关类型
export interface Item {
id: string | number
cookie_id: string
item_id: string
title?: string
item_title?: string
desc?: string
item_description?: string
item_detail?: string
item_category?: string
price?: string
item_price?: string
has_sku?: boolean
is_multi_spec?: number | boolean
multi_delivery?: boolean
multi_quantity_delivery?: number | boolean
created_at?: string
updated_at?: string
}
export interface ItemReply {
id: string
cookie_id: string
item_id: string
title?: string
content?: string
reply: string
created_at?: string
updated_at?: string
}
// 订单相关类型
export interface Order {
id: string
order_id: string
cookie_id: string
item_id: string
buyer_id: string
chat_id?: string
sku_info?: string
spec_name?: string
spec_value?: string
quantity: number
amount: string
status: OrderStatus
is_bargain?: boolean
created_at?: string
updated_at?: string
}
export type OrderStatus =
| 'processing'
| 'pending_ship'
| 'processed'
| 'shipped'
| 'completed'
| 'refunding'
| 'cancelled'
| 'unknown'
// 卡券相关类型
export interface Card {
id: string
cookie_id: string
item_id: string
keyword?: string
card_content: string
is_used: boolean
used?: boolean
order_id?: string
created_at?: string
updated_at?: string
}
// 发货规则相关类型 - 匹配后端接口
export interface DeliveryRule {
id: number
keyword: string
card_id: number
delivery_count: number
enabled: boolean
description?: string
delivery_times?: number
card_name?: string
card_type?: string
is_multi_spec?: boolean
spec_name?: string
spec_value?: string
created_at?: string
updated_at?: string
}
// 通知渠道相关类型
export interface NotificationChannel {
id: string
cookie_id?: string
name: string
type: 'qq' | 'dingtalk' | 'feishu' | 'bark' | 'email' | 'webhook' | 'wechat' | 'telegram'
channel_type?: string
channel_name?: string
channel_config?: string
config?: Record<string, unknown>
enabled: boolean
created_at?: string
updated_at?: string
}
// 消息通知相关类型 - 匹配后端接口
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
export interface MessageNotification {
cookie_id: string
channel_id: number
channel_name?: string
enabled: boolean
}
// 系统设置相关类型
export interface SystemSettings {
ai_model?: string
ai_api_key?: string
ai_api_url?: string
ai_base_url?: string
default_reply?: string
registration_enabled?: boolean
show_default_login_info?: boolean
login_captcha_enabled?: boolean
// SMTP邮件配置
smtp_server?: string
smtp_port?: number
smtp_user?: string
smtp_password?: string
smtp_from?: string
smtp_use_tls?: boolean
smtp_use_ssl?: boolean
// API安全
qq_reply_secret_key?: string
[key: string]: unknown
}
// API 响应类型
export interface ApiResponse<T = unknown> {
success: boolean
message?: string
data?: T
// 后端兼容字段
msg?: string
detail?: string
}
// 分页相关类型
export interface PaginationParams {
page: number
pageSize: number
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
pageSize: number
totalPages: number
}
// 仪表盘统计类型
export interface DashboardStats {
totalAccounts: number
totalKeywords: number
activeAccounts: number
totalOrders: number
}

6
src/utils/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

84
src/utils/request.ts Normal file
View File

@ -0,0 +1,84 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useAuthStore } from '@/store/authStore'
// 创建 axios 实例
const request: AxiosInstance = axios.create({
baseURL: '',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response: AxiosResponse) => {
return response
},
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token 过期或无效,清除并跳转登录
try {
// 统一通过 Zustand 清理登录状态,确保 isAuthenticated、token 与本地存储一致
useAuthStore.getState().clearAuth()
} catch {
// ignore
}
}
return Promise.reject(error)
}
)
// 封装 GET 请求
export const get = async <T = unknown>(
url: string,
config?: AxiosRequestConfig
): Promise<T> => {
const response = await request.get<T>(url, config)
return response.data
}
// 封装 POST 请求
export const post = async <T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T> => {
const response = await request.post<T>(url, data, config)
return response.data
}
// 封装 PUT 请求
export const put = async <T = unknown>(
url: string,
data?: unknown,
config?: AxiosRequestConfig
): Promise<T> => {
const response = await request.put<T>(url, data, config)
return response.data
}
// 封装 DELETE 请求
export const del = async <T = unknown>(
url: string,
config?: AxiosRequestConfig
): Promise<T> => {
const response = await request.delete<T>(url, config)
return response.data
}
export default request

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

89
tailwind.config.js Normal file
View File

@ -0,0 +1,89 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
// 主色调 - 闲鱼黄色系
primary: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
// 闲鱼橙色点缀
xianyu: {
light: '#fff7ed',
DEFAULT: '#ff6600',
dark: '#ea580c',
},
// 背景色
background: {
DEFAULT: '#f8fafc',
secondary: '#ffffff',
tertiary: '#f1f5f9',
},
// 边框色
border: {
DEFAULT: '#e2e8f0',
light: '#f1f5f9',
},
// 文字色
foreground: {
DEFAULT: '#1e293b',
secondary: '#64748b',
muted: '#94a3b8',
},
},
borderRadius: {
'ios': '20px',
'ios-lg': '24px',
'ios-xl': '28px',
},
boxShadow: {
'ios': '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05)',
'ios-md': '0 8px 16px -4px rgba(0, 0, 0, 0.08), 0 4px 8px -4px rgba(0, 0, 0, 0.04)',
'ios-lg': '0 12px 24px -6px rgba(0, 0, 0, 0.1), 0 6px 12px -6px rgba(0, 0, 0, 0.05)',
'glass': '0 8px 32px rgba(0, 0, 0, 0.08), inset 0 1px 0 0 rgba(255, 255, 255, 0.6)',
},
backdropBlur: {
'ios': '20px',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'slide-down': 'slideDown 0.3s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
},
},
},
plugins: [],
}

31
tsconfig.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

255
vite.config.ts Normal file
View File

@ -0,0 +1,255 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig(({ command }) => ({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/login': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.method === 'GET') {
return '/index.html'
}
},
},
'/verify': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/cookies': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/delivery-rules': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/system-settings': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/logs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/users': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// 管理员API - 前端有 /admin/* 路由,需要区分浏览器访问和 API 请求
'/admin': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
// 浏览器直接访问Accept 包含 text/html让前端路由处理
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
'/risk-control-logs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/qrcode': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/generate-captcha': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/verify-captcha': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/send-verification-code': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/registration-status': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/login-info-status': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/geetest': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/register': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
// 浏览器直接访问时返回前端页面,只有 POST 请求才代理到后端
if (req.method === 'GET' && req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
'/itemReplays': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/item-reply': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/default-replies': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ai-reply-settings': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/ai-reply-test': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/password-login': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/qr-login': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/keywords-export': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/keywords-import': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/upload-image': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/default-reply': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/backup': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/project-stats': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/change-password': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/change-admin-password': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/check-default-password': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/logout': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/user-settings': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/search': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// 商品管理 - 前端有 /items 路由,需要区分浏览器访问和 API 请求
'/items': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
// 只有浏览器直接访问 /items 路径时才返回前端页面
// API 请求通常是 /items/xxx 或带有 application/json
const isApiRequest = req.url !== '/items' ||
req.headers.accept?.includes('application/json') ||
req.headers['content-type']?.includes('application/json')
if (!isApiRequest && req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 卡券管理 - 前端有 /cards 路由
'/cards': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 通知渠道 - 前端有 /notification-channels 路由
'/notification-channels': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 消息通知 - 前端有 /message-notifications 路由
'/message-notifications': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 关键词 - 前端有 /keywords 路由
'/keywords': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 订单 API - 后端路径是 /api/orders
'/api/orders': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
// 资源放在 assets 目录,通过 base 配置让引用路径为 /static/assets/
assetsDir: 'assets',
},
// 只在生产构建时使用 /static/ 作为 base开发模式使用 /
base: command === 'build' ? '/static/' : '/',
}))