init
This commit is contained in:
commit
59408d1d80
26
index.html
Normal file
26
index.html
Normal 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
6496
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal 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
3924
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
10
public/favicon.svg
Normal file
10
public/favicon.svg
Normal 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
BIN
public/static/qq-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
public/static/wechat-group.png
Normal file
BIN
public/static/wechat-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
BIN
public/static/wechat-group1.png
Normal file
BIN
public/static/wechat-group1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
BIN
public/static/wechat.png
Normal file
BIN
public/static/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
public/static/xianyu-group.png
Normal file
BIN
public/static/xianyu-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
180
src/App.tsx
Normal file
180
src/App.tsx
Normal 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
202
src/api/accounts.ts
Normal 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
228
src/api/admin.ts
Normal 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
116
src/api/auth.ts
Normal 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
90
src/api/cards.ts
Normal 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
30
src/api/delivery.ts
Normal 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
10
src/api/index.ts
Normal 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
75
src/api/items.ts
Normal 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
157
src/api/keywords.ts
Normal 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
144
src/api/notifications.ts
Normal 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
69
src/api/orders.ts
Normal 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
35
src/api/search.ts
Normal 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
185
src/api/settings.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
48
src/components/common/DisclaimerContent.tsx
Normal file
48
src/components/common/DisclaimerContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
src/components/common/DisclaimerModal.tsx
Normal file
92
src/components/common/DisclaimerModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
src/components/common/GeetestCaptcha.tsx
Normal file
247
src/components/common/GeetestCaptcha.tsx
Normal 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
|
||||
53
src/components/common/Loading.tsx
Normal file
53
src/components/common/Loading.tsx
Normal 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" />
|
||||
}
|
||||
160
src/components/common/Select.tsx
Normal file
160
src/components/common/Select.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
src/components/common/Toast.tsx
Normal file
62
src/components/common/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/components/layout/MainLayout.tsx
Normal file
42
src/components/layout/MainLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
240
src/components/layout/Sidebar.tsx
Normal file
240
src/components/layout/Sidebar.tsx
Normal 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 抽屉(不依赖 collapsed);640-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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
292
src/components/layout/TabsBar.tsx
Normal file
292
src/components/layout/TabsBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
src/components/layout/TopNavbar.tsx
Normal file
107
src/components/layout/TopNavbar.tsx
Normal 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
23
src/main.tsx
Normal 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
533
src/pages/about/About.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1356
src/pages/accounts/Accounts.tsx
Normal file
1356
src/pages/accounts/Accounts.tsx
Normal file
File diff suppressed because it is too large
Load Diff
192
src/pages/admin/DataManagement.tsx
Normal file
192
src/pages/admin/DataManagement.tsx
Normal 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
188
src/pages/admin/Logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
209
src/pages/admin/RiskLogs.tsx
Normal file
209
src/pages/admin/RiskLogs.tsx
Normal 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
148
src/pages/admin/Users.tsx
Normal 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
259
src/pages/auth/Login.tsx
Normal 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
372
src/pages/auth/Register.tsx
Normal 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
801
src/pages/cards/Cards.tsx
Normal 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="请输入数据,每行一个: 卡号1:密码1 卡号2:密码2 或者 兑换码1 兑换码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">支持JPG、PNG、GIF格式,最大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">设置自动发货的延时时间,0表示立即发货,最大3600秒(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="可选的备注信息,支持变量替换: {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>
|
||||
)
|
||||
}
|
||||
314
src/pages/dashboard/Dashboard.tsx
Normal file
314
src/pages/dashboard/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
357
src/pages/delivery/Delivery.tsx
Normal file
357
src/pages/delivery/Delivery.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/pages/disclaimer/Disclaimer.tsx
Normal file
19
src/pages/disclaimer/Disclaimer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
332
src/pages/item-replies/ItemReplies.tsx
Normal file
332
src/pages/item-replies/ItemReplies.tsx
Normal 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
500
src/pages/items/Items.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
800
src/pages/keywords/Keywords.tsx
Normal file
800
src/pages/keywords/Keywords.tsx
Normal 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">支持 JPG、PNG、GIF,不超过 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>
|
||||
)
|
||||
}
|
||||
310
src/pages/notifications/MessageNotifications.tsx
Normal file
310
src/pages/notifications/MessageNotifications.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
428
src/pages/notifications/NotificationChannels.tsx
Normal file
428
src/pages/notifications/NotificationChannels.tsx
Normal 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
457
src/pages/orders/Orders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
193
src/pages/search/ItemSearch.tsx
Normal file
193
src/pages/search/ItemSearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
747
src/pages/settings/Settings.tsx
Normal file
747
src/pages/settings/Settings.tsx
Normal 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-plus、qwen-turbo、gpt-3.5-turbo、gpt-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.com、smtp.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
74
src/store/authStore.ts
Normal 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
65
src/store/uiStore.ts
Normal 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
782
src/styles/globals.css
Normal 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
232
src/types/index.ts
Normal 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
6
src/utils/cn.ts
Normal 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
84
src/utils/request.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
89
tailwind.config.js
Normal file
89
tailwind.config.js
Normal 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
31
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
255
vite.config.ts
Normal 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/' : '/',
|
||||
}))
|
||||
Loading…
Reference in New Issue
Block a user