完善前端功能:卡券管理完整迁移、移动端侧边栏修复、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:
parent
02dea67e41
commit
6e0c1f7fc9
@ -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/
|
||||
|
||||
24
Dockerfile
24
Dockerfile
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
}
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
// 添加商品回复
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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: '后端暂未实现订单状态更新接口' }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 抽屉(不依赖 collapsed);640-1024px 自动收缩;>1024px 展开
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const width = window.innerWidth
|
||||
if (width >= 640 && width < 1024) {
|
||||
setSidebarCollapsed(true)
|
||||
} else if (width >= 1024) {
|
||||
setSidebarCollapsed(false)
|
||||
}
|
||||
}
|
||||
|
||||
handleResize() // 初始化
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [setSidebarCollapsed])
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
setSidebarMobileOpen(false)
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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' && (
|
||||
<>
|
||||
|
||||
@ -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="请输入数据,每行一个: 卡号1:密码1 卡号2:密码2 或者 兑换码1 兑换码2"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">支持格式:卡号:密码 或 单独的兑换码</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片配置 */}
|
||||
{formData.type === 'image' && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-white mb-3">图片配置</h3>
|
||||
<div>
|
||||
<label className="input-label">选择图片 <span className="text-red-500">*</span></label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleImageChange}
|
||||
accept="image/*"
|
||||
className="input-ios"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">支持JPG、PNG、GIF格式,最大5MB</p>
|
||||
</div>
|
||||
{imagePreview && (
|
||||
<div className="mt-3">
|
||||
<label className="input-label">图片预览</label>
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="预览"
|
||||
className="max-w-full max-h-48 rounded-lg border border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 延时发货时间 */}
|
||||
<div>
|
||||
<label className="input-label">所属账号</label>
|
||||
<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">设置自动发货的延时时间,0表示立即发货,最大3600秒(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="粘贴卡密内容,每行一个 支持从Excel/TXT批量粘贴"
|
||||
value={formData.description}
|
||||
onChange={(e) => updateFormField('description', e.target.value)}
|
||||
className="input-ios h-20"
|
||||
placeholder="可选的备注信息,支持变量替换: {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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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 })
|
||||
},
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user