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:
“legeling” 2025-11-27 01:06:45 +08:00
parent 6bf2ac43e4
commit 543eed80e9
57 changed files with 12628 additions and 1 deletions

8
.gitignore vendored
View File

@ -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
View 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
View 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
View 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
View File

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

3924
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

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

136
frontend/src/App.tsx Normal file
View 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

View 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
View 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
View 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
View 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) })
}

View 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
View File

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

59
frontend/src/api/items.ts Normal file
View 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 } })
}

View 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' },
})
}

View 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}`)
}

View 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 })
}

View 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 })
}

View 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' })
}

View File

@ -0,0 +1,53 @@
import { motion } from 'framer-motion'
import { Loader2 } from 'lucide-react'
import { cn } from '@/utils/cn'
interface LoadingProps {
size?: 'sm' | 'md' | 'lg'
fullScreen?: boolean
text?: string
}
const sizes = {
sm: 'w-4 h-4',
md: 'w-8 h-8',
lg: 'w-12 h-12',
}
export function Loading({ size = 'md', fullScreen = false, text }: LoadingProps) {
const content = (
<div className="flex flex-col items-center justify-center gap-3">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<Loader2 className={cn('text-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" />
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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
View File

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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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="粘贴卡密内容,每行一个&#10;支持从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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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_urltoken等
</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}),
}
)
)

View 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),
}))
},
}))

View 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
View 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
View File

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

View 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
View File

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

View File

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

31
frontend/tsconfig.json Normal file
View File

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

View 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
View 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',
},
})