完善前端功能:卡券管理完整迁移、移动端侧边栏修复、AI测试账号选择

1. 卡券管理 Cards.tsx 完整迁移原版功能:
   - API类型:URL、请求方法、超时、请求头、请求参数(支持变量插入)
   - 固定文字类型
   - 批量数据类型
   - 图片类型(上传功能)
   - 延时发货时间
   - 备注信息(支持变量)
   - 多规格设置

2. 修复移动端侧边栏 Sidebar.tsx:
   - 抽屉打开时显示文字标签
   - 修复 header、nav、admin section 在移动端的显示

3. 设置页面 AI 测试功能:
   - 添加账号选择器
   - 修改 testAIConnection API 支持指定账号测试

4. 其他修复:
   - 卡券 API cards.ts 完整定义 CardData 类型
   - 修复 vite 代理配置
This commit is contained in:
lingxiaotian 2025-11-29 18:31:48 +08:00
parent 02dea67e41
commit 6e0c1f7fc9
32 changed files with 2182 additions and 580 deletions

View File

@ -90,13 +90,14 @@ docker-compose.override.yml
config.*.yml
!global_config.yml
# 前端相关
node_modules/
# 前端相关(保留源码用于构建,排除依赖和构建产物)
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/coverage/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
yarn.lock
# 数据目录(运行时挂载)
data/

View File

@ -11,10 +11,23 @@ ENV PYTHONUNBUFFERED=1 \
# 设置工作目录
WORKDIR /app
# Builder stage: install Python dependencies
FROM base AS builder
# ==================== Frontend Builder Stage ====================
FROM node:20-alpine AS frontend-builder
# 项目已完全开源,简化构建流程
WORKDIR /frontend
# 复制前端依赖文件
COPY frontend/package.json frontend/pnpm-lock.yaml ./
# 安装 pnpm 并安装依赖
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# 复制前端源码并构建
COPY frontend/ ./
RUN pnpm build
# ==================== Python Builder Stage ====================
FROM base AS builder
# 安装基础依赖
RUN apt-get update && \
@ -32,9 +45,12 @@ ENV PATH="$VIRTUAL_ENV/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bi
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目文件
# 复制项目文件(排除 frontend 目录)
COPY . .
# 复制前端构建产物到 static 目录
COPY --from=frontend-builder /frontend/dist ./static
# 项目已完全开源,无需编译二进制模块
# Runtime stage: only keep what is needed to run the app

View File

@ -19,7 +19,25 @@ RUN if [ -f /etc/apt/sources.list.d/debian.sources ]; then \
sed -i 's/deb.debian.org/mirrors.ustc.edu.cn/g' /etc/apt/sources.list; \
fi
# Builder stage
# ==================== Frontend Builder Stage ====================
FROM node:20-alpine AS frontend-builder
WORKDIR /frontend
# 设置 npm 镜像
RUN npm config set registry https://registry.npmmirror.com
# 复制前端依赖文件
COPY frontend/package.json frontend/pnpm-lock.yaml ./
# 安装 pnpm 并安装依赖
RUN npm install -g pnpm && pnpm install --frozen-lockfile
# 复制前端源码并构建
COPY frontend/ ./
RUN pnpm build
# ==================== Python Builder Stage ====================
FROM base AS builder
# 项目已完全开源,简化构建流程
@ -41,9 +59,12 @@ ENV PATH="$VIRTUAL_ENV/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bi
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制项目文件
# 复制项目文件(排除 frontend 目录)
COPY . .
# 复制前端构建产物到 static 目录
COPY --from=frontend-builder /frontend/dist ./static
# 项目已完全开源,无需编译二进制模块
# Runtime stage

View File

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

View File

