feat(frontend): 前端 UI 重构为 Vben Admin 风格
## 主要改动 - 使用 React 18 + TypeScript + Vite 5 重构前端 - 采用 TailwindCSS 实现 Vben Admin 设计风格 - 蓝白色系主题,完整支持暗黑模式 - 新增顶部导航栏(用户信息、主题切换、退出登录) - 新增多标签页导航功能 - 深色侧边栏设计 ## 已完成页面 - 登录/注册页面 - 仪表盘 - 账号管理 - 商品管理 - 关键词管理 - 卡券管理 - 系统设置 - 系统日志 - 管理员页面 ## 技术栈 - React 18 + TypeScript - Vite 5 - TailwindCSS - Zustand (状态管理) - React Router 6 - Lucide React (图标) - Framer Motion (动画)
This commit is contained in:
parent
6bf2ac43e4
commit
543eed80e9
8
.gitignore
vendored
8
.gitignore
vendored
@ -792,4 +792,10 @@ desktop.ini
|
||||
docker-compose.override.yml
|
||||
.docker/**
|
||||
docker-data/**
|
||||
container_logs/**
|
||||
container_logs/**
|
||||
|
||||
# 前端 React 构建产物
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/coverage/
|
||||
121
docs/前端改造方案.md
Normal file
121
docs/前端改造方案.md
Normal file
@ -0,0 +1,121 @@
|
||||
# 闲鱼自动回复管理系统 - 前端改造方案
|
||||
|
||||
## 改造目标
|
||||
|
||||
将前端 UI 重构为 **Vben Admin** 风格的现代化后台管理系统:
|
||||
|
||||
- **蓝白色系**:主色调为蓝色(Blue-500),配合白色/深色背景
|
||||
- **暗黑模式**:完整支持亮色/暗色主题切换
|
||||
- **多标签导航**:支持多标签页切换
|
||||
- **顶部导航栏**:用户信息、主题切换、退出登录
|
||||
- **响应式设计**:适配桌面端和移动端
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 用途 |
|
||||
|------|------|
|
||||
| React 18 + TypeScript | 前端框架 |
|
||||
| Vite 5 | 构建工具 |
|
||||
| TailwindCSS | 原子化 CSS |
|
||||
| Zustand | 状态管理 |
|
||||
| React Router 6 | 路由管理 |
|
||||
| Lucide React | 图标库 |
|
||||
| Framer Motion | 动画效果 |
|
||||
|
||||
---
|
||||
|
||||
## 改造进度
|
||||
|
||||
### ✅ 已完成
|
||||
|
||||
- [x] 项目基础架构搭建
|
||||
- [x] TailwindCSS 配置与主题变量
|
||||
- [x] 暗黑模式支持(CSS 变量 + dark: 前缀)
|
||||
- [x] 登录页面(简洁设计 + 主题切换)
|
||||
- [x] 注册页面
|
||||
- [x] 主布局(侧边栏 + 顶部导航栏 + 多标签栏)
|
||||
- [x] 侧边栏组件(深色背景 + 隐藏滚动条)
|
||||
- [x] 顶部导航栏(用户信息 + 主题切换 + 退出登录)
|
||||
- [x] 多标签导航组件
|
||||
- [x] 仪表盘页面
|
||||
- [x] 账号管理页面
|
||||
- [x] 商品管理页面
|
||||
- [x] 关键词管理页面
|
||||
- [x] 卡券管理页面
|
||||
- [x] 系统设置页面
|
||||
- [x] 系统日志页面
|
||||
- [x] .gitignore 文件(忽略 node_modules 和 dist)
|
||||
|
||||
### 🔄 进行中
|
||||
|
||||
- [ ] 下拉框组件美化(替换原生 select)
|
||||
- [ ] 其他管理员页面适配暗黑模式
|
||||
- [ ] 关于页面样式优化
|
||||
|
||||
### 📋 待完成
|
||||
|
||||
- [ ] 订单管理页面优化
|
||||
- [ ] 自动发货页面优化
|
||||
- [ ] 通知渠道页面优化
|
||||
- [ ] 消息通知页面优化
|
||||
- [ ] 商品搜索页面优化
|
||||
- [ ] 指定商品回复页面优化
|
||||
- [ ] 用户管理页面优化
|
||||
- [ ] 风控日志页面优化
|
||||
- [ ] 数据管理页面优化
|
||||
- [ ] 自定义 Select 组件
|
||||
- [ ] 表单验证优化
|
||||
- [ ] 响应式适配完善
|
||||
|
||||
---
|
||||
|
||||
## 设计规范
|
||||
|
||||
### 色彩体系
|
||||
|
||||
```
|
||||
主色调:Blue-500 (#3b82f6)
|
||||
背景色:
|
||||
- 亮色:slate-50 (#f8fafc)
|
||||
- 暗色:slate-900 (#0f172a)
|
||||
侧边栏:#001529(深蓝色,固定不变)
|
||||
```
|
||||
|
||||
### 组件样式类
|
||||
|
||||
| 类名 | 用途 |
|
||||
|------|------|
|
||||
| `.vben-card` | 卡片容器 |
|
||||
| `.vben-card-header` | 卡片头部 |
|
||||
| `.vben-card-title` | 卡片标题 |
|
||||
| `.vben-card-body` | 卡片内容 |
|
||||
| `.page-title` | 页面标题 |
|
||||
| `.page-description` | 页面描述 |
|
||||
| `.btn-ios-primary` | 主要按钮 |
|
||||
| `.btn-ios-secondary` | 次要按钮 |
|
||||
| `.btn-ios-danger` | 危险按钮 |
|
||||
| `.input-ios` | 输入框 |
|
||||
| `.input-label` | 输入框标签 |
|
||||
| `.table-ios` | 表格 |
|
||||
| `.badge-*` | 徽章 |
|
||||
| `.top-navbar` | 顶部导航栏 |
|
||||
| `.tabs-bar` | 多标签栏 |
|
||||
|
||||
---
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
cd frontend && npm install
|
||||
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
---
|
||||
32
frontend/.gitignore
vendored
Normal file
32
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
.eslintcache
|
||||
*.tsbuildinfo
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!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>
|
||||
<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>
|
||||
51
frontend/package.json
Normal file
51
frontend/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
frontend/pnpm-lock.yaml
Normal file
3924
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
136
frontend/src/App.tsx
Normal file
136
frontend/src/App.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } 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 { 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 { verifyToken } from '@/api/auth'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, setAuth, clearAuth } = useAuthStore()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
|
||||
if (!token) {
|
||||
setIsChecking(false)
|
||||
setIsValid(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经认证,直接通过
|
||||
if (isAuthenticated) {
|
||||
setIsChecking(false)
|
||||
setIsValid(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 token 有效性
|
||||
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,
|
||||
})
|
||||
setIsValid(true)
|
||||
} else {
|
||||
clearAuth()
|
||||
setIsValid(false)
|
||||
}
|
||||
} catch {
|
||||
clearAuth()
|
||||
setIsValid(false)
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
}, [isAuthenticated, setAuth, clearAuth])
|
||||
|
||||
// 显示加载状态
|
||||
if (isChecking) {
|
||||
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 (!isValid && !isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<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="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
|
||||
70
frontend/src/api/accounts.ts
Normal file
70
frontend/src/api/accounts.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { Account, AccountDetail, ApiResponse } from '@/types'
|
||||
|
||||
// 获取账号列表
|
||||
export const getAccounts = (): Promise<Account[]> => {
|
||||
return get('/cookies')
|
||||
}
|
||||
|
||||
// 获取账号详情列表
|
||||
export const getAccountDetails = (): Promise<AccountDetail[]> => {
|
||||
return get('/cookies/details')
|
||||
}
|
||||
|
||||
// 添加账号
|
||||
export const addAccount = (data: { id: string; cookie: string }): Promise<ApiResponse> => {
|
||||
return post('/cookies', data)
|
||||
}
|
||||
|
||||
// 更新账号
|
||||
export const updateAccount = (id: string, data: Partial<Account>): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}`, 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')
|
||||
}
|
||||
|
||||
// 检查扫码登录状态
|
||||
export const checkQRLoginStatus = (sessionId: string): Promise<{
|
||||
success: boolean
|
||||
status: 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'verification_required' | 'processing' | 'already_processed'
|
||||
message?: string
|
||||
account_info?: {
|
||||
account_id: string
|
||||
is_new_account: boolean
|
||||
}
|
||||
}> => {
|
||||
return get(`/qr-login/check/${sessionId}`)
|
||||
}
|
||||
|
||||
// 检查密码登录状态
|
||||
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}`)
|
||||
}
|
||||
89
frontend/src/api/admin.ts
Normal file
89
frontend/src/api/admin.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { ApiResponse, User } from '@/types'
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
|
||||
// 获取用户列表
|
||||
export const getUsers = (): Promise<{ success: boolean; data?: User[] }> => {
|
||||
return get('/admin/users')
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
export const addUser = (data: { username: string; password: string; email?: string; is_admin?: boolean }): Promise<ApiResponse> => {
|
||||
return post('/admin/users', data)
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
export const updateUser = (userId: number, data: Partial<User & { password?: string }>): Promise<ApiResponse> => {
|
||||
return put(`/admin/users/${userId}`, data)
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
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 = (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('limit', String(params.limit))
|
||||
if (params?.level) query.set('level', params.level)
|
||||
return get(`/admin/logs?${query.toString()}`)
|
||||
}
|
||||
|
||||
// 清空系统日志
|
||||
export const clearSystemLogs = (): Promise<ApiResponse> => {
|
||||
return del('/admin/logs')
|
||||
}
|
||||
|
||||
// ========== 风控日志 ==========
|
||||
|
||||
export interface RiskLog {
|
||||
id: string
|
||||
cookie_id: string
|
||||
risk_type: string
|
||||
message: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 获取风控日志
|
||||
export const getRiskLogs = (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)
|
||||
return get(`/admin/risk-control-logs?${query.toString()}`)
|
||||
}
|
||||
|
||||
// 清空风控日志
|
||||
export const clearRiskLogs = (): Promise<ApiResponse> => {
|
||||
return del('/admin/risk-control-logs')
|
||||
}
|
||||
|
||||
// ========== 数据管理 ==========
|
||||
|
||||
// 导出数据
|
||||
export const exportData = (type: string): Promise<Blob> => {
|
||||
return get(`/admin/backup/download?type=${type}`, { responseType: 'blob' }) as Promise<Blob>
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
export const importData = (formData: FormData): Promise<ApiResponse> => {
|
||||
return post('/admin/backup/upload', formData)
|
||||
}
|
||||
|
||||
// 清理数据
|
||||
export const cleanupData = (type: string): Promise<ApiResponse> => {
|
||||
return del(`/admin/data/${type}`)
|
||||
}
|
||||
53
frontend/src/api/auth.ts
Normal file
53
frontend/src/api/auth.ts
Normal file
@ -0,0 +1,53 @@
|
||||
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 = (): Promise<{ enabled: boolean }> => {
|
||||
return get('/registration-status')
|
||||
}
|
||||
|
||||
// 获取登录信息显示状态
|
||||
export const getLoginInfoStatus = (): Promise<{ enabled: boolean }> => {
|
||||
return get('/login-info-status')
|
||||
}
|
||||
|
||||
// 生成图形验证码
|
||||
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)
|
||||
}
|
||||
33
frontend/src/api/cards.ts
Normal file
33
frontend/src/api/cards.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { get, post, del } from '@/utils/request'
|
||||
import type { ApiResponse, Card } from '@/types'
|
||||
|
||||
// 获取卡券列表
|
||||
export const getCards = (accountId?: string): Promise<{ success: boolean; data?: Card[] }> => {
|
||||
const url = accountId ? `/cards?cookie_id=${accountId}` : '/cards'
|
||||
return get(url)
|
||||
}
|
||||
|
||||
// 获取账号的卡券列表
|
||||
export const getCardsByAccount = (accountId: string): Promise<Card[]> => {
|
||||
return get(`/cards?cookie_id=${accountId}`)
|
||||
}
|
||||
|
||||
// 添加卡券
|
||||
export const addCard = (accountId: string, data: { item_id: string; cards: string[] }): Promise<ApiResponse> => {
|
||||
return post('/cards', { ...data, cookie_id: accountId })
|
||||
}
|
||||
|
||||
// 删除卡券
|
||||
export const deleteCard = (cardId: string): Promise<ApiResponse> => {
|
||||
return del(`/cards/${cardId}`)
|
||||
}
|
||||
|
||||
// 批量删除卡券
|
||||
export const batchDeleteCards = (cardIds: string[]): Promise<ApiResponse> => {
|
||||
return post('/cards/batch-delete', { ids: cardIds })
|
||||
}
|
||||
|
||||
// 导入卡券(从文本)
|
||||
export const importCards = (accountId: string, data: { item_id: string; content: string }): Promise<ApiResponse> => {
|
||||
return post('/cards', { ...data, cookie_id: accountId, cards: data.content.split('\n').filter(Boolean) })
|
||||
}
|
||||
28
frontend/src/api/delivery.ts
Normal file
28
frontend/src/api/delivery.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { ApiResponse, DeliveryRule } from '@/types'
|
||||
|
||||
// 获取发货规则列表
|
||||
export const getDeliveryRules = (accountId?: string): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
|
||||
const url = accountId ? `/api/delivery-rules?cookie_id=${accountId}` : '/api/delivery-rules'
|
||||
return get(url)
|
||||
}
|
||||
|
||||
// 添加发货规则
|
||||
export const addDeliveryRule = (data: Partial<DeliveryRule>): Promise<ApiResponse> => {
|
||||
return post('/api/delivery-rules', data)
|
||||
}
|
||||
|
||||
// 更新发货规则
|
||||
export const updateDeliveryRule = (ruleId: string, data: Partial<DeliveryRule>): Promise<ApiResponse> => {
|
||||
return put(`/api/delivery-rules/${ruleId}`, data)
|
||||
}
|
||||
|
||||
// 删除发货规则
|
||||
export const deleteDeliveryRule = (ruleId: string): Promise<ApiResponse> => {
|
||||
return del(`/api/delivery-rules/${ruleId}`)
|
||||
}
|
||||
|
||||
// 获取账号的发货规则
|
||||
export const getDeliveryRulesByAccount = (accountId: string): Promise<DeliveryRule[]> => {
|
||||
return get(`/delivery-rules/${accountId}`)
|
||||
}
|
||||
10
frontend/src/api/index.ts
Normal file
10
frontend/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'
|
||||
59
frontend/src/api/items.ts
Normal file
59
frontend/src/api/items.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { Item, ItemReply, ApiResponse } from '@/types'
|
||||
|
||||
// 获取商品列表
|
||||
export const getItems = (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
|
||||
const params = cookieId ? `?cookie_id=${cookieId}` : ''
|
||||
return get(`/items${params}`)
|
||||
}
|
||||
|
||||
// 删除商品
|
||||
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 = (cookieId?: string): Promise<{ success: boolean; data: ItemReply[] }> => {
|
||||
const params = cookieId ? `/cookie/${cookieId}` : ''
|
||||
return get(`/itemReplays${params}`)
|
||||
}
|
||||
|
||||
// 添加商品回复
|
||||
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 } })
|
||||
}
|
||||
59
frontend/src/api/keywords.ts
Normal file
59
frontend/src/api/keywords.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { Keyword, ApiResponse } from '@/types'
|
||||
|
||||
// 获取关键词列表
|
||||
export const getKeywords = (cookieId: string): Promise<Keyword[]> => {
|
||||
return get(`/keywords/${cookieId}`)
|
||||
}
|
||||
|
||||
// 添加关键词
|
||||
export const addKeyword = (cookieId: string, data: Partial<Keyword>): Promise<ApiResponse> => {
|
||||
return post(`/keywords/${cookieId}`, data)
|
||||
}
|
||||
|
||||
// 更新关键词
|
||||
export const updateKeyword = (cookieId: string, keywordId: string, data: Partial<Keyword>): Promise<ApiResponse> => {
|
||||
return put(`/keywords/${cookieId}/${keywordId}`, data)
|
||||
}
|
||||
|
||||
// 删除关键词
|
||||
export const deleteKeyword = (cookieId: string, keywordId: string): Promise<ApiResponse> => {
|
||||
return del(`/keywords/${cookieId}/${keywordId}`)
|
||||
}
|
||||
|
||||
// 批量添加关键词
|
||||
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<{ default_reply: string }> => {
|
||||
return get(`/default-reply/${cookieId}`)
|
||||
}
|
||||
|
||||
// 更新默认回复
|
||||
export const updateDefaultReply = (cookieId: string, defaultReply: string): Promise<ApiResponse> => {
|
||||
return put(`/default-reply/${cookieId}`, { default_reply: defaultReply })
|
||||
}
|
||||
|
||||
// 导出关键词(Excel/模板),返回 Blob 供前端触发下载
|
||||
export const exportKeywords = (cookieId: string): Promise<Blob> => {
|
||||
return get<Blob>(`/keywords-export/${cookieId}`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
// 导入关键词(Excel),上传文件并返回导入结果
|
||||
export const importKeywords = (
|
||||
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' },
|
||||
})
|
||||
}
|
||||
51
frontend/src/api/notifications.ts
Normal file
51
frontend/src/api/notifications.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { ApiResponse, NotificationChannel, MessageNotification } from '@/types'
|
||||
|
||||
// ========== 通知渠道 ==========
|
||||
|
||||
// 获取通知渠道列表
|
||||
export const getNotificationChannels = (): Promise<{ success: boolean; data?: NotificationChannel[] }> => {
|
||||
return get('/api/notification-channels')
|
||||
}
|
||||
|
||||
// 添加通知渠道
|
||||
export const addNotificationChannel = (data: Partial<NotificationChannel>): Promise<ApiResponse> => {
|
||||
return post('/api/notification-channels', data)
|
||||
}
|
||||
|
||||
// 更新通知渠道
|
||||
export const updateNotificationChannel = (channelId: string, data: Partial<NotificationChannel>): Promise<ApiResponse> => {
|
||||
return put(`/api/notification-channels/${channelId}`, data)
|
||||
}
|
||||
|
||||
// 删除通知渠道
|
||||
export const deleteNotificationChannel = (channelId: string): Promise<ApiResponse> => {
|
||||
return del(`/api/notification-channels/${channelId}`)
|
||||
}
|
||||
|
||||
// 测试通知渠道
|
||||
export const testNotificationChannel = (channelId: string): Promise<ApiResponse> => {
|
||||
return post(`/api/notification-channels/${channelId}/test`)
|
||||
}
|
||||
|
||||
// ========== 消息通知 ==========
|
||||
|
||||
// 获取消息通知列表
|
||||
export const getMessageNotifications = (): Promise<{ success: boolean; data?: MessageNotification[] }> => {
|
||||
return get('/api/message-notifications')
|
||||
}
|
||||
|
||||
// 添加消息通知
|
||||
export const addMessageNotification = (data: Partial<MessageNotification>): Promise<ApiResponse> => {
|
||||
return post('/api/message-notifications', data)
|
||||
}
|
||||
|
||||
// 更新消息通知
|
||||
export const updateMessageNotification = (notificationId: string, data: Partial<MessageNotification>): Promise<ApiResponse> => {
|
||||
return put(`/api/message-notifications/${notificationId}`, data)
|
||||
}
|
||||
|
||||
// 删除消息通知
|
||||
export const deleteMessageNotification = (notificationId: string): Promise<ApiResponse> => {
|
||||
return del(`/api/message-notifications/${notificationId}`)
|
||||
}
|
||||
26
frontend/src/api/orders.ts
Normal file
26
frontend/src/api/orders.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { get, del, post } from '@/utils/request'
|
||||
import type { Order, ApiResponse } from '@/types'
|
||||
|
||||
// 获取订单列表
|
||||
export const getOrders = (cookieId?: string, status?: string): Promise<{ success: boolean; data: Order[] }> => {
|
||||
const params = new URLSearchParams()
|
||||
if (cookieId) params.append('cookie_id', cookieId)
|
||||
if (status) params.append('status', status)
|
||||
const queryString = params.toString()
|
||||
return get(`/api/orders${queryString ? `?${queryString}` : ''}`)
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
export const deleteOrder = (id: string): Promise<ApiResponse> => {
|
||||
return del(`/api/orders/${id}`)
|
||||
}
|
||||
|
||||
// 批量删除订单
|
||||
export const batchDeleteOrders = (ids: string[]): Promise<ApiResponse> => {
|
||||
return post('/api/orders/batch-delete', { ids })
|
||||
}
|
||||
|
||||
// 更新订单状态
|
||||
export const updateOrderStatus = (id: string, status: string): Promise<ApiResponse> => {
|
||||
return post(`/api/orders/${id}/status`, { status })
|
||||
}
|
||||
7
frontend/src/api/search.ts
Normal file
7
frontend/src/api/search.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { post } from '@/utils/request'
|
||||
import type { Item } from '@/types'
|
||||
|
||||
// 搜索商品
|
||||
export const searchItems = (keyword: string, accountId?: string): Promise<{ success: boolean; data?: Item[] }> => {
|
||||
return post('/api/items/search', { keyword, cookie_id: accountId })
|
||||
}
|
||||
49
frontend/src/api/settings.ts
Normal file
49
frontend/src/api/settings.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { get, post, put } from '@/utils/request'
|
||||
import type { ApiResponse, SystemSettings } from '@/types'
|
||||
|
||||
// 获取系统设置
|
||||
export const getSystemSettings = (): Promise<{ success: boolean; data?: SystemSettings }> => {
|
||||
return get('/system-settings')
|
||||
}
|
||||
|
||||
// 更新系统设置
|
||||
export const updateSystemSettings = (data: Partial<SystemSettings>): Promise<ApiResponse> => {
|
||||
// 逐个更新设置项
|
||||
const promises = Object.entries(data).map(([key, value]) =>
|
||||
put(`/system-settings/${key}`, { value })
|
||||
)
|
||||
return Promise.all(promises).then(() => ({ success: true, 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 连接
|
||||
export const testAIConnection = (): Promise<ApiResponse> => {
|
||||
return post('/ai-reply-test/default')
|
||||
}
|
||||
|
||||
// 获取邮件设置
|
||||
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 = (email: string): Promise<ApiResponse> => {
|
||||
return post('/send-verification-code', { email, type: 'test' })
|
||||
}
|
||||
53
frontend/src/components/common/Loading.tsx
Normal file
53
frontend/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-primary-500', sizes[size])} />
|
||||
</motion.div>
|
||||
{text && (
|
||||
<p className="text-sm text-gray-500 font-medium">{text}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white/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" />
|
||||
}
|
||||
61
frontend/src/components/common/Toast.tsx
Normal file
61
frontend/src/components/common/Toast.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
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 border-emerald-200 text-emerald-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
}
|
||||
|
||||
const iconColors = {
|
||||
success: 'text-emerald-500',
|
||||
error: 'text-red-500',
|
||||
warning: 'text-amber-500',
|
||||
info: 'text-blue-500',
|
||||
}
|
||||
|
||||
export function Toast() {
|
||||
const { toasts, removeToast } = useUIStore()
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[100] flex flex-col 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-[300px] max-w-[400px]',
|
||||
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 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/layout/MainLayout.tsx
Normal file
30
frontend/src/components/layout/MainLayout.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { TopNavbar } from './TopNavbar'
|
||||
import { TabsBar } from './TabsBar'
|
||||
import { Toast } from '@/components/common/Toast'
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 transition-colors duration-200">
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="lg:ml-56 min-h-screen flex flex-col">
|
||||
{/* Top navbar */}
|
||||
<TopNavbar />
|
||||
|
||||
{/* Tabs bar */}
|
||||
<TabsBar />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<Toast />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
frontend/src/components/layout/Sidebar.tsx
Normal file
179
frontend/src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
MessageSquare,
|
||||
CreditCard,
|
||||
Truck,
|
||||
Bell,
|
||||
MessageCircle,
|
||||
Search,
|
||||
Settings,
|
||||
UserCog,
|
||||
FileText,
|
||||
Shield,
|
||||
Database,
|
||||
Info,
|
||||
Menu,
|
||||
X,
|
||||
} 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: Info, label: '关于', path: '/about' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const { user } = useAuthStore()
|
||||
const { sidebarMobileOpen, setSidebarMobileOpen } = useUIStore()
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
setSidebarMobileOpen(false)
|
||||
}
|
||||
|
||||
const NavItemComponent = ({ item }: { item: NavItem }) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<NavLink
|
||||
to={item.path}
|
||||
onClick={closeMobileSidebar}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150',
|
||||
'text-slate-400 hover:text-white hover:bg-white/10',
|
||||
isActive && 'bg-blue-600 text-white shadow-sm'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{sidebarMobileOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40 lg:hidden"
|
||||
onClick={closeMobileSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{
|
||||
x: sidebarMobileOpen ? 0 : undefined,
|
||||
}}
|
||||
className={cn(
|
||||
'fixed top-0 left-0 h-screen w-56 z-50',
|
||||
'bg-[#001529] text-white',
|
||||
'flex flex-col',
|
||||
'transition-transform duration-200 ease-out',
|
||||
'lg:translate-x-0',
|
||||
!sidebarMobileOpen && '-translate-x-full lg:translate-x-0'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 flex items-center justify-between px-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-white">闲鱼管理系统</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMobileSidebar}
|
||||
className="lg:hidden p-1.5 hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5 sidebar-scrollbar">
|
||||
{mainNavItems.map((item) => (
|
||||
<NavItemComponent key={item.path} item={item} />
|
||||
))}
|
||||
|
||||
{/* Admin section */}
|
||||
{user?.is_admin && (
|
||||
<>
|
||||
<div className="pt-4 pb-2 px-3">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
管理员
|
||||
</p>
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
<NavItemComponent key={item.path} item={item} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-4 pb-2 px-3">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
其他
|
||||
</p>
|
||||
</div>
|
||||
{bottomNavItems.map((item) => (
|
||||
<NavItemComponent key={item.path} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
</motion.aside>
|
||||
|
||||
{/* Mobile toggle button */}
|
||||
<button
|
||||
onClick={() => setSidebarMobileOpen(true)}
|
||||
className={cn(
|
||||
'fixed top-3 left-3 z-30 lg:hidden',
|
||||
'w-10 h-10 rounded-md',
|
||||
'bg-blue-500 text-white shadow-md',
|
||||
'flex items-center justify-center',
|
||||
'hover:bg-blue-600 transition-colors'
|
||||
)}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
132
frontend/src/components/layout/TabsBar.tsx
Normal file
132
frontend/src/components/layout/TabsBar.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { X, Home } from 'lucide-react'
|
||||
import { create } from 'zustand'
|
||||
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
|
||||
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': '数据管理',
|
||||
'/about': '关于',
|
||||
}
|
||||
|
||||
export const useTabsStore = create<TabsStore>((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 })
|
||||
}
|
||||
},
|
||||
|
||||
setActiveTab: (path) => set({ activeTab: path }),
|
||||
}))
|
||||
|
||||
export function TabsBar() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { tabs, activeTab, addTab, removeTab, setActiveTab } = useTabsStore()
|
||||
|
||||
// 监听路由变化,自动添加标签
|
||||
useEffect(() => {
|
||||
const path = location.pathname
|
||||
const title = routeTitles[path]
|
||||
|
||||
if (title) {
|
||||
addTab({
|
||||
path,
|
||||
title,
|
||||
closable: path !== '/dashboard',
|
||||
})
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tabs-bar">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.path}
|
||||
onClick={() => handleTabClick(tab.path)}
|
||||
className={cn(
|
||||
activeTab === tab.path ? 'tab-item-active' : 'tab-item'
|
||||
)}
|
||||
>
|
||||
{tab.path === '/dashboard' && <Home className="w-3.5 h-3.5" />}
|
||||
<span>{tab.title}</span>
|
||||
{tab.closable && (
|
||||
<button
|
||||
onClick={(e) => handleTabClose(e, tab.path)}
|
||||
className="tab-close"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
frontend/src/components/layout/TopNavbar.tsx
Normal file
106
frontend/src/components/layout/TopNavbar.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
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">
|
||||
{/* 左侧 - 面包屑或标题 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">欢迎使用闲鱼管理系统</span>
|
||||
</div>
|
||||
|
||||
{/* 右侧 - 工具栏 */}
|
||||
<div className="flex items-center 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-2 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" />
|
||||
</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
frontend/src/main.tsx
Normal file
23
frontend/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>,
|
||||
)
|
||||
128
frontend/src/pages/about/About.tsx
Normal file
128
frontend/src/pages/about/About.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
|
||||
import { MessageSquare, Github, Globe, Users, Heart } from 'lucide-react'
|
||||
|
||||
export function About() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-24 h-24 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-600
|
||||
mx-auto mb-6 flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<MessageSquare className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<motion.h1
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="text-3xl font-bold text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
闲鱼自动回复管理系统
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="text-slate-500 dark:text-slate-400 mt-2"
|
||||
>
|
||||
智能管理您的闲鱼店铺,提升客服效率
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4">主要功能</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ title: '多账号管理', desc: '支持同时管理多个闲鱼账号' },
|
||||
{ title: '智能自动回复', desc: '基于关键词的智能消息回复' },
|
||||
{ title: 'AI 助手', desc: '接入 AI 模型,智能处理复杂问题' },
|
||||
{ title: '自动发货', desc: '订单自动发货,支持卡密发货' },
|
||||
{ title: '消息通知', desc: '多渠道消息推送通知' },
|
||||
{ title: '数据统计', desc: '订单、商品数据统计分析' },
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-slate-800"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{feature.title}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{feature.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4">相关链接</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-gray-900 text-white
|
||||
hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Github className="w-6 h-6" />
|
||||
<span className="font-medium">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-blue-500 text-white
|
||||
hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Globe className="w-6 h-6" />
|
||||
<span className="font-medium">官方网站</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500 text-white
|
||||
hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
<Users className="w-6 h-6" />
|
||||
<span className="font-medium">交流群</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="text-center py-6 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with <Heart className="w-4 h-4 text-red-500" /> by Open Source Community
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
赞助商:划算云服务器{' '}
|
||||
<a
|
||||
href="https://www.hsykj.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
www.hsykj.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
721
frontend/src/pages/accounts/Accounts.tsx
Normal file
721
frontend/src/pages/accounts/Accounts.tsx
Normal file
@ -0,0 +1,721 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getAccountDetails, deleteAccount, updateAccount, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { AccountDetail } from '@/types'
|
||||
|
||||
type ModalType = 'qrcode' | 'password' | 'manual' | 'edit' | null
|
||||
|
||||
export function Accounts() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
|
||||
// 扫码登录状态
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState('')
|
||||
const [, setQrSessionId] = useState('')
|
||||
const [qrStatus, setQrStatus] = useState<'loading' | 'ready' | 'scanned' | 'success' | 'expired' | 'error'>('loading')
|
||||
const qrCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// 密码登录状态
|
||||
const [pwdAccount, setPwdAccount] = useState('')
|
||||
const [pwdPassword, setPwdPassword] = useState('')
|
||||
const [pwdLoading, setPwdLoading] = useState(false)
|
||||
const [pwdShowBrowser, setPwdShowBrowser] = useState(false)
|
||||
|
||||
// 手动输入状态
|
||||
const [manualAccountId, setManualAccountId] = useState('')
|
||||
const [manualCookie, setManualCookie] = useState('')
|
||||
const [manualLoading, setManualLoading] = useState(false)
|
||||
|
||||
// 编辑账号状态
|
||||
const [editingAccount, setEditingAccount] = useState<AccountDetail | null>(null)
|
||||
const [editNote, setEditNote] = useState('')
|
||||
const [editUseAI, setEditUseAI] = useState(false)
|
||||
const [editUseDefault, setEditUseDefault] = useState(false)
|
||||
const [editCookie, setEditCookie] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getAccountDetails()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载账号列表失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
}, [])
|
||||
|
||||
// 清理扫码检查定时器
|
||||
const clearQrCheck = useCallback(() => {
|
||||
if (qrCheckIntervalRef.current) {
|
||||
clearInterval(qrCheckIntervalRef.current)
|
||||
qrCheckIntervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 关闭弹窗时清理
|
||||
const closeModal = useCallback(() => {
|
||||
clearQrCheck()
|
||||
setActiveModal(null)
|
||||
setQrCodeUrl('')
|
||||
setQrSessionId('')
|
||||
setQrStatus('loading')
|
||||
setPwdAccount('')
|
||||
setPwdPassword('')
|
||||
setPwdLoading(false)
|
||||
setManualAccountId('')
|
||||
setManualCookie('')
|
||||
setManualLoading(false)
|
||||
}, [clearQrCheck])
|
||||
|
||||
// ==================== 扫码登录 ====================
|
||||
const startQRCodeLogin = async () => {
|
||||
setActiveModal('qrcode')
|
||||
setQrStatus('loading')
|
||||
try {
|
||||
const result = await generateQRLogin()
|
||||
if (result.success && result.qr_code_url && result.session_id) {
|
||||
setQrCodeUrl(result.qr_code_url)
|
||||
setQrSessionId(result.session_id)
|
||||
setQrStatus('ready')
|
||||
// 开始轮询
|
||||
startQrCheck(result.session_id)
|
||||
} else {
|
||||
setQrStatus('error')
|
||||
addToast({ type: 'error', message: result.message || '生成二维码失败' })
|
||||
}
|
||||
} catch {
|
||||
setQrStatus('error')
|
||||
addToast({ type: 'error', message: '生成二维码失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const startQrCheck = (sessionId: string) => {
|
||||
clearQrCheck()
|
||||
qrCheckIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await checkQRLoginStatus(sessionId)
|
||||
if (!result.success) return
|
||||
|
||||
switch (result.status) {
|
||||
case 'scanned':
|
||||
setQrStatus('scanned')
|
||||
break
|
||||
case 'success':
|
||||
setQrStatus('success')
|
||||
clearQrCheck()
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: result.account_info?.is_new_account
|
||||
? `新账号 ${result.account_info.account_id} 添加成功`
|
||||
: `账号 ${result.account_info?.account_id} 登录成功`,
|
||||
})
|
||||
setTimeout(() => {
|
||||
closeModal()
|
||||
loadAccounts()
|
||||
}, 1500)
|
||||
break
|
||||
case 'expired':
|
||||
setQrStatus('expired')
|
||||
clearQrCheck()
|
||||
break
|
||||
case 'cancelled':
|
||||
clearQrCheck()
|
||||
addToast({ type: 'warning', message: '用户取消登录' })
|
||||
closeModal()
|
||||
break
|
||||
case 'verification_required':
|
||||
addToast({ type: 'warning', message: '需要手机验证,请在手机上完成' })
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// 忽略网络错误,继续轮询
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const refreshQRCode = async () => {
|
||||
setQrStatus('loading')
|
||||
clearQrCheck()
|
||||
try {
|
||||
const result = await generateQRLogin()
|
||||
if (result.success && result.qr_code_url && result.session_id) {
|
||||
setQrCodeUrl(result.qr_code_url)
|
||||
setQrSessionId(result.session_id)
|
||||
setQrStatus('ready')
|
||||
startQrCheck(result.session_id)
|
||||
} else {
|
||||
setQrStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setQrStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 密码登录 ====================
|
||||
const handlePasswordLogin = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pwdAccount.trim() || !pwdPassword.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入账号和密码' })
|
||||
return
|
||||
}
|
||||
|
||||
setPwdLoading(true)
|
||||
try {
|
||||
const result = await passwordLogin({
|
||||
account_id: pwdAccount.trim(),
|
||||
account: pwdAccount.trim(),
|
||||
password: pwdPassword,
|
||||
show_browser: pwdShowBrowser,
|
||||
})
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: '登录请求已提交,请等待处理' })
|
||||
closeModal()
|
||||
// 延迟刷新列表
|
||||
setTimeout(loadAccounts, 3000)
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '登录失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '登录请求失败' })
|
||||
} finally {
|
||||
setPwdLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 手动输入 ====================
|
||||
const handleManualAdd = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!manualAccountId.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入账号ID' })
|
||||
return
|
||||
}
|
||||
if (!manualCookie.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入Cookie' })
|
||||
return
|
||||
}
|
||||
|
||||
setManualLoading(true)
|
||||
try {
|
||||
const result = await addAccount({
|
||||
id: manualAccountId.trim(),
|
||||
cookie: manualCookie.trim(),
|
||||
})
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: '账号添加成功' })
|
||||
closeModal()
|
||||
loadAccounts()
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '添加失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '添加账号失败' })
|
||||
} finally {
|
||||
setManualLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleEnabled = async (account: AccountDetail) => {
|
||||
try {
|
||||
await updateAccount(account.id, { enabled: !account.enabled })
|
||||
addToast({ type: 'success', message: account.enabled ? '账号已禁用' : '账号已启用' })
|
||||
loadAccounts()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个账号吗?')) return
|
||||
try {
|
||||
await deleteAccount(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadAccounts()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 编辑账号 ====================
|
||||
const openEditModal = (account: AccountDetail) => {
|
||||
setEditingAccount(account)
|
||||
setEditNote(account.note || '')
|
||||
setEditUseAI(account.use_ai_reply || false)
|
||||
setEditUseDefault(account.use_default_reply || false)
|
||||
setEditCookie(account.cookie || '')
|
||||
setActiveModal('edit')
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!editingAccount) return
|
||||
|
||||
setEditSaving(true)
|
||||
try {
|
||||
await updateAccount(editingAccount.id, {
|
||||
note: editNote.trim() || undefined,
|
||||
use_ai_reply: editUseAI,
|
||||
use_default_reply: editUseDefault,
|
||||
cookie: editCookie.trim() || undefined,
|
||||
})
|
||||
addToast({ type: 'success', message: '账号信息已更新' })
|
||||
closeModal()
|
||||
loadAccounts()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '保存失败' })
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => clearQrCheck()
|
||||
}, [clearQrCheck])
|
||||
|
||||
if (loading) {
|
||||
return <PageLoading />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="page-header flex-between">
|
||||
<div>
|
||||
<h1 className="page-title">账号管理</h1>
|
||||
<p className="page-description">管理闲鱼账号Cookie信息</p>
|
||||
</div>
|
||||
<button onClick={loadAccounts} className="btn-ios-secondary">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add Account Card */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title ">
|
||||
<Plus className="w-4 h-4" />
|
||||
添加新账号
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{/* 扫码登录 */}
|
||||
<button
|
||||
onClick={startQRCodeLogin}
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-indigo-200
|
||||
bg-blue-50 hover:bg-blue-100 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<QrCode className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">扫码登录</p>
|
||||
<p className="text-xs text-gray-500">推荐方式</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 账号密码登录 */}
|
||||
<button
|
||||
onClick={() => setActiveModal('password')}
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
|
||||
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">账号密码</p>
|
||||
<p className="text-xs text-gray-500">使用账号和密码</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 手动输入 */}
|
||||
<button
|
||||
onClick={() => setActiveModal('manual')}
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
|
||||
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<Edit2 className="w-4 h-4 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">手动输入</p>
|
||||
<p className="text-xs text-gray-500">手动输入Cookie</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accounts List */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">账号列表</h2>
|
||||
<span className="badge-primary">{accounts.length} 个账号</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-ios">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号ID</th>
|
||||
<th>Cookie</th>
|
||||
<th>状态</th>
|
||||
<th>AI回复</th>
|
||||
<th>默认回复</th>
|
||||
<th>备注</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="empty-state py-8">
|
||||
<p className="text-gray-500">暂无账号,请添加新账号</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<tr key={account.id}>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
|
||||
<td>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded max-w-[120px] truncate block">
|
||||
{account.cookie?.substring(0, 25)}...
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`inline-flex items-center gap-1.5 ${account.enabled !== false ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
<span className={`status-dot ${account.enabled !== false ? 'status-dot-success' : 'status-dot-danger'}`} />
|
||||
{account.enabled !== false ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={account.use_ai_reply ? 'badge-success' : 'badge-gray'}>
|
||||
{account.use_ai_reply ? '开启' : '关闭'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={account.use_default_reply ? 'badge-success' : 'badge-gray'}>
|
||||
{account.use_default_reply ? '开启' : '关闭'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500 max-w-[80px] truncate">
|
||||
{account.note || '-'}
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(account)}
|
||||
className="table-action-btn"
|
||||
title={account.enabled !== false ? '禁用' : '启用'}
|
||||
>
|
||||
{account.enabled !== false ? (
|
||||
<PowerOff className="w-4 h-4 text-amber-500" />
|
||||
) : (
|
||||
<Power className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(account)}
|
||||
className="table-action-btn"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-blue-500" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(account.id)}
|
||||
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>
|
||||
|
||||
{/* 扫码登录弹窗 */}
|
||||
{activeModal === 'qrcode' && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-sm">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">扫码登录</h2>
|
||||
<button onClick={closeModal} className="modal-close">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body flex flex-col items-center py-6">
|
||||
{qrStatus === 'loading' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-10 h-10 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<p className="text-sm text-gray-500">正在生成二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'ready' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img src={qrCodeUrl} alt="登录二维码" className="w-44 h-44 rounded-lg border" />
|
||||
<p className="text-sm text-gray-600">请使用闲鱼APP扫描二维码</p>
|
||||
<p className="text-xs text-gray-400">二维码有效期约5分钟</p>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'scanned' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img src={qrCodeUrl} alt="登录二维码" className="w-44 h-44 rounded-lg border opacity-50" />
|
||||
<div className=" text-blue-600 dark:text-blue-400 text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>已扫描,等待确认...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'success' && (
|
||||
<div className="flex flex-col items-center gap-3 text-green-600">
|
||||
<div className="w-14 h-14 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<Power className="w-7 h-7" />
|
||||
</div>
|
||||
<p className="font-medium">登录成功!</p>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'expired' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-gray-500">二维码已过期</p>
|
||||
<button onClick={refreshQRCode} className="btn-ios-primary btn-sm">
|
||||
刷新二维码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'error' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-red-500">生成二维码失败</p>
|
||||
<button onClick={refreshQRCode} className="btn-ios-primary btn-sm">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密码登录弹窗 */}
|
||||
{activeModal === 'password' && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-sm">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">账号密码登录</h2>
|
||||
<button onClick={closeModal} className="modal-close">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handlePasswordLogin}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">账号</label>
|
||||
<input
|
||||
type="text"
|
||||
value={pwdAccount}
|
||||
onChange={(e) => setPwdAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入闲鱼账号/手机号"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={pwdPassword}
|
||||
onChange={(e) => setPwdPassword(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pwdShowBrowser}
|
||||
onChange={(e) => setPwdShowBrowser(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
显示浏览器(调试用)
|
||||
</label>
|
||||
<p className="input-hint">
|
||||
登录过程可能需要进行人脸验证,请确保手机畅通
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={pwdLoading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={pwdLoading}>
|
||||
{pwdLoading ? (
|
||||
<span className="">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
登录中...
|
||||
</span>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 手动输入弹窗 */}
|
||||
{activeModal === 'manual' && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-md">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">手动输入Cookie</h2>
|
||||
<button onClick={closeModal} className="modal-close">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleManualAdd}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">账号ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={manualAccountId}
|
||||
onChange={(e) => setManualAccountId(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入账号ID(如手机号或用户名)"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">Cookie</label>
|
||||
<textarea
|
||||
value={manualCookie}
|
||||
onChange={(e) => setManualCookie(e.target.value)}
|
||||
className="input-ios h-28 resize-none font-mono text-xs"
|
||||
placeholder="请粘贴完整的Cookie值"
|
||||
/>
|
||||
<p className="input-hint">
|
||||
可从浏览器开发者工具中获取Cookie
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={manualLoading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={manualLoading}>
|
||||
{manualLoading ? (
|
||||
<span className="">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
添加中...
|
||||
</span>
|
||||
) : (
|
||||
'添加账号'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑账号弹窗 */}
|
||||
{activeModal === 'edit' && editingAccount && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-md">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">编辑账号</h2>
|
||||
<button onClick={closeModal} className="modal-close">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleEditSubmit}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">账号ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingAccount.id}
|
||||
disabled
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">备注</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="添加备注信息"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">Cookie</label>
|
||||
<textarea
|
||||
value={editCookie}
|
||||
onChange={(e) => setEditCookie(e.target.value)}
|
||||
className="input-ios h-20 resize-none font-mono text-xs"
|
||||
placeholder="更新Cookie值"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editUseAI}
|
||||
onChange={(e) => setEditUseAI(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
启用AI回复
|
||||
</label>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editUseDefault}
|
||||
onChange={(e) => setEditUseDefault(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
启用默认回复
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={editSaving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={editSaving}>
|
||||
{editSaving ? (
|
||||
<span className="">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
保存中...
|
||||
</span>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
frontend/src/pages/admin/DataManagement.tsx
Normal file
256
frontend/src/pages/admin/DataManagement.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
import { Database, Download, Upload, Trash2, AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { exportData, cleanupData, importData } from '@/api/admin'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { ButtonLoading } from '@/components/common/Loading'
|
||||
|
||||
const dataTypes = [
|
||||
{ id: 'accounts', name: '账号数据', desc: '导出所有闲鱼账号信息' },
|
||||
{ id: 'keywords', name: '关键词数据', desc: '导出所有自动回复关键词' },
|
||||
{ id: 'items', name: '商品数据', desc: '导出所有商品信息' },
|
||||
{ id: 'orders', name: '订单数据', desc: '导出所有订单信息' },
|
||||
{ id: 'cards', name: '卡券数据', desc: '导出所有卡券信息' },
|
||||
{ id: 'all', name: '全部数据', desc: '导出所有系统数据' },
|
||||
]
|
||||
|
||||
const cleanupTypes = [
|
||||
{ id: 'logs', name: '清理日志', desc: '清理超过30天的系统日志', danger: false },
|
||||
{ id: 'orders', name: '清理订单', desc: '清理已完成的历史订单', danger: false },
|
||||
{ id: 'cards_used', name: '清理已用卡券', desc: '清理已使用的卡券记录', danger: false },
|
||||
{ id: 'all_data', name: '清空所有数据', desc: '危险操作!清除所有用户数据', danger: true },
|
||||
]
|
||||
|
||||
export function DataManagement() {
|
||||
const { addToast } = useUIStore()
|
||||
const [exporting, setExporting] = useState<string | null>(null)
|
||||
const [cleaning, setCleaning] = useState<string | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleExport = async (type: string) => {
|
||||
try {
|
||||
setExporting(type)
|
||||
const blob = await exportData(type)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `xianyu_${type}_${new Date().toISOString().split('T')[0]}.json`
|
||||
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(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanup = async (type: string, danger: boolean) => {
|
||||
const confirmMsg = danger
|
||||
? '⚠️ 这是一个危险操作!将会清除所有用户数据,此操作不可恢复!确定要继续吗?'
|
||||
: '确定要执行此清理操作吗?'
|
||||
|
||||
if (!confirm(confirmMsg)) return
|
||||
if (danger && !confirm('再次确认:是否真的要清空所有数据?')) return
|
||||
|
||||
try {
|
||||
setCleaning(type)
|
||||
const result = await cleanupData(type)
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: '清理完成' })
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '清理失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '清理失败' })
|
||||
} finally {
|
||||
setCleaning(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = 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
|
||||
}
|
||||
|
||||
if (!confirm('确定要导入此数据吗?这将覆盖现有数据!')) {
|
||||
e.target.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
setImporting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await importData(formData)
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: '数据导入成功' })
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '导入失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '导入失败' })
|
||||
} finally {
|
||||
setImporting(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="page-title">数据管理</h1>
|
||||
<p className="page-description">导入导出和清理系统数据</p>
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title ">
|
||||
<Download className="w-4 h-4" />
|
||||
数据导出
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{dataTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary-300
|
||||
hover:bg-primary-50/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900 dark:text-slate-100">{type.name}</h3>
|
||||
<p className="text-sm page-description">{type.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleExport(type.id)}
|
||||
disabled={exporting !== null}
|
||||
className="btn-ios-secondary py-2 px-3 text-sm"
|
||||
>
|
||||
{exporting === type.id ? <ButtonLoading /> : <Download className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div
|
||||
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="bg-emerald-500 px-6 py-4 text-white">
|
||||
<h2 className="vben-card-title ">
|
||||
<Upload className="w-4 h-4" />
|
||||
数据导入
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div
|
||||
onClick={handleImportClick}
|
||||
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center
|
||||
hover:border-primary-400 transition-colors cursor-pointer"
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="w-12 h-12 text-blue-500 dark:text-blue-400 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-2">正在导入数据...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-12 h-12 text-slate-400 dark:text-slate-500 mx-auto mb-4" />
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-2">点击选择文件上传</p>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">支持 JSON 格式的导出数据文件</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cleanup Section */}
|
||||
<div
|
||||
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="bg-red-500 px-6 py-4 text-white">
|
||||
<h2 className="vben-card-title ">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
数据清理
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{cleanupTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`border rounded-xl p-4 ${
|
||||
type.danger
|
||||
? 'border-red-200 bg-red-50/50'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-amber-300 hover:bg-amber-50/30'
|
||||
} transition-colors`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{type.danger && (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-medium ${type.danger ? 'text-red-700' : 'text-slate-900 dark:text-slate-100'}`}>
|
||||
{type.name}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${type.danger ? 'text-red-600' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{type.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCleanup(type.id, type.danger)}
|
||||
disabled={cleaning !== null}
|
||||
className={`py-2 px-3 text-sm rounded-lg font-medium transition-colors ${
|
||||
type.danger
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'btn-ios-secondary'
|
||||
}`}
|
||||
>
|
||||
{cleaning === type.id ? <ButtonLoading /> : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
frontend/src/pages/admin/Logs.tsx
Normal file
147
frontend/src/pages/admin/Logs.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FileText, RefreshCw, Trash2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { getSystemLogs, clearSystemLogs, type SystemLog } from '@/api/admin'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Logs() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||
const [levelFilter, setLevelFilter] = useState('')
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getSystemLogs({ level: levelFilter || undefined })
|
||||
if (result.success) {
|
||||
setLogs(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载系统日志失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [levelFilter])
|
||||
|
||||
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={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 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>
|
||||
|
||||
{/* 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">{logs.length} 条记录</span>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100 dark:divide-slate-700 max-h-[600px] overflow-y-auto">
|
||||
{logs.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>
|
||||
) : (
|
||||
logs.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>
|
||||
)
|
||||
}
|
||||
162
frontend/src/pages/admin/RiskLogs.tsx
Normal file
162
frontend/src/pages/admin/RiskLogs.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
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 { PageLoading } from '@/components/common/Loading'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
export function RiskLogs() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logs, setLogs] = useState<RiskLog[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
|
||||
const loadLogs = async () => {
|
||||
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 () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
loadLogs()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [selectedAccount])
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
|
||||
try {
|
||||
await clearRiskLogs()
|
||||
addToast({ type: 'success', message: '日志已清空' })
|
||||
loadLogs()
|
||||
} 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="max-w-md">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs List */}
|
||||
<div
|
||||
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="bg-red-500 px-6 py-4 text-white
|
||||
flex items-center justify-between">
|
||||
<h2 className="vben-card-title ">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
风控日志
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} 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-[300px] truncate text-slate-500 dark:text-slate-400">{log.message}</td>
|
||||
<td className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
311
frontend/src/pages/admin/Users.tsx
Normal file
311
frontend/src/pages/admin/Users.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
|
||||
import { Users as UsersIcon, RefreshCw, Plus, Edit2, Trash2, Shield, ShieldOff, X, Loader2 } from 'lucide-react'
|
||||
import { getUsers, deleteUser, updateUser, addUser } from '@/api/admin'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { User } from '@/types'
|
||||
|
||||
export function Users() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [formUsername, setFormUsername] = useState('')
|
||||
const [formPassword, setFormPassword] = useState('')
|
||||
const [formEmail, setFormEmail] = useState('')
|
||||
const [formIsAdmin, setFormIsAdmin] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getUsers()
|
||||
if (result.success) {
|
||||
setUsers(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载用户列表失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const handleToggleAdmin = async (user: User) => {
|
||||
try {
|
||||
await updateUser(user.user_id, { is_admin: !user.is_admin })
|
||||
addToast({ type: 'success', message: user.is_admin ? '已取消管理员权限' : '已设为管理员' })
|
||||
loadUsers()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
if (!confirm('确定要删除这个用户吗?此操作不可恢复!')) return
|
||||
try {
|
||||
await deleteUser(userId)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadUsers()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingUser(null)
|
||||
setFormUsername('')
|
||||
setFormPassword('')
|
||||
setFormEmail('')
|
||||
setFormIsAdmin(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setFormUsername(user.username)
|
||||
setFormPassword('')
|
||||
setFormEmail(user.email || '')
|
||||
setFormIsAdmin(user.is_admin)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingUser(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formUsername.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入用户名' })
|
||||
return
|
||||
}
|
||||
if (!editingUser && !formPassword) {
|
||||
addToast({ type: 'warning', message: '请输入密码' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
const data: Partial<User> & { password?: string } = {
|
||||
username: formUsername.trim(),
|
||||
email: formEmail.trim() || undefined,
|
||||
is_admin: formIsAdmin,
|
||||
}
|
||||
if (formPassword) data.password = formPassword
|
||||
await updateUser(editingUser.user_id, data)
|
||||
addToast({ type: 'success', message: '用户已更新' })
|
||||
} else {
|
||||
await addUser({
|
||||
username: formUsername.trim(),
|
||||
password: formPassword,
|
||||
email: formEmail.trim() || undefined,
|
||||
is_admin: formIsAdmin,
|
||||
})
|
||||
addToast({ type: 'success', message: '用户已添加' })
|
||||
}
|
||||
|
||||
closeModal()
|
||||
loadUsers()
|
||||
} 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={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="">
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
|
||||
title={user.is_admin ? '取消管理员' : '设为管理员'}
|
||||
>
|
||||
{user.is_admin ? (
|
||||
<ShieldOff className="w-4 h-4 text-amber-500" />
|
||||
) : (
|
||||
<Shield className="w-4 h-4 text-emerald-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.user_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>
|
||||
</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">
|
||||
{editingUser ? '编辑用户' : '添加用户'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="p-1 hover:bg-slate-100 dark:bg-slate-700 rounded-lg">
|
||||
<X className="w-4 h-4 text-slate-500 dark:text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formUsername}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">
|
||||
密码{editingUser && '(留空则不修改)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder={editingUser ? '留空则不修改密码' : '请输入密码'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">邮箱(可选)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-slate-700 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formIsAdmin}
|
||||
onChange={(e) => setFormIsAdmin(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
设为管理员
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
516
frontend/src/pages/auth/Login.tsx
Normal file
516
frontend/src/pages/auth/Login.tsx
Normal file
@ -0,0 +1,516 @@
|
||||
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 } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { ButtonLoading } from '@/components/common/Loading'
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
|
||||
// 初始化主题
|
||||
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')
|
||||
}
|
||||
|
||||
// Check if already logged in
|
||||
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])
|
||||
|
||||
// Load initial states
|
||||
useEffect(() => {
|
||||
getRegistrationStatus()
|
||||
.then((result) => setRegistrationEnabled(result.enabled))
|
||||
.catch(() => {})
|
||||
|
||||
getLoginInfoStatus()
|
||||
.then((result) => setShowDefaultLogin(result.enabled))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load captcha when switching to email-code
|
||||
useEffect(() => {
|
||||
if (loginType === 'email-code') {
|
||||
loadCaptcha()
|
||||
}
|
||||
}, [loginType])
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
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 handleVerifyCaptcha = async () => {
|
||||
if (captchaCode.length !== 4) return
|
||||
|
||||
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: '验证失败' })
|
||||
}
|
||||
}
|
||||
|
||||
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: '发送验证码失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
let loginData = {}
|
||||
|
||||
if (loginType === 'username') {
|
||||
if (!username || !password) {
|
||||
addToast({ type: 'error', message: '请输入用户名和密码' })
|
||||
return
|
||||
}
|
||||
loginData = { username, password }
|
||||
} else if (loginType === 'email-password') {
|
||||
if (!email || !emailPassword) {
|
||||
addToast({ type: 'error', message: '请输入邮箱和密码' })
|
||||
return
|
||||
}
|
||||
loginData = { email, password: emailPassword }
|
||||
} else {
|
||||
if (!emailForCode || !verificationCode) {
|
||||
addToast({ type: 'error', message: '请输入邮箱和验证码' })
|
||||
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 || '登录失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '登录失败,请检查网络连接' })
|
||||
} 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>
|
||||
|
||||
{/* Left side - Branding */}
|
||||
<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>
|
||||
{/* Decorative circles */}
|
||||
<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>
|
||||
|
||||
{/* Right side - Login form */}
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{/* Mobile header */}
|
||||
<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>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 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>
|
||||
|
||||
{/* Login type tabs */}
|
||||
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-6">
|
||||
{[
|
||||
{ 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-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
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-4">
|
||||
{/* Username login */}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email password login */}
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email code login */}
|
||||
{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>
|
||||
|
||||
{/* 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)
|
||||
if (e.target.value.length === 4) {
|
||||
setTimeout(handleVerifyCaptcha, 100)
|
||||
}
|
||||
}}
|
||||
placeholder="输入验证码"
|
||||
maxLength={4}
|
||||
className="input-ios flex-1"
|
||||
/>
|
||||
<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' : 'text-gray-400'
|
||||
)}>
|
||||
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
|
||||
</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-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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full btn-ios-primary"
|
||||
>
|
||||
{loading ? <ButtonLoading /> : '登 录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Register link */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Default credentials */}
|
||||
{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>
|
||||
|
||||
{/* Footer */}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
328
frontend/src/pages/auth/Register.tsx
Normal file
328
frontend/src/pages/auth/Register.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
getRegistrationStatus()
|
||||
.then((result) => {
|
||||
setRegistrationEnabled(result.enabled)
|
||||
if (!result.enabled) {
|
||||
addToast({ type: 'warning', message: '注册功能已关闭' })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCaptcha()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [countdown])
|
||||
|
||||
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 handleVerifyCaptcha = async () => {
|
||||
if (captchaCode.length !== 4) return
|
||||
|
||||
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: '验证失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!captchaVerified || !email || 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 {
|
||||
addToast({ type: 'error', message: '注册失败,请检查网络连接' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center max-w-sm">
|
||||
<div className="w-14 h-14 rounded-full bg-amber-100 mx-auto mb-4 flex items-center justify-center">
|
||||
<span className="text-2xl">🚫</span>
|
||||
</div>
|
||||
<h1 className="text-lg vben-card-title text-gray-900 mb-2">注册功能已关闭</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">管理员已关闭注册功能,如需账号请联系管理员</p>
|
||||
<Link to="/login" className="btn-ios-primary">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
||||
<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-gray-900">用户注册</h1>
|
||||
<p className="text-sm page-description">创建您的账号以开始使用</p>
|
||||
</div>
|
||||
|
||||
{/* Register Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 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-gray-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-gray-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-gray-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-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{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-gray-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)
|
||||
if (e.target.value.length === 4) {
|
||||
setTimeout(handleVerifyCaptcha, 100)
|
||||
}
|
||||
}}
|
||||
placeholder="输入验证码"
|
||||
maxLength={4}
|
||||
className="input-ios flex-1"
|
||||
/>
|
||||
<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' : 'text-gray-400'
|
||||
)}>
|
||||
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
|
||||
</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-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 || !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-gray-500 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-gray-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>
|
||||
)
|
||||
}
|
||||
399
frontend/src/pages/cards/Cards.tsx
Normal file
399
frontend/src/pages/cards/Cards.tsx
Normal file
@ -0,0 +1,399 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Ticket, RefreshCw, Plus, Trash2, Upload, X, Loader2 } from 'lucide-react'
|
||||
import { getCards, deleteCard, addCard, importCards } from '@/api/cards'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { Card, Account } from '@/types'
|
||||
|
||||
type ModalType = 'add' | 'import' | null
|
||||
|
||||
export function Cards() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [cards, setCards] = useState<Card[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
|
||||
// 添加卡券表单
|
||||
const [addItemId, setAddItemId] = useState('')
|
||||
const [addCardContent, setAddCardContent] = useState('')
|
||||
const [addLoading, setAddLoading] = useState(false)
|
||||
|
||||
// 导入卡券表单
|
||||
const [importItemId, setImportItemId] = useState('')
|
||||
const [importContent, setImportContent] = useState('')
|
||||
const [importLoading, setImportLoading] = useState(false)
|
||||
|
||||
const loadCards = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getCards(selectedAccount || undefined)
|
||||
if (result.success) {
|
||||
setCards(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载卡券列表失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
loadCards()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCards()
|
||||
}, [selectedAccount])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这张卡券吗?')) return
|
||||
try {
|
||||
await deleteCard(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadCards()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setActiveModal(null)
|
||||
setAddItemId('')
|
||||
setAddCardContent('')
|
||||
setAddLoading(false)
|
||||
setImportItemId('')
|
||||
setImportContent('')
|
||||
setImportLoading(false)
|
||||
}
|
||||
|
||||
const handleAddCard = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedAccount) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
return
|
||||
}
|
||||
if (!addItemId.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入商品ID' })
|
||||
return
|
||||
}
|
||||
if (!addCardContent.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入卡密内容' })
|
||||
return
|
||||
}
|
||||
|
||||
setAddLoading(true)
|
||||
try {
|
||||
const cards = addCardContent.split('\n').map((s) => s.trim()).filter(Boolean)
|
||||
const result = await addCard(selectedAccount, { item_id: addItemId.trim(), cards })
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: `成功添加 ${cards.length} 张卡券` })
|
||||
closeModal()
|
||||
loadCards()
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '添加失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '添加卡券失败' })
|
||||
} finally {
|
||||
setAddLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportCards = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedAccount) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
return
|
||||
}
|
||||
if (!importItemId.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入商品ID' })
|
||||
return
|
||||
}
|
||||
if (!importContent.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入卡密内容' })
|
||||
return
|
||||
}
|
||||
|
||||
setImportLoading(true)
|
||||
try {
|
||||
const result = await importCards(selectedAccount, {
|
||||
item_id: importItemId.trim(),
|
||||
content: importContent,
|
||||
})
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: '卡券导入成功' })
|
||||
closeModal()
|
||||
loadCards()
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '导入失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '导入卡券失败' })
|
||||
} finally {
|
||||
setImportLoading(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('import')} className="btn-ios-success">
|
||||
<Upload className="w-4 h-4" />
|
||||
导入卡券
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{/* 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={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</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>
|
||||
<span className="badge-primary">{cards.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>
|
||||
{cards.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} 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 text-blue-600 dark:text-blue-400">{card.cookie_id}</td>
|
||||
<td className="text-sm">{card.item_id}</td>
|
||||
<td>
|
||||
<code className="text-xs bg-gray-100 px-2 py-1 rounded max-w-[200px] truncate block">
|
||||
{card.card_content}
|
||||
</code>
|
||||
</td>
|
||||
<td>
|
||||
{card.is_used ? (
|
||||
<span className="badge-gray">已使用</span>
|
||||
) : (
|
||||
<span className="badge-success">未使用</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-gray-500 text-sm">
|
||||
{card.created_at ? new Date(card.created_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleDelete(card.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加卡券弹窗 */}
|
||||
{activeModal === 'add' && (
|
||||
<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">添加卡券</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={handleAddCard}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">所属账号</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedAccount || '请先在列表页选择账号'}
|
||||
disabled
|
||||
className="input-ios bg-gray-100 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">商品ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={addItemId}
|
||||
onChange={(e) => setAddItemId(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入商品ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">卡密内容</label>
|
||||
<textarea
|
||||
value={addCardContent}
|
||||
onChange={(e) => setAddCardContent(e.target.value)}
|
||||
className="input-ios h-32 resize-none font-mono text-sm"
|
||||
placeholder="每行一个卡密,支持批量添加"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
每行输入一个卡密,系统会自动按行拆分
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={addLoading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={addLoading || !selectedAccount}>
|
||||
{addLoading ? (
|
||||
<span className="">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
添加中...
|
||||
</span>
|
||||
) : (
|
||||
'添加'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导入卡券弹窗 */}
|
||||
{activeModal === 'import' && (
|
||||
<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">导入卡券</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={handleImportCards}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">所属账号</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedAccount || '请先在列表页选择账号'}
|
||||
disabled
|
||||
className="input-ios bg-gray-100 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">商品ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={importItemId}
|
||||
onChange={(e) => setImportItemId(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入商品ID"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">卡密内容(批量)</label>
|
||||
<textarea
|
||||
value={importContent}
|
||||
onChange={(e) => setImportContent(e.target.value)}
|
||||
className="input-ios h-40 resize-none font-mono text-sm"
|
||||
placeholder="粘贴卡密内容,每行一个 支持从Excel/TXT批量粘贴"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
支持从Excel或文本文件中批量粘贴,系统会自动按行解析
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={importLoading}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={importLoading || !selectedAccount}>
|
||||
{importLoading ? (
|
||||
<span className="">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
导入中...
|
||||
</span>
|
||||
) : (
|
||||
'导入'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
229
frontend/src/pages/dashboard/Dashboard.tsx
Normal file
229
frontend/src/pages/dashboard/Dashboard.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Users, MessageSquare, Activity, ShoppingCart, RefreshCw } from 'lucide-react'
|
||||
import { getAccountDetails } from '@/api/accounts'
|
||||
import { getKeywords } from '@/api/keywords'
|
||||
import { getOrders } from '@/api/orders'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalAccounts: 0,
|
||||
totalKeywords: 0,
|
||||
activeAccounts: 0,
|
||||
totalOrders: 0,
|
||||
})
|
||||
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
||||
|
||||
const loadDashboard = async () => {
|
||||
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)
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载仪表盘数据失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [])
|
||||
|
||||
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-4">
|
||||
{/* Page header */}
|
||||
<div className="page-header flex-between">
|
||||
<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" />
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 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>
|
||||
|
||||
{/* 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>
|
||||
<span className={`inline-flex items-center gap-1.5 ${!isEnabled ? 'text-gray-400' : keywordCount > 0 ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
<span className={`status-dot ${!isEnabled ? 'status-dot-danger' : keywordCount > 0 ? 'status-dot-success' : 'bg-gray-300'}`} />
|
||||
{!isEnabled ? '已禁用' : keywordCount > 0 ? '活跃' : '无关键词'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
{account.updated_at
|
||||
? new Date(account.updated_at).toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
379
frontend/src/pages/delivery/Delivery.tsx
Normal file
379
frontend/src/pages/delivery/Delivery.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
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 { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { DeliveryRule, Account } from '@/types'
|
||||
|
||||
export function Delivery() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rules, setRules] = useState<DeliveryRule[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
|
||||
const [formItemId, setFormItemId] = useState('')
|
||||
const [formDeliveryType, setFormDeliveryType] = useState<'card' | 'text' | 'api'>('card')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
const [formEnabled, setFormEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadRules = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getDeliveryRules(selectedAccount || undefined)
|
||||
if (result.success) {
|
||||
setRules(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载发货规则失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
loadRules()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadRules()
|
||||
}, [selectedAccount])
|
||||
|
||||
const handleToggleEnabled = async (rule: DeliveryRule) => {
|
||||
try {
|
||||
await updateDeliveryRule(rule.id, { enabled: !rule.enabled })
|
||||
addToast({ type: 'success', message: rule.enabled ? '规则已禁用' : '规则已启用' })
|
||||
loadRules()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这条规则吗?')) return
|
||||
try {
|
||||
await deleteDeliveryRule(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadRules()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!selectedAccount) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
return
|
||||
}
|
||||
setEditingRule(null)
|
||||
setFormItemId('')
|
||||
setFormDeliveryType('card')
|
||||
setFormContent('')
|
||||
setFormEnabled(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (rule: DeliveryRule) => {
|
||||
setEditingRule(rule)
|
||||
setFormItemId(rule.item_id || '')
|
||||
setFormDeliveryType((rule.delivery_type as 'card' | 'text' | 'api') || 'card')
|
||||
setFormContent(rule.delivery_content || '')
|
||||
setFormEnabled(rule.enabled)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingRule(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedAccount && !editingRule) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
cookie_id: editingRule?.cookie_id || selectedAccount,
|
||||
item_id: formItemId || undefined,
|
||||
delivery_type: formDeliveryType,
|
||||
delivery_content: formContent,
|
||||
enabled: formEnabled,
|
||||
}
|
||||
|
||||
if (editingRule) {
|
||||
await updateDeliveryRule(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>
|
||||
|
||||
{/* Filter */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="max-w-md">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</motion.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>账号ID</th>
|
||||
<th>商品ID</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) => (
|
||||
<tr key={rule.id}>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.cookie_id}</td>
|
||||
<td className="text-sm">{rule.item_id || '所有商品'}</td>
|
||||
<td>
|
||||
{rule.delivery_type === 'card' ? (
|
||||
<span className="badge-info">卡密发货</span>
|
||||
) : rule.delivery_type === 'text' ? (
|
||||
<span className="badge-warning">固定文本</span>
|
||||
) : (
|
||||
<span className="badge-gray">其他</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate text-gray-500">
|
||||
{rule.delivery_content || '-'}
|
||||
</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={editingRule?.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="留空表示适用于所有商品"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">发货类型</label>
|
||||
<select
|
||||
value={formDeliveryType}
|
||||
onChange={(e) => setFormDeliveryType(e.target.value as 'card' | 'text' | 'api')}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="card">卡密发货</option>
|
||||
<option value="text">固定文本</option>
|
||||
<option value="api">API接口</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">
|
||||
{formDeliveryType === 'card' ? '卡密说明' : formDeliveryType === 'api' ? 'API地址' : '发货内容'}
|
||||
</label>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
className="input-ios h-24 resize-none"
|
||||
placeholder={
|
||||
formDeliveryType === 'card'
|
||||
? '卡密将从卡券库中自动获取'
|
||||
: formDeliveryType === 'api'
|
||||
? '请输入API接口地址'
|
||||
: '请输入固定发货文本'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
启用此规则
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
323
frontend/src/pages/item-replies/ItemReplies.tsx
Normal file
323
frontend/src/pages/item-replies/ItemReplies.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
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 { PageLoading } from '@/components/common/Loading'
|
||||
import type { ItemReply, Account } from '@/types'
|
||||
|
||||
export function ItemReplies() {
|
||||
const { addToast } = useUIStore()
|
||||
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 () => {
|
||||
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 () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
loadReplies()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadReplies()
|
||||
}, [selectedAccount])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这条商品回复吗?')) return
|
||||
try {
|
||||
await deleteItemReply(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: formReply.trim(),
|
||||
}
|
||||
|
||||
if (editingReply) {
|
||||
await updateItemReply(editingReply.id, data)
|
||||
addToast({ type: 'success', message: '回复已更新' })
|
||||
} else {
|
||||
await addItemReply(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="max-w-md">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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.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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
322
frontend/src/pages/items/Items.tsx
Normal file
322
frontend/src/pages/items/Items.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Package, RefreshCw, Search, Trash2, Download, CheckSquare, Square, Loader2 } from 'lucide-react'
|
||||
import { getItems, deleteItem, fetchItemsFromAccount, batchDeleteItems } from '@/api/items'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { Item, Account } from '@/types'
|
||||
|
||||
export function Items() {
|
||||
const { addToast } = useUIStore()
|
||||
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>>(new Set())
|
||||
const [fetching, setFetching] = useState(false)
|
||||
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0 })
|
||||
|
||||
const loadItems = async () => {
|
||||
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)
|
||||
setFetchProgress({ current: 0, total: 0 })
|
||||
|
||||
try {
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
let totalFetched = 0
|
||||
|
||||
while (hasMore) {
|
||||
setFetchProgress({ current: page, total: page })
|
||||
const result = await fetchItemsFromAccount(selectedAccount, page)
|
||||
|
||||
if (result.success) {
|
||||
const fetchedCount = (result as { count?: number }).count || 0
|
||||
totalFetched += fetchedCount
|
||||
hasMore = (result as { has_more?: boolean }).has_more === true
|
||||
page++
|
||||
} else {
|
||||
hasMore = false
|
||||
}
|
||||
|
||||
// 防止无限循环,最多抓取20页
|
||||
if (page > 20) hasMore = false
|
||||
}
|
||||
|
||||
addToast({ type: 'success', message: `成功获取商品,共 ${totalFetched} 件` })
|
||||
await loadItems()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '获取商品失败' })
|
||||
} finally {
|
||||
setFetching(false)
|
||||
setFetchProgress({ current: 0, total: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
loadItems()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadItems()
|
||||
}, [selectedAccount])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个商品吗?')) return
|
||||
try {
|
||||
await deleteItem(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadItems()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
// 批量选择相关
|
||||
const toggleSelect = (id: string) => {
|
||||
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 {
|
||||
await batchDeleteItems(Array.from(selectedIds))
|
||||
addToast({ type: 'success', message: `成功删除 ${selectedIds.size} 个商品` })
|
||||
setSelectedIds(new Set())
|
||||
loadItems()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '批量删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (!searchKeyword) return true
|
||||
const keyword = searchKeyword.toLowerCase()
|
||||
return (
|
||||
item.title?.toLowerCase().includes(keyword) ||
|
||||
item.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-success"
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
抓取中 (第{fetchProgress.current}页)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-10">
|
||||
<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>账号ID</th>
|
||||
<th>商品ID</th>
|
||||
<th>商品标题</th>
|
||||
<th>价格</th>
|
||||
<th>多规格</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<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' : ''}>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => toggleSelect(item.id)}
|
||||
className="p-1 hover:bg-gray-100 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">{item.item_id}</td>
|
||||
<td className="max-w-[180px] truncate" title={item.title}>
|
||||
{item.title}
|
||||
</td>
|
||||
<td className="text-amber-600 font-medium">¥{item.price}</td>
|
||||
<td>
|
||||
<span className={item.has_sku ? 'badge-success' : 'badge-gray'}>
|
||||
{item.has_sku ? '是' : '否'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
{item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="table-action-btn hover:!bg-red-50"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
447
frontend/src/pages/keywords/Keywords.tsx
Normal file
447
frontend/src/pages/keywords/Keywords.tsx
Normal file
@ -0,0 +1,447 @@
|
||||
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 } from 'lucide-react'
|
||||
import { getKeywords, deleteKeyword, addKeyword, updateKeyword, exportKeywords, importKeywords as importKeywordsApi } from '@/api/keywords'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { Keyword, Account } from '@/types'
|
||||
|
||||
export function Keywords() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keywords, setKeywords] = useState<Keyword[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
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 [fuzzyMatch, setFuzzyMatch] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const loadKeywords = async () => {
|
||||
if (!selectedAccount) {
|
||||
setKeywords([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getKeywords(selectedAccount)
|
||||
setKeywords(data)
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载关键词列表失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
if (data.length > 0 && !selectedAccount) {
|
||||
setSelectedAccount(data[0].id)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccount) {
|
||||
loadKeywords()
|
||||
}
|
||||
}, [selectedAccount])
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!selectedAccount) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
return
|
||||
}
|
||||
setEditingKeyword(null)
|
||||
setKeywordText('')
|
||||
setReplyText('')
|
||||
setFuzzyMatch(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (keyword: Keyword) => {
|
||||
setEditingKeyword(keyword)
|
||||
setKeywordText(keyword.keyword)
|
||||
setReplyText(keyword.reply)
|
||||
setFuzzyMatch(!!keyword.fuzzy_match)
|
||||
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) {
|
||||
await updateKeyword(selectedAccount, editingKeyword.id, {
|
||||
keyword: keywordText.trim(),
|
||||
reply: replyText.trim(),
|
||||
fuzzy_match: fuzzyMatch,
|
||||
})
|
||||
addToast({ type: 'success', message: '关键词已更新' })
|
||||
} else {
|
||||
await addKeyword(selectedAccount, {
|
||||
keyword: keywordText.trim(),
|
||||
reply: replyText.trim(),
|
||||
fuzzy_match: fuzzyMatch,
|
||||
})
|
||||
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)
|
||||
if (result.success) {
|
||||
const info = (result.data as { added?: number; updated?: number } | undefined) || {}
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: `导入成功:新增 ${info.added ?? 0} 条,更新 ${info.updated ?? 0} 条`,
|
||||
})
|
||||
await loadKeywords()
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || '导入失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '导入关键词失败' })
|
||||
} finally {
|
||||
setImporting(false)
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (keywordId: string) => {
|
||||
if (!confirm('确定要删除这个关键词吗?')) return
|
||||
try {
|
||||
await deleteKeyword(selectedAccount, keywordId)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadKeywords()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
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={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="max-w-md">
|
||||
<label className="input-label">选择账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
{accounts.length === 0 ? (
|
||||
<option value="">暂无账号</option>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</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>回复内容</th>
|
||||
<th>匹配模式</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!selectedAccount ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-8 text-gray-500">
|
||||
请先选择一个账号
|
||||
</td>
|
||||
</tr>
|
||||
) : loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center py-8 text-gray-500">
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : keywords.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} 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) => (
|
||||
<tr key={keyword.id}>
|
||||
<td className="font-medium">
|
||||
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded">
|
||||
{keyword.keyword}
|
||||
</code>
|
||||
</td>
|
||||
<td className="max-w-[300px]">
|
||||
<p className="truncate text-gray-600" title={keyword.reply}>
|
||||
{keyword.reply}
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
{keyword.fuzzy_match ? (
|
||||
<span className="badge-info">模糊匹配</span>
|
||||
) : (
|
||||
<span className="badge-gray">精确匹配</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="">
|
||||
<button
|
||||
onClick={() => openEditModal(keyword)}
|
||||
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(keyword.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">
|
||||
<div className="modal-header">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{editingKeyword ? '编辑关键词' : '添加关键词'}
|
||||
</h2>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">所属账号</label>
|
||||
<input
|
||||
type="text"
|
||||
value={selectedAccount}
|
||||
disabled
|
||||
className="input-ios bg-gray-100 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">回复内容</label>
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
className="input-ios h-28 resize-none"
|
||||
placeholder="请输入自动回复内容"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fuzzyMatch}
|
||||
onChange={(e) => setFuzzyMatch(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400 focus:ring-primary-500"
|
||||
/>
|
||||
使用模糊匹配
|
||||
</label>
|
||||
<p className="text-xs text-gray-400">开启后,将在消息中模糊匹配该关键词</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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
307
frontend/src/pages/notifications/MessageNotifications.tsx
Normal file
307
frontend/src/pages/notifications/MessageNotifications.tsx
Normal file
@ -0,0 +1,307 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Mail, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getMessageNotifications, deleteMessageNotification, updateMessageNotification, addMessageNotification } from '@/api/notifications'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { MessageNotification } from '@/types'
|
||||
|
||||
export function MessageNotifications() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notifications, setNotifications] = useState<MessageNotification[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingNotification, setEditingNotification] = useState<MessageNotification | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formKeyword, setFormKeyword] = useState('')
|
||||
const [formChannelId, setFormChannelId] = useState('')
|
||||
const [formEnabled, setFormEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadNotifications = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getMessageNotifications()
|
||||
if (result.success) {
|
||||
setNotifications(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载消息通知失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications()
|
||||
}, [])
|
||||
|
||||
const handleToggleEnabled = async (notification: MessageNotification) => {
|
||||
try {
|
||||
await updateMessageNotification(notification.id, { enabled: !notification.enabled })
|
||||
addToast({ type: 'success', message: notification.enabled ? '通知已禁用' : '通知已启用' })
|
||||
loadNotifications()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个消息通知吗?')) return
|
||||
try {
|
||||
await deleteMessageNotification(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadNotifications()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingNotification(null)
|
||||
setFormName('')
|
||||
setFormKeyword('')
|
||||
setFormChannelId('')
|
||||
setFormEnabled(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (notification: MessageNotification) => {
|
||||
setEditingNotification(notification)
|
||||
setFormName(notification.name)
|
||||
setFormKeyword(notification.trigger_keyword || '')
|
||||
setFormChannelId(notification.channel_id || '')
|
||||
setFormEnabled(notification.enabled)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingNotification(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formName.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入通知名称' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
name: formName.trim(),
|
||||
trigger_keyword: formKeyword.trim() || undefined,
|
||||
channel_id: formChannelId.trim() || undefined,
|
||||
enabled: formEnabled,
|
||||
}
|
||||
|
||||
if (editingNotification) {
|
||||
await updateMessageNotification(editingNotification.id, data)
|
||||
addToast({ type: 'success', message: '通知已更新' })
|
||||
} else {
|
||||
await addMessageNotification(data)
|
||||
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>名称</th>
|
||||
<th>触发关键词</th>
|
||||
<th>通知渠道</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{notifications.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} 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.id}>
|
||||
<td className="font-medium">{notification.name}</td>
|
||||
<td>
|
||||
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded text-sm">
|
||||
{notification.trigger_keyword || '全部消息'}
|
||||
</code>
|
||||
</td>
|
||||
<td className="text-sm text-gray-500">
|
||||
{notification.channel_id || '-'}
|
||||
</td>
|
||||
<td>
|
||||
{notification.enabled ? (
|
||||
<span className="badge-success">启用</span>
|
||||
) : (
|
||||
<span className="badge-danger">禁用</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(notification)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 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={() => openEditModal(notification)}
|
||||
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(notification.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-md">
|
||||
<div className="modal-header flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{editingNotification ? '编辑消息通知' : '添加消息通知'}
|
||||
</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={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="如:订单通知"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">触发关键词(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formKeyword}
|
||||
onChange={(e) => setFormKeyword(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="留空表示所有消息"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">通知渠道ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formChannelId}
|
||||
onChange={(e) => setFormChannelId(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="输入渠道ID"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
启用此通知
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
341
frontend/src/pages/notifications/NotificationChannels.tsx
Normal file
341
frontend/src/pages/notifications/NotificationChannels.tsx
Normal file
@ -0,0 +1,341 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Bell, RefreshCw, Plus, Edit2, Trash2, Send, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getNotificationChannels, deleteNotificationChannel, updateNotificationChannel, testNotificationChannel, addNotificationChannel } from '@/api/notifications'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { NotificationChannel } from '@/types'
|
||||
|
||||
const channelTypeLabels: Record<string, string> = {
|
||||
email: '邮件',
|
||||
wechat: '微信',
|
||||
dingtalk: '钉钉',
|
||||
feishu: '飞书',
|
||||
webhook: 'Webhook',
|
||||
telegram: 'Telegram',
|
||||
}
|
||||
|
||||
export function NotificationChannels() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingChannel, setEditingChannel] = useState<NotificationChannel | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formType, setFormType] = useState<'email' | 'wechat' | 'dingtalk' | 'feishu' | 'webhook' | 'telegram'>('email')
|
||||
const [formConfig, setFormConfig] = useState('')
|
||||
const [formEnabled, setFormEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getNotificationChannels()
|
||||
if (result.success) {
|
||||
setChannels(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载通知渠道失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels()
|
||||
}, [])
|
||||
|
||||
const handleToggleEnabled = async (channel: NotificationChannel) => {
|
||||
try {
|
||||
await updateNotificationChannel(channel.id, { 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 openAddModal = () => {
|
||||
setEditingChannel(null)
|
||||
setFormName('')
|
||||
setFormType('email')
|
||||
setFormConfig('')
|
||||
setFormEnabled(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (channel: NotificationChannel) => {
|
||||
setEditingChannel(channel)
|
||||
setFormName(channel.name)
|
||||
setFormType(channel.type)
|
||||
setFormConfig(JSON.stringify(channel.config || {}, null, 2))
|
||||
setFormEnabled(channel.enabled)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingChannel(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formName.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入渠道名称' })
|
||||
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: formType,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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={loadChannels} className="btn-ios-secondary ">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channels Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{channels.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="col-span-full vben-card p-8 text-center"
|
||||
>
|
||||
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暂无通知渠道,点击上方按钮添加</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
channels.map((channel, index) => (
|
||||
<motion.div
|
||||
key={channel.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
channel.enabled ? 'bg-primary-100 text-blue-500 dark:text-blue-400' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
<Bell className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="vben-card-title text-gray-900">{channel.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{channelTypeLabels[channel.type] || channel.type}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{channel.enabled ? (
|
||||
<span className="badge-success">启用</span>
|
||||
) : (
|
||||
<span className="badge-gray">禁用</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className=" mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(channel)}
|
||||
className="flex-1 btn-ios-secondary py-2 text-sm flex items-center justify-center gap-1"
|
||||
>
|
||||
{channel.enabled ? (
|
||||
<>
|
||||
<PowerOff className="w-4 h-4" />
|
||||
禁用
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Power className="w-4 h-4" />
|
||||
启用
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTest(channel.id)}
|
||||
className="flex-1 btn-ios-secondary py-2 text-sm flex items-center justify-center gap-1"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(channel)}
|
||||
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(channel.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</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">
|
||||
{editingChannel ? '编辑通知渠道' : '添加通知渠道'}
|
||||
</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={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="如:我的邮箱通知"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">渠道类型</label>
|
||||
<select
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value as typeof formType)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="email">邮件</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="dingtalk">钉钉</option>
|
||||
<option value="feishu">飞书</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
</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='{"webhook_url": "..."}'
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
根据渠道类型填写对应的配置,如webhook_url、token等
|
||||
</p>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
启用此渠道
|
||||
</label>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
224
frontend/src/pages/orders/Orders.tsx
Normal file
224
frontend/src/pages/orders/Orders.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { ShoppingCart, RefreshCw, Search, Trash2 } from 'lucide-react'
|
||||
import { getOrders, deleteOrder } from '@/api/orders'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { Order, Account } from '@/types'
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
processing: { label: '处理中', class: 'badge-warning' },
|
||||
processed: { label: '已处理', class: 'badge-info' },
|
||||
shipped: { label: '已发货', class: 'badge-success' },
|
||||
completed: { label: '已完成', class: 'badge-success' },
|
||||
cancelled: { label: '已关闭', class: 'badge-danger' },
|
||||
unknown: { label: '未知', class: 'badge-gray' },
|
||||
}
|
||||
|
||||
export function Orders() {
|
||||
const { addToast } = useUIStore()
|
||||
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 loadOrders = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined)
|
||||
if (result.success) {
|
||||
setOrders(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载订单列表失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
loadOrders()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders()
|
||||
}, [selectedAccount, selectedStatus])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个订单吗?')) return
|
||||
try {
|
||||
await deleteOrder(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadOrders()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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>
|
||||
<button onClick={loadOrders} className="btn-ios-secondary ">
|
||||
<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="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">订单状态</label>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有状态</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="processed">已处理</option>
|
||||
<option value="shipped">已发货</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">搜索订单</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 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-12"
|
||||
/>
|
||||
</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">{filteredOrders.length} 个订单</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>账号ID</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredOrders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} 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 className="font-medium text-blue-600 dark:text-blue-400">{order.cookie_id}</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleDelete(order.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
frontend/src/pages/search/ItemSearch.tsx
Normal file
142
frontend/src/pages/search/ItemSearch.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Search, ShoppingBag } from 'lucide-react'
|
||||
import { searchItems } from '@/api/search'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { ButtonLoading } from '@/components/common/Loading'
|
||||
import type { Item, Account } from '@/types'
|
||||
|
||||
export function ItemSearch() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
const [results, setResults] = useState<Item[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().then(setAccounts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleSearch = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!keyword.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入搜索关键词' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await searchItems(keyword, selectedAccount || undefined)
|
||||
if (result.success) {
|
||||
setResults(result.data || [])
|
||||
if ((result.data || []).length === 0) {
|
||||
addToast({ type: 'info', message: '未找到相关商品' })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '搜索失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="page-title">商品搜索</h1>
|
||||
<p className="page-description">在闲鱼平台搜索商品</p>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<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-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="输入关键词搜索商品..."
|
||||
className="input-ios pl-12"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-64">
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">使用所有账号搜索</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-ios-primary w-full md:w-32 flex items-center justify-center"
|
||||
>
|
||||
{loading ? <ButtonLoading /> : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
{/* Results */}
|
||||
<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 gap-6"
|
||||
>
|
||||
{results.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id || index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="vben-card group hover:shadow-ios-lg transition-all duration-300"
|
||||
>
|
||||
<div className="aspect-square bg-gray-100 relative overflow-hidden">
|
||||
{/* Placeholder for item image - in real app would use item.image_url */}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-300">
|
||||
<ShoppingBag className="w-12 h-12" />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-gray-900 line-clamp-2 mb-2 h-12">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-red-500">¥{item.price}</span>
|
||||
<span className="text-sm text-gray-500">{item.cookie_id}</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center text-sm text-gray-500">
|
||||
<span>ID: {item.item_id}</span>
|
||||
{item.has_sku && <span className="badge-info">多规格</span>}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && results.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<ShoppingBag className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<p>输入关键词开始搜索</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
frontend/src/pages/settings/Settings.tsx
Normal file
291
frontend/src/pages/settings/Settings.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw } from 'lucide-react'
|
||||
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend } from '@/api/settings'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
|
||||
import type { SystemSettings } from '@/types'
|
||||
|
||||
export function Settings() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<SystemSettings | null>(null)
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getSystemSettings()
|
||||
if (result.success && result.data) {
|
||||
setSettings(result.data)
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '加载系统设置失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
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 handleTestAI = async () => {
|
||||
try {
|
||||
const result = await testAIConnection()
|
||||
if (result.success) {
|
||||
addToast({ type: 'success', message: 'AI 连接测试成功' })
|
||||
} else {
|
||||
addToast({ type: 'error', message: result.message || 'AI 连接测试失败' })
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: 'AI 连接测试失败' })
|
||||
}
|
||||
}
|
||||
|
||||
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: '发送测试邮件失败' })
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <PageLoading />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
{/* 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={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>
|
||||
|
||||
{/* General Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<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="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.registration_enabled ?? true}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
|
||||
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
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 peer-checked:bg-blue-500" />
|
||||
</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="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.show_login_info ?? true}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
|
||||
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
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 peer-checked:bg-blue-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-green-500" />
|
||||
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 || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_api_url: e.target.value } : null)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings?.ai_api_key || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
|
||||
placeholder="sk-..."
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">模型</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.ai_model || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_model: e.target.value } : null)}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleTestAI} className="btn-ios-secondary">
|
||||
测试 AI 连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-amber-500" />
|
||||
邮件设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<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_host || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_host: e.target.value } : null)}
|
||||
placeholder="smtp.example.com"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings?.smtp_port || 465}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_port: parseInt(e.target.value) } : null)}
|
||||
placeholder="465"
|
||||
className="input-ios"
|
||||
/>
|
||||
</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="noreply@example.com"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">密码/授权码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings?.smtp_password || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
|
||||
placeholder="••••••••"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleTestEmail} className="btn-ios-secondary">
|
||||
发送测试邮件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-red-500" />
|
||||
安全设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<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="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.login_captcha_enabled ?? false}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
|
||||
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
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 peer-checked:bg-blue-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
frontend/src/store/authStore.ts
Normal file
48
frontend/src/store/authStore.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { User } from '@/types'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
setAuth: (token: string, user: User) => void
|
||||
clearAuth: () => void
|
||||
updateUser: (user: Partial<User>) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: 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,
|
||||
}))
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
60
frontend/src/store/uiStore.ts
Normal file
60
frontend/src/store/uiStore.ts
Normal file
@ -0,0 +1,60 @@
|
||||
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
|
||||
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 }))
|
||||
},
|
||||
|
||||
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),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
566
frontend/src/styles/globals.css
Normal file
566
frontend/src/styles/globals.css
Normal file
@ -0,0 +1,566 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.vben-card-body {
|
||||
@apply p-5;
|
||||
}
|
||||
|
||||
/* 兼容旧类名 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 输入框组 */
|
||||
.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-header {
|
||||
@apply flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ==================== 统计卡片 ==================== */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 阴影 */
|
||||
.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);
|
||||
}
|
||||
}
|
||||
207
frontend/src/types/index.ts
Normal file
207
frontend/src/types/index.ts
Normal file
@ -0,0 +1,207 @@
|
||||
// 用户相关类型
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 关键词相关类型
|
||||
export interface Keyword {
|
||||
id: string
|
||||
cookie_id: string
|
||||
keyword: string
|
||||
reply: string
|
||||
fuzzy_match: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 商品相关类型
|
||||
export interface Item {
|
||||
id: string
|
||||
cookie_id: string
|
||||
item_id: string
|
||||
title: string
|
||||
desc?: string
|
||||
price: string
|
||||
has_sku: boolean
|
||||
multi_delivery: 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
|
||||
sku_info?: string
|
||||
quantity: number
|
||||
amount: string
|
||||
status: OrderStatus
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type OrderStatus =
|
||||
| 'processing'
|
||||
| 'processed'
|
||||
| 'shipped'
|
||||
| 'completed'
|
||||
| '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: string
|
||||
cookie_id: string
|
||||
item_id?: string
|
||||
keyword?: string
|
||||
delivery_type: 'card' | 'text' | 'api'
|
||||
delivery_content?: string
|
||||
api_url?: string
|
||||
api_method?: string
|
||||
api_params?: string
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 通知渠道相关类型
|
||||
export interface NotificationChannel {
|
||||
id: string
|
||||
cookie_id?: string
|
||||
name: string
|
||||
type: 'webhook' | 'email' | 'telegram' | 'wechat' | 'dingtalk' | 'feishu'
|
||||
channel_type?: string
|
||||
channel_name?: string
|
||||
channel_config?: string
|
||||
config?: Record<string, unknown>
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 消息通知相关类型
|
||||
export interface MessageNotification {
|
||||
id: string
|
||||
cookie_id?: string
|
||||
name: string
|
||||
notification_type?: string
|
||||
trigger_keyword?: string
|
||||
channel_id?: string
|
||||
channel_ids?: string[]
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 系统设置相关类型
|
||||
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?: boolean
|
||||
show_login_info?: boolean
|
||||
login_captcha_enabled?: boolean
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_user?: string
|
||||
smtp_password?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
// 分页相关类型
|
||||
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
frontend/src/utils/cn.ts
Normal file
6
frontend/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))
|
||||
}
|
||||
80
frontend/src/utils/request.ts
Normal file
80
frontend/src/utils/request.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
// 创建 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 过期或无效,清除并跳转登录
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('user_info')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
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
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
89
frontend/tailwind.config.js
Normal file
89
frontend/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
frontend/tsconfig.json
Normal file
31
frontend/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
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
146
frontend/vite.config.ts
Normal file
146
frontend/vite.config.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
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,
|
||||
},
|
||||
'/verify': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/cookies': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/keywords': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/cards': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/delivery-rules': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/notification-channels': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/message-notifications': {
|
||||
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,
|
||||
},
|
||||
'/admin': {
|
||||
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,
|
||||
},
|
||||
'/register': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/items': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/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,
|
||||
},
|
||||
'/default-reply': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user