@ -26,37 +26,33 @@ import { verifyToken } from '@/api/auth'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, setAuth, clearAuth, token: storeToken, _hasHydrated } = useAuthStore()
const [isChecking, setIsChecking] = useState(true)
const [isValid, setIsValid] = useState(false)
const hasCheckedRef = useRef(false)
const [authState, setAuthState] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const checkingRef = useRef(false)
useEffect(() => {
// 等待 zustand persist 完成 hydration
if (!_hasHydrated) return
if (!_hasHydrated) {
return
}
// 防止重复检查
if (hasCheckedRef.current) return
// 防止并发检查
if (checkingRef.current) {
return
}
const checkAuth = async () => {
checkingRef.current = true
// 优先使用 store 中的 token其次是 localStorage
const token = storeToken || localStorage.getItem('auth_token')
if (!token) {
setIsChecking(false)
setIsValid(false)
hasCheckedRef.current = true
setAuthState('unauthenticated')
checkingRef.current = false
return
}
// 如果 store 中已经认证且有用户信息,直接通过
if (isAuthenticated && storeToken) {
setIsChecking(false)
setIsValid(true)
hasCheckedRef.current = true
return
}
// 验证 token 有效性
// 验证 token 有效性(不再单纯相信本地 isAuthenticated 状态)
try {
const result = await verifyToken()
if (result.authenticated && result.user_id) {
@ -65,17 +61,16 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
username: result.username || '',
is_admin: result.is_admin || false,
})
setIsValid(true)
setAuthState('authenticated')
} else {
clearAuth()
setIsValid(false)
setAuthState('unauthenticated')
}
} catch {
clearAuth()
setIsValid(false)
setAuthState('unauthenticated')
} finally {
setIsChecking(false)
hasCheckedRef.current = true
checkingRef.current = false
}
}
@ -83,7 +78,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
}, [_hasHydrated, isAuthenticated, storeToken, setAuth, clearAuth])
// 等待 hydration 或检查完成
if (!_hasHydrated || isChecking) {
if (!_hasHydrated || authState === 'checking') {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
@ -91,7 +86,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
)
}
if (!isValid && !isAuthenticated) {
if (authState === 'unauthenticated') {
return <Navigate to="/login" replace />
}

View File

@ -96,16 +96,28 @@ export const generateQRLogin = (): Promise<{ success: boolean; session_id?: stri
}
// 检查扫码登录状态
export const checkQRLoginStatus = (sessionId: string): Promise<{
// 后端直接返回 { status: ..., message?: ..., account_info?: ... },没有 success 字段
export const checkQRLoginStatus = async (sessionId: string): Promise<{
success: boolean
status: 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'verification_required' | 'processing' | 'already_processed'
status: 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'verification_required' | 'processing' | 'already_processed' | 'error'
message?: string
account_info?: {
account_id: string
is_new_account: boolean
}
}> => {
return get(`/qr-login/check/${sessionId}`)
const result = await get<{
status: string
message?: string
account_info?: { account_id: string; is_new_account: boolean }
}>(`/qr-login/check/${sessionId}`)
// 后端没有返回 success 字段,根据 status 判断
return {
success: result.status !== 'error',
status: result.status as 'pending' | 'scanned' | 'success' | 'expired' | 'cancelled' | 'verification_required' | 'processing' | 'already_processed' | 'error',
message: result.message,
account_info: result.account_info,
}
}
// 检查密码登录状态
@ -117,3 +129,43 @@ export const checkPasswordLoginStatus = (sessionId: string): Promise<{
}> => {
return get(`/password-login/status/${sessionId}`)
}
// AI 回复设置接口 - 与后端 AIReplySettings 模型对应
export interface AIReplySettings {
ai_enabled: boolean
model_name?: string
api_key?: string
base_url?: string
max_discount_percent?: number
max_discount_amount?: number
max_bargain_rounds?: number
custom_prompts?: string
// 兼容旧字段(前端内部使用)
enabled?: boolean
}
// 获取AI回复设置
export const getAIReplySettings = (cookieId: string): Promise<AIReplySettings> => {
return get(`/ai-reply-settings/${cookieId}`)
}
// 更新AI回复设置
export const updateAIReplySettings = (cookieId: string, settings: Partial<AIReplySettings>): Promise<ApiResponse> => {
// 转换字段名以匹配后端
const payload: Record<string, unknown> = {
ai_enabled: settings.ai_enabled ?? settings.enabled ?? false,
model_name: settings.model_name ?? 'qwen-plus',
api_key: settings.api_key ?? '',
base_url: settings.base_url ?? 'https://dashscope.aliyuncs.com/compatible-mode/v1',
max_discount_percent: settings.max_discount_percent ?? 10,
max_discount_amount: settings.max_discount_amount ?? 100,
max_bargain_rounds: settings.max_bargain_rounds ?? 3,
custom_prompts: settings.custom_prompts ?? '',
}
return put(`/ai-reply-settings/${cookieId}`, payload)
}
// 获取所有账号的AI回复设置
export const getAllAIReplySettings = (): Promise<Record<string, AIReplySettings>> => {
return get('/ai-reply-settings')
}

View File

@ -64,7 +64,7 @@ export const getSystemLogs = async (params?: { page?: number; limit?: number; le
// 清空系统日志
export const clearSystemLogs = (): Promise<ApiResponse> => {
return del('/admin/logs')
return post('/logs/clear')
}
// ========== 风控日志 ==========
@ -106,16 +106,38 @@ export const getRiskLogs = async (params?: { page?: number; limit?: number; cook
return { success: true, data: logs, total: result.total }
}
// 清空风控日志
export const clearRiskLogs = (): Promise<ApiResponse> => {
return del('/admin/risk-control-logs')
// 清空风控日志 - 后端暂未实现批量删除接口
export const clearRiskLogs = async (): Promise<ApiResponse> => {
// 后端只有单条删除接口 DELETE /risk-control-logs/{log_id}
// 暂时返回提示信息
return { success: false, message: '后端暂未实现批量清空风控日志接口' }
}
// ========== 数据管理 ==========
// 导出数据
export const exportData = (type: string): Promise<Blob> => {
return get(`/admin/backup/download?type=${type}`, { responseType: 'blob' }) as Promise<Blob>
// 导出数据 - 后端只支持导出整个数据库
export const exportData = async (type: string): Promise<Blob> => {
// 后端 /admin/backup/download 不支持 type 参数,只能导出整个数据库
// 如果需要导出特定表数据,可以使用 /admin/data/{table_name} 获取后转换为 JSON
if (type === 'all') {
const token = localStorage.getItem('auth_token')
const response = await fetch(`/admin/backup/download?token=${token}`)
if (!response.ok) throw new Error('导出失败')
return response.blob()
}
// 导出特定表数据
const tableMap: Record<string, string> = {
accounts: 'cookies',
keywords: 'keywords',
items: 'item_info',
orders: 'orders',
cards: 'cards',
}
const tableName = tableMap[type] || type
const data = await get<{ data: unknown[] }>(`/admin/data/${tableName}`)
const jsonStr = JSON.stringify(data.data || [], null, 2)
return new Blob([jsonStr], { type: 'application/json' })
}
// 导入数据
@ -125,5 +147,19 @@ export const importData = (formData: FormData): Promise<ApiResponse> => {
// 清理数据
export const cleanupData = (type: string): Promise<ApiResponse> => {
return del(`/admin/data/${type}`)
// 清理类型映射表名
const tableMap: Record<string, string> = {
logs: 'logs',
orders: 'orders',
cards_used: 'cards',
all_data: 'all',
}
const tableName = tableMap[type] || type
// 如果是清空日志,使用通用接口
if (type === 'logs') {
return post('/logs/clear')
}
return del(`/admin/data/${tableName}`)
}

View File

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

View File

@ -36,9 +36,12 @@ export const updateItem = (cookieId: string, itemId: string, data: Partial<Item>
}
// 获取商品回复列表
export const getItemReplies = (cookieId?: string): Promise<{ success: boolean; data: ItemReply[] }> => {
export const getItemReplies = async (cookieId?: string): Promise<{ success: boolean; data: ItemReply[] }> => {
const params = cookieId ? `/cookie/${cookieId}` : ''
return get(`/itemReplays${params}`)
const result = await get<{ items?: ItemReply[] } | ItemReply[]>(`/itemReplays${params}`)
// 后端返回 { items: [...] } 格式
const items = Array.isArray(result) ? result : (result.items || [])
return { success: true, data: items }
}
// 添加商品回复

View File

@ -3,19 +3,86 @@ import type { ApiResponse, NotificationChannel, MessageNotification } from '@/ty
// ========== 通知渠道 ==========
// 后端返回的通知渠道格式
interface BackendChannel {
id: number
name: string
type: string
config: string
enabled: boolean
created_at?: string
updated_at?: string
}
// 将前端的 config 对象/字符串序列化为后端需要的字符串格式
const serializeChannelConfig = (config: NotificationChannel['config'] | unknown): string => {
if (typeof config === 'string') {
return config
}
if (config && typeof config === 'object') {
try {
return JSON.stringify(config)
} catch {
return '{}'
}
}
return '{}'
}
// 获取通知渠道列表
export const getNotificationChannels = (): Promise<{ success: boolean; data?: NotificationChannel[] }> => {
return get('/notification-channels')
export const getNotificationChannels = async (): Promise<{ success: boolean; data?: NotificationChannel[] }> => {
const result = await get<BackendChannel[]>('/notification-channels')
// 后端直接返回数组,需要转换格式
const channels: NotificationChannel[] = (result || []).map((item) => {
let parsedConfig: Record<string, unknown> | undefined
if (item.config) {
if (typeof item.config === 'string') {
try {
parsedConfig = JSON.parse(item.config)
} catch {
// 兼容旧数据或非法 JSON避免单条配置导致整个列表加载失败
parsedConfig = undefined
}
} else if (typeof item.config === 'object') {
parsedConfig = item.config as unknown as Record<string, unknown>
}
}
return {
id: String(item.id),
name: item.name,
type: item.type as NotificationChannel['type'],
config: parsedConfig,
enabled: item.enabled,
created_at: item.created_at,
updated_at: item.updated_at,
}
})
return { success: true, data: channels }
}
// 添加通知渠道
export const addNotificationChannel = (data: Partial<NotificationChannel>): Promise<ApiResponse> => {
return post('/notification-channels', data)
const payload = {
...data,
// 后端期望 config 为字符串
config: serializeChannelConfig(data.config),
}
return post('/notification-channels', payload)
}
// 更新通知渠道
export const updateNotificationChannel = (channelId: string, data: Partial<NotificationChannel>): Promise<ApiResponse> => {
return put(`/notification-channels/${channelId}`, data)
const payload: Record<string, unknown> = {
...data,
}
if ('config' in data) {
payload.config = serializeChannelConfig(data.config)
}
return put(`/notification-channels/${channelId}`, payload)
}
// 删除通知渠道
@ -30,20 +97,31 @@ export const testNotificationChannel = (channelId: string): Promise<ApiResponse>
// ========== 消息通知 ==========
// 后端返回格式: { cookie_id: [ { id, channel_id, enabled, channel_name, channel_type, channel_config } ] }
interface BackendNotification {
id: number
channel_id: number
enabled: boolean
channel_name?: string
channel_type?: string
channel_config?: string
}
// 获取所有消息通知配置
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
export const getMessageNotifications = async (): Promise<{ success: boolean; data?: MessageNotification[] }> => {
const result = await get<Record<string, Record<string, { enabled: boolean; channel_name?: string }>>>('/message-notifications')
const result = await get<Record<string, BackendNotification[]>>('/message-notifications')
// 将嵌套对象转换为数组
const notifications: MessageNotification[] = []
for (const [cookieId, channels] of Object.entries(result || {})) {
for (const [channelId, config] of Object.entries(channels || {})) {
notifications.push({
cookie_id: cookieId,
channel_id: Number(channelId),
channel_name: config.channel_name,
enabled: config.enabled,
})
for (const [cookieId, channelList] of Object.entries(result || {})) {
if (Array.isArray(channelList)) {
for (const item of channelList) {
notifications.push({
cookie_id: cookieId,
channel_id: item.channel_id,
channel_name: item.channel_name,
enabled: item.enabled,
})
}
}
}
return { success: true, data: notifications }

View File

@ -1,4 +1,4 @@
import { get, del, post } from '@/utils/request'
import { get } from '@/utils/request'
import type { Order, ApiResponse } from '@/types'
// 获取订单列表
@ -10,17 +10,20 @@ export const getOrders = (cookieId?: string, status?: string): Promise<{ success
return get(`/api/orders${queryString ? `?${queryString}` : ''}`)
}
// 删除订单
export const deleteOrder = (id: string): Promise<ApiResponse> => {
return del(`/api/orders/${id}`)
// 删除订单 - 后端暂未实现
export const deleteOrder = async (_id: string): Promise<ApiResponse> => {
// 后端暂未实现 DELETE /api/orders/{id} 接口
return { success: false, message: '后端暂未实现订单删除接口' }
}
// 批量删除订单
export const batchDeleteOrders = (ids: string[]): Promise<ApiResponse> => {
return post('/api/orders/batch-delete', { ids })
// 批量删除订单 - 后端暂未实现
export const batchDeleteOrders = async (_ids: string[]): Promise<ApiResponse> => {
// 后端暂未实现批量删除接口
return { success: false, message: '后端暂未实现批量删除订单接口' }
}
// 更新订单状态
export const updateOrderStatus = (id: string, status: string): Promise<ApiResponse> => {
return post(`/api/orders/${id}/status`, { status })
// 更新订单状态 - 后端暂未实现
export const updateOrderStatus = async (_id: string, _status: string): Promise<ApiResponse> => {
// 后端暂未实现订单状态更新接口
return { success: false, message: '后端暂未实现订单状态更新接口' }
}

View File

@ -1,11 +1,11 @@
import { get, put } from '@/utils/request'
import { get, put, post } from '@/utils/request'
import type { ApiResponse, SystemSettings } from '@/types'
// 获取系统设置
export const getSystemSettings = async (): Promise<{ success: boolean; data?: SystemSettings }> => {
const data = await get<Record<string, unknown>>('/system-settings')
// 将字符串 'true'/'false' 转换为布尔值
const booleanFields = ['registration_enabled', 'show_login_info', 'login_captcha_enabled', 'show_default_login']
const booleanFields = ['registration_enabled', 'show_default_login_info', 'login_captcha_enabled']
const converted: SystemSettings = {}
for (const [key, value] of Object.entries(data)) {
if (booleanFields.includes(key)) {
@ -41,11 +41,22 @@ export const updateAISettings = (data: Record<string, unknown>): Promise<ApiResp
return put('/ai-reply-settings', data)
}
// TODO: 测试 AI 连接需要指定 cookie_id后端接口为 POST /ai-reply-test/{cookie_id}
// 系统设置页面的测试按钮暂时无法使用,需要先选择账号
export const testAIConnection = async (): Promise<ApiResponse> => {
// 后端需要有效的 cookie_id这里返回提示信息
return { success: false, message: 'AI 测试需要先选择一个账号,请在账号管理页面的 AI 设置中测试' }
// 测试 AI 连接 - 需要指定 cookie_id
export const testAIConnection = async (cookieId?: string): Promise<ApiResponse> => {
if (!cookieId) {
return { success: false, message: '请先选择一个账号进行测试' }
}
try {
const result = await post<{ success?: boolean; message?: string; reply?: string }>(`/ai-reply-test/${cookieId}`, {
message: '你好,这是一条测试消息'
})
if (result.reply) {
return { success: true, message: `AI 回复: ${result.reply}` }
}
return { success: result.success ?? true, message: result.message || 'AI 连接测试成功' }
} catch (error) {
return { success: false, message: 'AI 连接测试失败' }
}
}
// 获取邮件设置
@ -66,3 +77,44 @@ export const updateEmailSettings = (data: Record<string, unknown>): Promise<ApiR
export const testEmailSend = async (_email: string): Promise<ApiResponse> => {
return { success: false, message: '邮件测试功能暂未实现,请检查 SMTP 配置后直接保存' }
}
// 修改密码
export const changePassword = async (data: { current_password: string; new_password: string }): Promise<ApiResponse> => {
return post('/change-password', data)
}
// 获取备份文件列表(管理员)
export const getBackupList = async (): Promise<{ backups: Array<{ filename: string; size: number; size_mb: number; modified_time: string }>; total: number }> => {
return get('/admin/backup/list')
}
// 下载数据库备份(管理员)
export const downloadDatabaseBackup = (): string => {
const token = localStorage.getItem('auth_token')
return `/admin/backup/download?token=${token}`
}
// 上传数据库备份(管理员)
export const uploadDatabaseBackup = async (file: File): Promise<ApiResponse> => {
const formData = new FormData()
formData.append('backup_file', file)
return post('/admin/backup/upload', formData)
}
// 刷新系统缓存
export const reloadSystemCache = async (): Promise<ApiResponse> => {
return post('/admin/reload-cache')
}
// 导出用户备份
export const exportUserBackup = (): string => {
const token = localStorage.getItem('auth_token')
return `/backup/export?token=${token}`
}
// 导入用户备份
export const importUserBackup = async (file: File): Promise<ApiResponse> => {
const formData = new FormData()
formData.append('file', file)
return post('/backup/import', formData)
}

View File

@ -11,36 +11,37 @@ const icons = {
}
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',
success: 'bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200',
error: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200',
warning: 'bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200',
info: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200',
}
const iconColors = {
success: 'text-emerald-500',
error: 'text-red-500',
warning: 'text-amber-500',
info: 'text-blue-500',
success: 'text-emerald-500 dark:text-emerald-400',
error: 'text-red-500 dark:text-red-400',
warning: 'text-amber-500 dark:text-amber-400',
info: 'text-blue-500 dark:text-blue-400',
}
export function Toast() {
const { toasts, removeToast } = useUIStore()
return (
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-3">
<div className="fixed top-20 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-3">
<AnimatePresence>
{toasts.map((toast) => {
const Icon = icons[toast.type]
return (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
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]',
'backdrop-blur-sm min-w-[280px] max-w-[400px]',
'bg-white/95 dark:bg-slate-800/95',
colors[toast.type]
)}
>
@ -48,7 +49,7 @@ export function Toast() {
<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"
className="p-1 hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
</button>

View File

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

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react'
import { NavLink } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
@ -19,6 +20,8 @@ import {
Info,
Menu,
X,
PanelLeftClose,
PanelLeft,
} from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { useUIStore } from '@/store/uiStore'
@ -59,7 +62,24 @@ const bottomNavItems: NavItem[] = [
export function Sidebar() {
const { user } = useAuthStore()
const { sidebarMobileOpen, setSidebarMobileOpen } = useUIStore()
const { sidebarCollapsed, sidebarMobileOpen, setSidebarMobileOpen, setSidebarCollapsed } = useUIStore()
// 监听窗口大小变化:
// <640px 抽屉(不依赖 collapsed640-1024px 自动收缩;>1024px 展开
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth
if (width >= 640 && width < 1024) {
setSidebarCollapsed(true)
} else if (width >= 1024) {
setSidebarCollapsed(false)
}
}
handleResize() // 初始化
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [setSidebarCollapsed])
const closeMobileSidebar = () => {
setSidebarMobileOpen(false)
@ -67,13 +87,17 @@ export function Sidebar() {
const NavItemComponent = ({ item }: { item: NavItem }) => {
const Icon = item.icon
// 移动端抽屉模式下,始终显示文字
const showLabel = sidebarMobileOpen || !sidebarCollapsed
return (
<NavLink
to={item.path}
onClick={closeMobileSidebar}
title={!showLabel ? item.label : undefined}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150',
!showLabel && 'justify-center px-2',
isActive
? 'bg-blue-600 text-white dark:text-white hover:text-white hover:bg-blue-700 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10'
@ -81,58 +105,69 @@ export function Sidebar() {
}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{item.label}</span>
{showLabel && <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}
/>
)}
{/* Mobile overlay - 点击关闭侧边栏(仅在 <640px 显示) */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: sidebarMobileOpen ? 1 : 0 }}
transition={{ duration: 0.2 }}
className={cn(
'fixed inset-0 bg-black/60 z-40 sm:hidden',
sidebarMobileOpen ? 'pointer-events-auto' : 'pointer-events-none'
)}
onClick={closeMobileSidebar}
/>
{/* Sidebar */}
<motion.aside
initial={false}
animate={{
x: sidebarMobileOpen ? 0 : undefined,
}}
className={cn(
'fixed top-0 left-0 h-screen w-56 z-50',
'fixed top-0 left-0 h-screen z-50',
'bg-white dark:bg-[#001529]',
'flex flex-col',
'transition-transform duration-200 ease-out',
'border-r border-slate-200 dark:border-slate-700',
'lg:translate-x-0',
!sidebarMobileOpen && '-translate-x-full lg:translate-x-0'
// <640px 抽屉:根据 sidebarMobileOpen 控制显隐;>=640px 常驻
sidebarMobileOpen ? 'translate-x-0' : '-translate-x-full sm:translate-x-0',
// 宽度:移动端抽屉 288px桌面根据收缩状态
sidebarMobileOpen ? 'w-72' : sidebarCollapsed ? 'w-16' : 'w-56'
)}
>
{/* Header */}
<div className="h-14 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700">
<div className={cn(
'h-14 flex items-center border-b border-slate-200 dark:border-slate-700',
(!sidebarMobileOpen && sidebarCollapsed) ? 'justify-center px-2' : 'justify-between px-4'
)}>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center flex-shrink-0">
<MessageSquare className="w-4 h-4 text-white" />
</div>
<span className="font-semibold text-sm text-slate-900 dark:text-white"></span>
{(sidebarMobileOpen || !sidebarCollapsed) && (
<span className="font-semibold text-sm text-slate-900 dark:text-white whitespace-nowrap"></span>
)}
</div>
<button
onClick={closeMobileSidebar}
className="lg:hidden p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
<X className="w-4 h-4" />
</button>
{/* 移动端抽屉打开时显示关闭按钮 */}
{sidebarMobileOpen && (
<button
onClick={closeMobileSidebar}
className="sm:hidden p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-0.5 sidebar-scrollbar">
<nav className={cn(
'flex-1 overflow-y-auto py-3 space-y-0.5 sidebar-scrollbar',
(!sidebarMobileOpen && sidebarCollapsed) ? 'px-1.5' : 'px-2'
)}>
{mainNavItems.map((item) => (
<NavItemComponent key={item.path} item={item} />
))}
@ -140,42 +175,65 @@ export function Sidebar() {
{/* Admin section */}
{user?.is_admin && (
<>
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-slate-400 dark:text-gray-500 uppercase tracking-wider">
</p>
</div>
{(sidebarMobileOpen || !sidebarCollapsed) && (
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-slate-400 dark:text-gray-500 uppercase tracking-wider">
</p>
</div>
)}
{(!sidebarMobileOpen && sidebarCollapsed) && <div className="pt-2 border-t border-slate-200 dark:border-slate-700 mt-2" />}
{adminNavItems.map((item) => (
<NavItemComponent key={item.path} item={item} />
))}
</>
)}
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
</p>
</div>
{(sidebarMobileOpen || !sidebarCollapsed) && (
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
</p>
</div>
)}
{(!sidebarMobileOpen && sidebarCollapsed) && <div className="pt-2 border-t border-slate-200 dark:border-slate-700 mt-2" />}
{bottomNavItems.map((item) => (
<NavItemComponent key={item.path} item={item} />
))}
</nav>
{/* Collapse toggle button - 只在 lg 以上显示 */}
<div className="hidden lg:flex items-center justify-center p-2 border-t border-slate-200 dark:border-slate-700">
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-white/10 transition-colors text-slate-400 hover:text-slate-600 dark:hover:text-white"
title={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{sidebarCollapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
</motion.aside>
{/* Mobile toggle button */}
<button
{/* Mobile toggle button - 只在 <640px 且侧边栏关闭时显示 */}
<motion.button
initial={{ opacity: 0, scale: 0.9 }}
animate={{
opacity: sidebarMobileOpen ? 0 : 1,
scale: sidebarMobileOpen ? 0.9 : 1
}}
transition={{ duration: 0.15 }}
onClick={() => setSidebarMobileOpen(true)}
className={cn(
'fixed top-3 left-3 z-30 lg:hidden',
'w-10 h-10 rounded-md',
'fixed top-2.5 left-2.5 z-50 sm:hidden',
'w-8 h-8 rounded-md',
'bg-blue-500 text-white shadow-md',
'flex items-center justify-center',
'hover:bg-blue-600 transition-colors'
'hover:bg-blue-600 active:scale-95 transition-all',
sidebarMobileOpen && 'pointer-events-none'
)}
>
<Menu className="w-5 h-5" />
</button>
<Menu className="w-4 h-4" />
</motion.button>
</>
)
}

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { X, Home } from 'lucide-react'
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { cn } from '@/utils/cn'
interface Tab {
@ -39,34 +40,42 @@ const routeTitles: Record<string, string> = {
'/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 })
export const useTabsStore = create<TabsStore>()(
persist(
(set, get) => ({
tabs: [{ path: '/dashboard', title: '仪表盘', closable: false }],
activeTab: '/dashboard',
addTab: (tab) => {
const { tabs } = get()
const exists = tabs.find(t => t.path === tab.path)
if (!exists) {
set({ tabs: [...tabs, tab], activeTab: tab.path })
} else {
set({ activeTab: tab.path })
}
},
removeTab: (path) => {
const { tabs, activeTab } = get()
const newTabs = tabs.filter(t => t.path !== path)
// 如果关闭的是当前标签,切换到最后一个标签
if (activeTab === path && newTabs.length > 0) {
set({ tabs: newTabs, activeTab: newTabs[newTabs.length - 1].path })
} else {
set({ tabs: newTabs })
}
},
setActiveTab: (path) => set({ activeTab: path }),
}),
{
name: 'tabs-storage',
storage: createJSONStorage(() => localStorage),
}
},
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()
@ -106,27 +115,30 @@ export function TabsBar() {
}
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 className="tabs-bar overflow-x-auto scrollbar-hide">
<div className="flex min-w-max">
{tabs.map((tab) => (
<div
key={tab.path}
onClick={() => handleTabClick(tab.path)}
className={cn(
activeTab === tab.path ? 'tab-item-active' : 'tab-item',
'whitespace-nowrap flex-shrink-0'
)}
>
{tab.path === '/dashboard' && <Home className="w-3.5 h-3.5" />}
<span className="text-xs sm:text-sm">{tab.title}</span>
{tab.closable && (
<button
onClick={(e) => handleTabClose(e, tab.path)}
className="tab-close"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
</div>
)
}

View File

@ -34,13 +34,14 @@ export function TopNavbar() {
return (
<div className="top-navbar">
{/* 左侧 - 面包屑或标题 */}
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500 dark:text-slate-400">使</span>
{/* 左侧 - 面包屑或标题(<640px 留空间给汉堡菜单) */}
<div className="flex items-center gap-2 ml-12 sm:ml-0">
<span className="text-sm text-slate-500 dark:text-slate-400 hidden sm:inline">使</span>
<span className="text-sm text-slate-500 dark:text-slate-400 sm:hidden"></span>
</div>
{/* 右侧 - 工具栏 */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 sm:gap-2">
{/* 主题切换 */}
<button
onClick={toggleTheme}
@ -57,7 +58,7 @@ export function TopNavbar() {
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center gap-2 px-3 py-1.5 rounded-md
className="flex items-center gap-1 sm:gap-2 px-2 sm:px-3 py-1.5 rounded-md
text-slate-700 dark:text-slate-200
hover:bg-slate-100 dark:hover:bg-slate-700
transition-colors duration-150"
@ -66,7 +67,7 @@ export function TopNavbar() {
{(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" />
<ChevronDown className="w-4 h-4 text-slate-400 hidden sm:block" />
</button>
{/* 下拉菜单 */}

View File

@ -1,8 +1,32 @@
import { useState } from 'react'
import { MessageSquare, Github, Heart, Code, MessageCircle, Users, UserCheck, Bot, Truck, Bell, BarChart3, X } from 'lucide-react'
import { useState, useEffect } from 'react'
import { MessageSquare, Github, Heart, Code, MessageCircle, Users, UserCheck, Bot, Truck, Bell, BarChart3, X, Globe } from 'lucide-react'
export function About() {
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [version, setVersion] = useState('v1.0.4')
const [totalUsers, setTotalUsers] = useState(0)
useEffect(() => {
// 获取版本信息
fetch('/static/version.txt')
.then(res => res.ok ? res.text() : null)
.then(text => {
if (text && text.trim().startsWith('v')) {
setVersion(text.trim())
}
})
.catch(() => {})
// 获取使用人数
fetch('/project-stats')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data?.total_users) {
setTotalUsers(data.total_users)
}
})
.catch(() => {})
}, [])
return (
<div className="max-w-5xl mx-auto space-y-4">
@ -19,6 +43,19 @@ export function About() {
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
</p>
{/* 版本和使用人数 */}
<div className="flex items-center justify-center gap-3 mt-3">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gradient-to-r from-emerald-500/10 to-teal-500/10 text-emerald-600 dark:from-emerald-500/20 dark:to-teal-500/20 dark:text-emerald-400 border border-emerald-200/50 dark:border-emerald-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span>{version}</span>
</div>
{totalUsers > 0 && (
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-gradient-to-r from-blue-500/10 to-cyan-500/10 text-blue-600 dark:from-blue-500/20 dark:to-cyan-500/20 dark:text-blue-400 border border-blue-200/50 dark:border-blue-500/30">
<Globe className="w-3.5 h-3.5" />
<span>{totalUsers.toLocaleString()} 使</span>
</div>
)}
</div>
</div>
{/* Contact Groups */}

View File

@ -1,21 +1,32 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { FormEvent } from 'react'
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2, Copy } from 'lucide-react'
import { getAccountDetails, deleteAccount, updateAccountCookie, updateAccountStatus, updateAccountRemark, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2, Clock, CheckCircle, MessageSquare, Bot } from 'lucide-react'
import { getAccountDetails, deleteAccount, updateAccountCookie, updateAccountStatus, updateAccountRemark, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin, updateAccountAutoConfirm, updateAccountPauseDuration, getAllAIReplySettings, updateAIReplySettings, type AIReplySettings } from '@/api/accounts'
import { getKeywords, getDefaultReply, updateDefaultReply } from '@/api/keywords'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { AccountDetail } from '@/types'
type ModalType = 'qrcode' | 'password' | 'manual' | 'edit' | null
type ModalType = 'qrcode' | 'password' | 'manual' | 'edit' | 'default-reply' | null
interface AccountWithKeywordCount extends AccountDetail {
keywordCount?: number
aiEnabled?: boolean
}
export function Accounts() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [accounts, setAccounts] = useState<AccountDetail[]>([])
const [accounts, setAccounts] = useState<AccountWithKeywordCount[]>([])
const [activeModal, setActiveModal] = useState<ModalType>(null)
// 默认回复管理状态
const [defaultReplyAccount, setDefaultReplyAccount] = useState<AccountWithKeywordCount | null>(null)
const [defaultReplyContent, setDefaultReplyContent] = useState('')
const [defaultReplySaving, setDefaultReplySaving] = useState(false)
// 扫码登录状态
const [qrCodeUrl, setQrCodeUrl] = useState('')
const [, setQrSessionId] = useState('')
@ -37,6 +48,8 @@ export function Accounts() {
const [editingAccount, setEditingAccount] = useState<AccountDetail | null>(null)
const [editNote, setEditNote] = useState('')
const [editCookie, setEditCookie] = useState('')
const [editAutoConfirm, setEditAutoConfirm] = useState(false)
const [editPauseDuration, setEditPauseDuration] = useState(0)
const [editSaving, setEditSaving] = useState(false)
const loadAccounts = async () => {
@ -44,7 +57,36 @@ export function Accounts() {
try {
setLoading(true)
const data = await getAccountDetails()
setAccounts(data)
// 获取所有账号的AI回复设置
let aiSettings: Record<string, AIReplySettings> = {}
try {
aiSettings = await getAllAIReplySettings()
} catch {
// ignore
}
// 为每个账号获取关键词数量
const accountsWithKeywords = await Promise.all(
data.map(async (account) => {
try {
const keywords = await getKeywords(account.id)
return {
...account,
keywordCount: keywords.length,
aiEnabled: aiSettings[account.id]?.enabled || false
}
} catch {
return {
...account,
keywordCount: 0,
aiEnabled: aiSettings[account.id]?.enabled || false
}
}
})
)
setAccounts(accountsWithKeywords)
} catch {
addToast({ type: 'error', message: '加载账号列表失败' })
} finally {
@ -261,6 +303,8 @@ export function Accounts() {
setEditingAccount(account)
setEditNote(account.note || '')
setEditCookie(account.cookie || '')
setEditAutoConfirm(account.auto_confirm || false)
setEditPauseDuration(account.pause_duration || 0)
setActiveModal('edit')
}
@ -282,6 +326,16 @@ export function Accounts() {
if (editCookie.trim() && editCookie.trim() !== editingAccount.cookie) {
promises.push(updateAccountCookie(editingAccount.id, editCookie.trim()))
}
// 更新自动确认发货
if (editAutoConfirm !== (editingAccount.auto_confirm || false)) {
promises.push(updateAccountAutoConfirm(editingAccount.id, editAutoConfirm))
}
// 更新暂停时间
if (editPauseDuration !== (editingAccount.pause_duration || 0)) {
promises.push(updateAccountPauseDuration(editingAccount.id, editPauseDuration))
}
await Promise.all(promises)
addToast({ type: 'success', message: '账号信息已更新' })
@ -294,6 +348,50 @@ export function Accounts() {
}
}
// ==================== 默认回复管理 ====================
const openDefaultReplyModal = async (account: AccountWithKeywordCount) => {
setDefaultReplyAccount(account)
setDefaultReplyContent('')
setActiveModal('default-reply')
// 加载当前默认回复
try {
const result = await getDefaultReply(account.id)
setDefaultReplyContent(result.default_reply || '')
} catch {
// ignore
}
}
const handleSaveDefaultReply = async () => {
if (!defaultReplyAccount) return
try {
setDefaultReplySaving(true)
await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent)
addToast({ type: 'success', message: '默认回复已保存' })
closeModal()
} catch {
addToast({ type: 'error', message: '保存失败' })
} finally {
setDefaultReplySaving(false)
}
}
// ==================== AI回复开关 ====================
const handleToggleAI = async (account: AccountWithKeywordCount) => {
const newEnabled = !account.aiEnabled
try {
await updateAIReplySettings(account.id, { enabled: newEnabled })
setAccounts(prev => prev.map(a =>
a.id === account.id ? { ...a, aiEnabled: newEnabled } : a
))
addToast({ type: 'success', message: `AI回复已${newEnabled ? '开启' : '关闭'}` })
} catch {
addToast({ type: 'error', message: '操作失败' })
}
}
// 组件卸载时清理
useEffect(() => {
return () => clearQrCheck()
@ -386,18 +484,18 @@ export function Accounts() {
<thead>
<tr>
<th>ID</th>
<th>Cookie</th>
<th></th>
<th></th>
<th>AI回复</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr>
<td colSpan={7}>
<td colSpan={8}>
<div className="empty-state py-8">
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
@ -408,23 +506,11 @@ export function Accounts() {
<tr key={account.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
<td>
<div className="flex items-center gap-2">
<code className="text-xs font-mono bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded text-slate-600 dark:text-slate-300 max-w-[180px] truncate block">
{account.cookie ? account.cookie.substring(0, 30) + '...' : '无'}
</code>
{account.cookie && (
<button
onClick={() => {
navigator.clipboard.writeText(account.cookie || '')
addToast({ type: 'success', message: 'Cookie已复制' })
}}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
title="复制Cookie"
>
<Copy className="w-3.5 h-3.5 text-slate-400 hover:text-blue-500" />
</button>
)}
</div>
<span className="inline-flex items-center gap-1.5 text-sm">
<MessageSquare className="w-3.5 h-3.5 text-blue-500" />
<span className="font-medium">{account.keywordCount || 0}</span>
<span className="text-slate-400"></span>
</span>
</td>
<td>
<span className={`inline-flex items-center gap-1.5 ${account.enabled !== false ? 'text-green-600' : 'text-gray-400'}`}>
@ -433,20 +519,40 @@ export function Accounts() {
</span>
</td>
<td>
<span className={account.use_ai_reply ? 'badge-success' : 'badge-gray'}>
{account.use_ai_reply ? '开启' : '关闭'}
<button
onClick={() => handleToggleAI(account)}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors ${
account.aiEnabled
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-900/50'
: 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 hover:bg-slate-200 dark:hover:bg-slate-600'
}`}
title={account.aiEnabled ? '点击关闭AI回复' : '点击开启AI回复'}
>
<Bot className="w-3.5 h-3.5" />
{account.aiEnabled ? '已开启' : '已关闭'}
</button>
</td>
<td>
<span className={account.auto_confirm ? 'badge-success' : 'badge-gray'}>
{account.auto_confirm ? '开启' : '关闭'}
</span>
</td>
<td>
<span className={account.use_default_reply ? 'badge-success' : 'badge-gray'}>
{account.use_default_reply ? '开启' : '关闭'}
<span className="text-slate-600 dark:text-slate-300 text-sm">
<Clock className="w-3.5 h-3.5 inline mr-1" />
{account.pause_duration || 0}
</span>
</td>
<td className="text-gray-500 max-w-[80px] truncate">
{account.note || '-'}
</td>
<td>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 flex-wrap">
<button
onClick={() => openDefaultReplyModal(account)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-green-50 dark:hover:bg-green-900/30 transition-colors"
title="默认回复"
>
<MessageSquare className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-600 dark:text-green-400"></span>
</button>
<button
onClick={() => handleToggleEnabled(account)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
@ -704,14 +810,58 @@ export function Accounts() {
<textarea
value={editCookie}
onChange={(e) => setEditCookie(e.target.value)}
className="input-ios h-28 resize-none font-mono text-xs"
className="input-ios h-20 resize-none font-mono text-xs"
placeholder="更新Cookie值"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Cookie长度: {editCookie.length}
</p>
</div>
{/* AI回复和默认回复设置请在"自动回复"页面配置 */}
{/* 自动确认发货 */}
<div className="flex items-center justify-between py-3 border-t border-slate-100 dark:border-slate-700">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
</p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
</div>
<button
type="button"
onClick={() => setEditAutoConfirm(!editAutoConfirm)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editAutoConfirm ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
editAutoConfirm ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* 暂停时间 */}
<div className="input-group">
<label className="input-label flex items-center gap-2">
<Clock className="w-4 h-4 text-amber-500" />
</label>
<input
type="number"
min="0"
max="1440"
value={editPauseDuration}
onChange={(e) => setEditPauseDuration(parseInt(e.target.value) || 0)}
className="input-ios"
placeholder="0"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
0
</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 pt-2">
AI回复和默认回复设置请在"自动回复"
</p>
@ -735,6 +885,66 @@ export function Accounts() {
</div>
</div>
)}
{/* 默认回复管理弹窗 */}
{activeModal === 'default-reply' && defaultReplyAccount && (
<div className="modal-overlay">
<div className="modal-content max-w-lg">
<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 space-y-4">
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={defaultReplyAccount.id}
disabled
className="input-ios bg-slate-100 dark:bg-slate-700"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<textarea
value={defaultReplyContent}
onChange={(e) => setDefaultReplyContent(e.target.value)}
className="input-ios h-32 resize-none"
placeholder="输入默认回复内容,留空表示不使用默认回复"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
使
</p>
</div>
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p className="text-xs text-blue-600 dark:text-blue-400">
<strong></strong><br />
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_user_name}'}</code> - <br />
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_user_id}'}</code> - ID<br />
<code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_message}'}</code> -
</p>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={defaultReplySaving}>
</button>
<button onClick={handleSaveDefaultReply} className="btn-ios-primary" disabled={defaultReplySaving}>
{defaultReplySaving ? (
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'保存'
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -257,7 +257,7 @@ export function Login() {
</motion.div>
{/* Right side - Login form */}
<div className="flex-1 flex items-center justify-center p-6">
<div className="flex-1 flex items-center justify-center p-4 sm:p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -278,14 +278,14 @@ export function Login() {
</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="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-5 sm:p-8">
<div className="mb-6">
<h2 className="text-xl vben-card-title text-slate-900 dark:text-white"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
{/* Login type tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-6">
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-4 sm:mb-6 overflow-x-auto scrollbar-hide">
{[
{ type: 'username' as const, label: '账号登录' },
{ type: 'email-password' as const, label: '邮箱密码' },
@ -295,7 +295,7 @@ export function Login() {
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',
'px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap flex-shrink-0',
loginType === tab.type
? 'text-blue-600 dark:text-blue-400 border-blue-600 dark:border-blue-400'
: 'text-slate-500 dark:text-slate-400 border-transparent hover:text-slate-700 dark:hover:text-slate-300'
@ -306,7 +306,7 @@ export function Login() {
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* Username login */}
{loginType === 'username' && (
<>

View File

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

View File

@ -16,6 +16,7 @@ interface DashboardStats {
totalOrders: number
}
export function Dashboard() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
@ -74,6 +75,7 @@ export function Dashboard() {
// ignore
}
setStats({
totalAccounts: accountsWithKeywords.length,
totalKeywords,
@ -133,21 +135,22 @@ export function Dashboard() {
}
return (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{/* Page header */}
<div className="page-header flex-between">
<div className="page-header flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<button onClick={loadDashboard} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</button>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-2 sm:gap-4">
{statCards.map((card, index) => {
const Icon = card.icon
return (

View File

@ -1,7 +1,7 @@
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 { MessageSquare, RefreshCw, Plus, Edit2, Trash2, Upload, Download, Info } 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'
@ -39,8 +39,10 @@ export function Keywords() {
try {
setLoading(true)
const data = await getKeywords(selectedAccount)
setKeywords(data)
// 确保 data 是数组,防止后端返回非数组或请求失败时出错
setKeywords(Array.isArray(data) ? data : [])
} catch {
setKeywords([])
addToast({ type: 'error', message: '加载关键词列表失败' })
} finally {
setLoading(false)
@ -52,13 +54,20 @@ export function Keywords() {
return
}
try {
setLoading(true)
const data = await getAccounts()
setAccounts(data)
if (data.length > 0 && !selectedAccount) {
setSelectedAccount(data[0].id)
if (data.length > 0) {
if (!selectedAccount) {
setSelectedAccount(data[0].id)
}
} else {
setSelectedAccount('')
}
} catch {
// ignore
} finally {
setLoading(false)
}
}
@ -289,6 +298,28 @@ export function Keywords() {
</div>
</motion.div>
{/* 变量提示说明 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className="vben-card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
>
<div className="vben-card-body py-3">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-blue-700 dark:text-blue-300 mb-1"></p>
<div className="text-blue-600 dark:text-blue-400 space-y-0.5">
<p><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_user_name}'}</code> - </p>
<p><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_user_id}'}</code> - ID</p>
<p><code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{send_message}'}</code> - </p>
</div>
</div>
</div>
</div>
</motion.div>
{/* Keywords List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -416,8 +447,11 @@ export function Keywords() {
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
className="input-ios h-28 resize-none"
placeholder="请输入自动回复内容"
placeholder="请输入自动回复内容,留空表示不回复"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
</p>
</div>
<div className="flex items-center justify-between pt-2">
<div>

View File

@ -32,14 +32,24 @@ export function NotificationChannels() {
const [saving, setSaving] = useState(false)
const loadChannels = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
if (!_hasHydrated || !isAuthenticated || !token) {
setLoading(false)
return
}
try {
setLoading(true)
const result = await getNotificationChannels()
if (result.success) {
setChannels(result.data || [])
}
} catch {
} catch (err) {
// 401 错误由 axios 拦截器处理,不需要重复提示
if (err && typeof err === 'object' && 'response' in err) {
const axiosErr = err as { response?: { status?: number } }
if (axiosErr.response?.status === 401) {
return
}
}
addToast({ type: 'error', message: '加载通知渠道失败' })
} finally {
setLoading(false)
@ -47,13 +57,24 @@ export function NotificationChannels() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
// 只有在完全就绪时才加载数据
if (!_hasHydrated) return
if (!isAuthenticated || !token) {
setLoading(false)
return
}
loadChannels()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_hasHydrated, isAuthenticated, token])
const handleToggleEnabled = async (channel: NotificationChannel) => {
try {
await updateNotificationChannel(channel.id, { enabled: !channel.enabled })
// 后端更新接口要求同时提供 name 和 config
await updateNotificationChannel(channel.id, {
name: channel.name,
config: channel.config,
enabled: !channel.enabled,
})
addToast({ type: 'success', message: channel.enabled ? '渠道已禁用' : '渠道已启用' })
loadChannels()
} catch {

View File

@ -90,14 +90,14 @@ export function Orders() {
}
return (
<div className="space-y-4">
<div className="space-y-3 sm:space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<button onClick={loadOrders} className="btn-ios-secondary ">
<button onClick={loadOrders} className="btn-ios-secondary w-full sm:w-auto">
<RefreshCw className="w-4 h-4" />
</button>
@ -110,7 +110,7 @@ export function Orders() {
className="vben-card"
>
<div className="vben-card-body">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div className="input-group">
<label className="input-label"></label>
<Select

View File

@ -1,17 +1,36 @@
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 { useState, useEffect, useRef } from 'react'
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw, Key, Database, Download, Upload, Archive } from 'lucide-react'
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend, changePassword, downloadDatabaseBackup, uploadDatabaseBackup, reloadSystemCache, exportUserBackup, importUserBackup } from '@/api/settings'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
import type { SystemSettings } from '@/types'
import { Select } from '@/components/common/Select'
import type { SystemSettings, Account } from '@/types'
export function Settings() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const { isAuthenticated, token, _hasHydrated, user } = useAuthStore()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState<SystemSettings | null>(null)
// 密码修改状态
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [changingPassword, setChangingPassword] = useState(false)
// 备份管理状态
const [uploadingBackup, setUploadingBackup] = useState(false)
const [reloadingCache, setReloadingCache] = useState(false)
const backupFileRef = useRef<HTMLInputElement>(null)
const userBackupFileRef = useRef<HTMLInputElement>(null)
// AI 测试账号选择
const [accounts, setAccounts] = useState<Account[]>([])
const [testAccountId, setTestAccountId] = useState('')
const [testingAI, setTestingAI] = useState(false)
const loadSettings = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
@ -50,16 +69,42 @@ export function Settings() {
}
}
const handleTestAI = async () => {
// 加载账号列表
const loadAccounts = async () => {
try {
const result = await testAIConnection()
const data = await getAccounts()
setAccounts(data)
if (data.length > 0 && !testAccountId) {
setTestAccountId(data[0].id)
}
} catch {
// ignore
}
}
useEffect(() => {
if (_hasHydrated && isAuthenticated && token) {
loadAccounts()
}
}, [_hasHydrated, isAuthenticated, token])
const handleTestAI = async () => {
if (!testAccountId) {
addToast({ type: 'warning', message: '请先选择一个账号' })
return
}
setTestingAI(true)
try {
const result = await testAIConnection(testAccountId)
if (result.success) {
addToast({ type: 'success', message: 'AI 连接测试成功' })
addToast({ type: 'success', message: result.message || 'AI 连接测试成功' })
} else {
addToast({ type: 'error', message: result.message || 'AI 连接测试失败' })
}
} catch {
addToast({ type: 'error', message: 'AI 连接测试失败' })
} finally {
setTestingAI(false)
}
}
@ -78,6 +123,121 @@ export function Settings() {
}
}
// 修改密码
const handleChangePassword = async () => {
if (!currentPassword) {
addToast({ type: 'warning', message: '请输入当前密码' })
return
}
if (!newPassword) {
addToast({ type: 'warning', message: '请输入新密码' })
return
}
if (newPassword !== confirmPassword) {
addToast({ type: 'warning', message: '两次输入的密码不一致' })
return
}
if (newPassword.length < 6) {
addToast({ type: 'warning', message: '新密码长度不能少于6位' })
return
}
try {
setChangingPassword(true)
const result = await changePassword({ current_password: currentPassword, new_password: newPassword })
if (result.success) {
addToast({ type: 'success', message: '密码修改成功' })
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
} else {
addToast({ type: 'error', message: result.message || '密码修改失败' })
}
} catch {
addToast({ type: 'error', message: '密码修改失败' })
} finally {
setChangingPassword(false)
}
}
// 下载数据库备份(管理员)
const handleDownloadBackup = () => {
const url = downloadDatabaseBackup()
window.open(url, '_blank')
}
// 上传数据库备份(管理员)
const handleUploadBackup = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.name.endsWith('.db')) {
addToast({ type: 'error', message: '只支持 .db 格式的数据库文件' })
return
}
if (!confirm('警告:恢复数据库将覆盖所有当前数据!确定要继续吗?')) {
e.target.value = ''
return
}
try {
setUploadingBackup(true)
const result = await uploadDatabaseBackup(file)
if (result.success) {
addToast({ type: 'success', message: '数据库恢复成功' })
} else {
addToast({ type: 'error', message: result.message || '数据库恢复失败' })
}
} catch {
addToast({ type: 'error', message: '数据库恢复失败' })
} finally {
setUploadingBackup(false)
e.target.value = ''
}
}
// 刷新系统缓存
const handleReloadCache = async () => {
try {
setReloadingCache(true)
const result = await reloadSystemCache()
if (result.success) {
addToast({ type: 'success', message: '系统缓存已刷新' })
} else {
addToast({ type: 'error', message: result.message || '刷新缓存失败' })
}
} catch {
addToast({ type: 'error', message: '刷新缓存失败' })
} finally {
setReloadingCache(false)
}
}
// 导出用户备份
const handleExportUserBackup = () => {
const url = exportUserBackup()
window.open(url, '_blank')
}
// 导入用户备份
const handleImportUserBackup = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.name.endsWith('.json')) {
addToast({ type: 'error', message: '只支持 .json 格式的备份文件' })
return
}
try {
const result = await importUserBackup(file)
if (result.success) {
addToast({ type: 'success', message: '备份导入成功' })
} else {
addToast({ type: 'error', message: result.message || '备份导入失败' })
}
} catch {
addToast({ type: 'error', message: '备份导入失败' })
} finally {
e.target.value = ''
}
}
if (loading) {
return <PageLoading />
}
@ -138,8 +298,8 @@ export function Settings() {
<label className="switch-ios">
<input
type="checkbox"
checked={settings?.show_login_info ?? true}
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
checked={settings?.show_default_login_info ?? true}
onChange={(e) => setSettings(s => s ? { ...s, show_default_login_info: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
@ -186,9 +346,24 @@ export function Settings() {
className="input-ios"
/>
</div>
<button onClick={handleTestAI} className="btn-ios-secondary">
AI
</button>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="input-label"></label>
<Select
value={testAccountId}
onChange={setTestAccountId}
options={accounts.map(a => ({ value: a.id, label: a.id }))}
placeholder="选择账号"
/>
</div>
<button
onClick={handleTestAI}
className="btn-ios-secondary"
disabled={testingAI || !testAccountId}
>
{testingAI ? '测试中...' : '测试 AI 连接'}
</button>
</div>
</div>
</div>
</div>
@ -281,6 +456,159 @@ export function Settings() {
</div>
</div>
</div>
{/* 密码修改 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Key className="w-4 h-4 text-purple-500" />
</h2>
</div>
<div className="vben-card-body">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="input-group">
<label className="input-label"></label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="请输入当前密码"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
className="input-ios"
/>
</div>
</div>
<div className="mt-4">
<button
onClick={handleChangePassword}
disabled={changingPassword}
className="btn-ios-primary"
>
{changingPassword ? <ButtonLoading /> : <Key className="w-4 h-4" />}
</button>
</div>
</div>
</div>
{/* 数据备份 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Archive className="w-4 h-4 text-blue-500" />
</h2>
</div>
<div className="vben-card-body">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 用户数据备份 */}
<div className="relative overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-slate-800 dark:to-slate-800">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-100 dark:bg-blue-900/20 rounded-full -translate-y-1/2 translate-x-1/2 opacity-50"></div>
<div className="relative p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center shadow-lg shadow-blue-500/30">
<Download className="w-6 h-6 text-white" />
</div>
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100"></h3>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<button onClick={handleExportUserBackup} className="btn-ios-primary">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
<Upload className="w-4 h-4" />
<input
ref={userBackupFileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportUserBackup}
/>
</label>
</div>
</div>
</div>
{/* 管理员数据库备份 */}
{user?.is_admin && (
<div className="relative overflow-hidden rounded-xl border border-red-200 dark:border-red-800 bg-gradient-to-br from-red-50 to-rose-50 dark:from-red-900/20 dark:to-rose-900/20">
<div className="absolute top-0 right-0 w-32 h-32 bg-red-100 dark:bg-red-900/30 rounded-full -translate-y-1/2 translate-x-1/2 opacity-50"></div>
<div className="relative p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-xl bg-red-500 flex items-center justify-center shadow-lg shadow-red-500/30">
<Database className="w-6 h-6 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-red-900 dark:text-red-100"></h3>
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full font-medium"></span>
</div>
<p className="text-sm text-red-700 dark:text-red-300"></p>
</div>
</div>
<div className="flex flex-wrap gap-3 mb-4">
<button onClick={handleDownloadBackup} className="btn-ios-primary bg-red-500 hover:bg-red-600 focus:ring-red-500">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
{uploadingBackup ? <ButtonLoading /> : <Upload className="w-4 h-4" />}
<input
ref={backupFileRef}
type="file"
accept=".db"
className="hidden"
onChange={handleUploadBackup}
disabled={uploadingBackup}
/>
</label>
<button
onClick={handleReloadCache}
disabled={reloadingCache}
className="btn-ios-secondary"
>
{reloadingCache ? <ButtonLoading /> : <RefreshCw className="w-4 h-4" />}
</button>
</div>
<div className="flex items-start gap-2 p-3 bg-red-100/50 dark:bg-red-900/30 rounded-lg">
<span className="text-red-600 dark:text-red-400 text-lg"></span>
<p className="text-xs text-red-700 dark:text-red-300">
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

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

View File

@ -13,6 +13,7 @@ interface UIState {
loading: boolean
toasts: Toast[]
toggleSidebar: () => void
setSidebarCollapsed: (collapsed: boolean) => void
setSidebarMobileOpen: (open: boolean) => void
setLoading: (loading: boolean) => void
addToast: (toast: Omit<Toast, 'id'>) => void
@ -29,6 +30,10 @@ export const useUIStore = create<UIState>((set) => ({
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed }))
},
setSidebarCollapsed: (collapsed) => {
set({ sidebarCollapsed: collapsed })
},
setSidebarMobileOpen: (open) => {
set({ sidebarMobileOpen: open })
},

View File

@ -613,6 +613,156 @@
padding-bottom: env(safe-area-inset-bottom);
}
/* 响应式工具类 */
.hide-mobile {
@apply hidden sm:block;
}
.show-mobile {
@apply block sm:hidden;
}
.hide-tablet {
@apply hidden md:block;
}
.show-tablet {
@apply block md:hidden;
}
/* 响应式文字 */
.text-responsive {
@apply text-sm sm:text-base;
}
.text-responsive-sm {
@apply text-xs sm:text-sm;
}
/* 响应式间距 */
.gap-responsive {
@apply gap-2 sm:gap-3 md:gap-4;
}
.p-responsive {
@apply p-3 sm:p-4 md:p-6;
}
/* 响应式表格 */
.table-responsive-wrapper {
@apply overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0;
}
/* 隐藏滚动条 */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* 移动端优化 */
@media (max-width: 640px) {
/* 卡片优化 */
.vben-card {
@apply rounded-lg border;
}
.vben-card-body {
@apply p-3;
}
.vben-card-header {
@apply px-3 py-2.5;
}
/* 模态框优化 */
.modal-content {
@apply max-w-[calc(100%-1rem)] mx-2 rounded-lg max-h-[90vh] overflow-y-auto;
}
/* 页面头部优化 */
.page-header {
@apply flex-col items-start gap-3;
}
.page-title {
@apply text-lg;
}
.page-description {
@apply text-xs;
}
/* 统计卡片优化 */
.stat-card {
@apply p-3 flex-row items-center gap-3;
}
.stat-icon {
@apply w-10 h-10;
}
.stat-value {
@apply text-xl;
}
.stat-label {
@apply text-xs;
}
/* 表格优化 */
.table-ios th,
.table-ios td {
@apply px-2 py-2 text-xs;
}
.table-ios th {
@apply text-[10px];
}
/* 按钮优化 */
.btn-ios {
@apply px-3 py-2 text-xs;
}
.btn-ios-primary,
.btn-ios-secondary {
@apply text-xs;
}
/* 输入框优化 */
.input-ios {
@apply text-sm py-2;
}
.input-label {
@apply text-xs;
}
/* 徽章优化 */
.badge-ios {
@apply text-[10px] px-1.5 py-0.5;
}
/* 顶部导航优化 */
.top-navbar {
@apply px-3 h-12;
}
/* 标签栏优化 */
.tabs-bar {
@apply px-2;
}
.tab-item,
.tab-item-active {
@apply px-2 py-1.5 text-xs gap-1;
}
}
/* 阴影 */
.shadow-vben {
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.03),

View File

@ -171,8 +171,7 @@ export interface SystemSettings {
ai_base_url?: string
default_reply?: string
registration_enabled?: boolean
show_default_login?: boolean
show_login_info?: boolean
show_default_login_info?: boolean
login_captcha_enabled?: boolean
smtp_host?: string
smtp_port?: number

View File

@ -1,4 +1,5 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useAuthStore } from '@/store/authStore'
// 创建 axios 实例
const request: AxiosInstance = axios.create({
@ -31,9 +32,12 @@ request.interceptors.response.use(
(error: AxiosError) => {
if (error.response?.status === 401) {
// Token 过期或无效,清除并跳转登录
localStorage.removeItem('auth_token')
localStorage.removeItem('user_info')
window.location.href = '/login'
try {
// 统一通过 Zustand 清理登录状态,确保 isAuthenticated、token 与本地存储一致
useAuthStore.getState().clearAuth()
} catch {
// ignore
}
}
return Promise.reject(error)
}

View File

@ -20,6 +20,11 @@ export default defineConfig({
'/login': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.method === 'GET') {
return '/index.html'
}
},
},
'/verify': {
target: 'http://localhost:8080',
@ -29,26 +34,10 @@ export default defineConfig({
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,
@ -61,26 +50,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/users': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/logs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/risk-control-logs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/backup': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/data': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/cookies': {
target: 'http://localhost:8080',
changeOrigin: true,
@ -113,10 +86,6 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/items': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/itemReplays': {
target: 'http://localhost:8080',
changeOrigin: true,
@ -157,6 +126,87 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/static': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/backup': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/project-stats': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/change-password': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/search': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// 商品管理 - 前端有 /items 路由,需要区分浏览器访问和 API 请求
'/items': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
// 浏览器直接访问Accept 包含 text/html让前端路由处理
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 卡券管理 - 前端有 /cards 路由
'/cards': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 通知渠道 - 前端有 /notification-channels 路由
'/notification-channels': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 消息通知 - 前端有 /message-notifications 路由
'/message-notifications': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 关键词 - 前端有 /keywords 路由
'/keywords': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
// 订单 - 前端有 /orders 路由
'/orders': {
target: 'http://localhost:8080',
changeOrigin: true,
bypass: (req) => {
if (req.headers.accept?.includes('text/html')) {
return '/index.html'
}
},
},
},
},
build: {