fix: 修复账号管理、商品管理、商品搜索等多个页面问题
- 账号管理:修复编辑/启用/禁用功能,正确调用后端API - 商品管理:修复商品列表显示,支持标题悬停查看完整内容 - 商品搜索:重写搜索页面,正确显示搜索结果和图片 - 关于页面:优化二维码显示,添加点击放大和悬停效果 - 更新favicon为简约聊天气泡图标 - 统一自动回复命名
This commit is contained in:
parent
543eed80e9
commit
02dea67e41
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6"/>
|
||||
<stop offset="100%" style="stop-color:#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#grad)"/>
|
||||
<path d="M16 7C11.03 7 7 10.58 7 15c0 2.5 1.4 4.7 3.5 6.1L9 25l4.5-2.3c.8.2 1.6.3 2.5.3 4.97 0 9-3.58 9-8s-4.03-8-9-8z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
BIN
frontend/public/static/qq-group.png
Normal file
BIN
frontend/public/static/qq-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
frontend/public/static/wechat-group.png
Normal file
BIN
frontend/public/static/wechat-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
BIN
frontend/public/static/wechat-group1.png
Normal file
BIN
frontend/public/static/wechat-group1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
BIN
frontend/public/static/wechat.png
Normal file
BIN
frontend/public/static/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
frontend/public/static/xianyu-group.png
Normal file
BIN
frontend/public/static/xianyu-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { MainLayout } from '@/components/layout/MainLayout'
|
||||
@ -25,24 +25,34 @@ import { verifyToken } from '@/api/auth'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, setAuth, clearAuth } = useAuthStore()
|
||||
const { isAuthenticated, setAuth, clearAuth, token: storeToken, _hasHydrated } = useAuthStore()
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
const [isValid, setIsValid] = useState(false)
|
||||
const hasCheckedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 等待 zustand persist 完成 hydration
|
||||
if (!_hasHydrated) return
|
||||
|
||||
// 防止重复检查
|
||||
if (hasCheckedRef.current) return
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
// 优先使用 store 中的 token,其次是 localStorage
|
||||
const token = storeToken || localStorage.getItem('auth_token')
|
||||
|
||||
if (!token) {
|
||||
setIsChecking(false)
|
||||
setIsValid(false)
|
||||
hasCheckedRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已经认证,直接通过
|
||||
if (isAuthenticated) {
|
||||
// 如果 store 中已经认证且有用户信息,直接通过
|
||||
if (isAuthenticated && storeToken) {
|
||||
setIsChecking(false)
|
||||
setIsValid(true)
|
||||
hasCheckedRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
@ -65,14 +75,15 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
setIsValid(false)
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
hasCheckedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
checkAuth()
|
||||
}, [isAuthenticated, setAuth, clearAuth])
|
||||
}, [_hasHydrated, isAuthenticated, storeToken, setAuth, clearAuth])
|
||||
|
||||
// 显示加载状态
|
||||
if (isChecking) {
|
||||
// 等待 hydration 或检查完成
|
||||
if (!_hasHydrated || isChecking) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
|
||||
@ -1,24 +1,73 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import type { Account, AccountDetail, ApiResponse } from '@/types'
|
||||
|
||||
// 获取账号列表
|
||||
export const getAccounts = (): Promise<Account[]> => {
|
||||
return get('/cookies')
|
||||
// 获取账号列表(返回账号ID数组)
|
||||
export const getAccounts = async (): Promise<Account[]> => {
|
||||
const ids: string[] = await get('/cookies')
|
||||
// 后端返回的是账号ID数组,转换为Account对象数组
|
||||
return ids.map(id => ({
|
||||
id,
|
||||
cookie: '',
|
||||
enabled: true,
|
||||
use_ai_reply: false,
|
||||
use_default_reply: false,
|
||||
auto_confirm: false
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取账号详情列表
|
||||
export const getAccountDetails = (): Promise<AccountDetail[]> => {
|
||||
return get('/cookies/details')
|
||||
export const getAccountDetails = async (): Promise<AccountDetail[]> => {
|
||||
interface BackendAccountDetail {
|
||||
id: string
|
||||
value: string
|
||||
enabled: boolean
|
||||
auto_confirm: boolean
|
||||
remark?: string
|
||||
pause_duration?: number
|
||||
}
|
||||
const data = await get<BackendAccountDetail[]>('/cookies/details')
|
||||
// 后端返回 value 字段,前端使用 cookie 字段
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
cookie: item.value,
|
||||
enabled: item.enabled,
|
||||
auto_confirm: item.auto_confirm,
|
||||
note: item.remark,
|
||||
pause_duration: item.pause_duration,
|
||||
use_ai_reply: false,
|
||||
use_default_reply: false,
|
||||
}))
|
||||
}
|
||||
|
||||
// 添加账号
|
||||
export const addAccount = (data: { id: string; cookie: string }): Promise<ApiResponse> => {
|
||||
return post('/cookies', data)
|
||||
// 后端需要 id 和 value 字段
|
||||
return post('/cookies', { id: data.id, value: data.cookie })
|
||||
}
|
||||
|
||||
// 更新账号
|
||||
export const updateAccount = (id: string, data: Partial<Account>): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}`, data)
|
||||
// 更新账号 Cookie 值
|
||||
export const updateAccountCookie = (id: string, value: string): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}`, { id, value })
|
||||
}
|
||||
|
||||
// 更新账号启用/禁用状态
|
||||
export const updateAccountStatus = (id: string, enabled: boolean): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}/status`, { enabled })
|
||||
}
|
||||
|
||||
// 更新账号备注
|
||||
export const updateAccountRemark = (id: string, remark: string): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}/remark`, { remark })
|
||||
}
|
||||
|
||||
// 更新账号自动确认设置
|
||||
export const updateAccountAutoConfirm = (id: string, autoConfirm: boolean): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}/auto-confirm`, { auto_confirm: autoConfirm })
|
||||
}
|
||||
|
||||
// 更新账号暂停时间
|
||||
export const updateAccountPauseDuration = (id: string, pauseDuration: number): Promise<ApiResponse> => {
|
||||
return put(`/cookies/${id}/pause-duration`, { pause_duration: pauseDuration })
|
||||
}
|
||||
|
||||
// 删除账号
|
||||
|
||||
@ -1,22 +1,33 @@
|
||||
import { get, post, put, del } from '@/utils/request'
|
||||
import { get, post, del } from '@/utils/request'
|
||||
import type { ApiResponse, User } from '@/types'
|
||||
|
||||
// ========== 用户管理 ==========
|
||||
|
||||
// 获取用户列表
|
||||
export const getUsers = (): Promise<{ success: boolean; data?: User[] }> => {
|
||||
return get('/admin/users')
|
||||
export const getUsers = async (): Promise<{ success: boolean; data?: User[] }> => {
|
||||
const result = await get<{ users: Array<{
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
is_admin: boolean
|
||||
cookie_count?: number
|
||||
card_count?: number
|
||||
}> }>('/admin/users')
|
||||
// 后端返回 { users: [...] } 格式,转换字段名
|
||||
const users: User[] = (result.users || []).map(u => ({
|
||||
user_id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
is_admin: u.is_admin,
|
||||
}))
|
||||
return { success: true, data: users }
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
export const addUser = (data: { username: string; password: string; email?: string; is_admin?: boolean }): Promise<ApiResponse> => {
|
||||
return post('/admin/users', data)
|
||||
}
|
||||
// TODO: 后端暂未实现 POST /admin/users 接口
|
||||
// export const addUser = ...
|
||||
|
||||
// 更新用户
|
||||
export const updateUser = (userId: number, data: Partial<User & { password?: string }>): Promise<ApiResponse> => {
|
||||
return put(`/admin/users/${userId}`, data)
|
||||
}
|
||||
// TODO: 后端暂未实现 PUT /admin/users/{userId} 接口
|
||||
// export const updateUser = ...
|
||||
|
||||
// 删除用户
|
||||
export const deleteUser = (userId: number): Promise<ApiResponse> => {
|
||||
@ -34,12 +45,21 @@ export interface SystemLog {
|
||||
}
|
||||
|
||||
// 获取系统日志
|
||||
export const getSystemLogs = (params?: { page?: number; limit?: number; level?: string }): Promise<{ success: boolean; data?: SystemLog[]; total?: number }> => {
|
||||
export const getSystemLogs = async (params?: { page?: number; limit?: number; level?: string }): Promise<{ success: boolean; data?: SystemLog[]; total?: number }> => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.limit) query.set('limit', String(params.limit))
|
||||
if (params?.level) query.set('level', params.level)
|
||||
return get(`/admin/logs?${query.toString()}`)
|
||||
const result = await get<{ logs?: string[]; total?: number }>(`/admin/logs?${query.toString()}`)
|
||||
// 后端返回 { logs: [...] } 格式,转换为 SystemLog 数组
|
||||
const logs: SystemLog[] = (result.logs || []).map((log, index) => ({
|
||||
id: String(index),
|
||||
level: log.includes('ERROR') ? 'error' : log.includes('WARNING') ? 'warning' : 'info',
|
||||
message: log,
|
||||
module: 'system',
|
||||
created_at: new Date().toISOString(),
|
||||
}))
|
||||
return { success: true, data: logs, total: result.total }
|
||||
}
|
||||
|
||||
// 清空系统日志
|
||||
@ -58,12 +78,32 @@ export interface RiskLog {
|
||||
}
|
||||
|
||||
// 获取风控日志
|
||||
export const getRiskLogs = (params?: { page?: number; limit?: number; cookie_id?: string }): Promise<{ success: boolean; data?: RiskLog[]; total?: number }> => {
|
||||
export const getRiskLogs = async (params?: { page?: number; limit?: number; cookie_id?: string }): Promise<{ success: boolean; data?: RiskLog[]; total?: number }> => {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.page) query.set('page', String(params.page))
|
||||
if (params?.limit) query.set('limit', String(params.limit))
|
||||
if (params?.cookie_id) query.set('cookie_id', params.cookie_id)
|
||||
return get(`/admin/risk-control-logs?${query.toString()}`)
|
||||
const result = await get<{ success: boolean; data?: Array<{
|
||||
id: number
|
||||
cookie_id: string
|
||||
event_type: string
|
||||
event_description: string
|
||||
processing_result: string
|
||||
processing_status: string
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
cookie_name: string
|
||||
}>; total?: number }>(`/admin/risk-control-logs?${query.toString()}`)
|
||||
// 转换后端格式为前端格式
|
||||
const logs: RiskLog[] = (result.data || []).map(item => ({
|
||||
id: String(item.id),
|
||||
cookie_id: item.cookie_id || item.cookie_name,
|
||||
risk_type: item.event_type,
|
||||
message: item.event_description || item.processing_result,
|
||||
created_at: item.created_at,
|
||||
}))
|
||||
return { success: true, data: logs, total: result.total }
|
||||
}
|
||||
|
||||
// 清空风控日志
|
||||
|
||||
@ -2,9 +2,12 @@ import { get, post, del } from '@/utils/request'
|
||||
import type { ApiResponse, Card } from '@/types'
|
||||
|
||||
// 获取卡券列表
|
||||
export const getCards = (accountId?: string): Promise<{ success: boolean; data?: Card[] }> => {
|
||||
export const getCards = async (accountId?: string): Promise<{ success: boolean; data?: Card[] }> => {
|
||||
const url = accountId ? `/cards?cookie_id=${accountId}` : '/cards'
|
||||
return get(url)
|
||||
const result = await get<Card[] | { cards?: Card[] }>(url)
|
||||
// 后端可能返回数组或 { cards: [...] } 格式
|
||||
const data = Array.isArray(result) ? result : (result.cards || [])
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
// 获取账号的卡券列表
|
||||
|
||||
@ -2,24 +2,26 @@ import { get, post, put, del } from '@/utils/request'
|
||||
import type { ApiResponse, DeliveryRule } from '@/types'
|
||||
|
||||
// 获取发货规则列表
|
||||
export const getDeliveryRules = (accountId?: string): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
|
||||
const url = accountId ? `/api/delivery-rules?cookie_id=${accountId}` : '/api/delivery-rules'
|
||||
return get(url)
|
||||
export const getDeliveryRules = async (): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
|
||||
const result = await get<DeliveryRule[] | { rules?: DeliveryRule[] }>('/delivery-rules')
|
||||
// 后端可能返回数组或 { rules: [...] } 格式
|
||||
const data = Array.isArray(result) ? result : (result.rules || [])
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
// 添加发货规则
|
||||
export const addDeliveryRule = (data: Partial<DeliveryRule>): Promise<ApiResponse> => {
|
||||
return post('/api/delivery-rules', data)
|
||||
return post('/delivery-rules', data)
|
||||
}
|
||||
|
||||
// 更新发货规则
|
||||
export const updateDeliveryRule = (ruleId: string, data: Partial<DeliveryRule>): Promise<ApiResponse> => {
|
||||
return put(`/api/delivery-rules/${ruleId}`, data)
|
||||
return put(`/delivery-rules/${ruleId}`, data)
|
||||
}
|
||||
|
||||
// 删除发货规则
|
||||
export const deleteDeliveryRule = (ruleId: string): Promise<ApiResponse> => {
|
||||
return del(`/api/delivery-rules/${ruleId}`)
|
||||
return del(`/delivery-rules/${ruleId}`)
|
||||
}
|
||||
|
||||
// 获取账号的发货规则
|
||||
|
||||
@ -2,9 +2,12 @@ import { get, post, put, del } from '@/utils/request'
|
||||
import type { Item, ItemReply, ApiResponse } from '@/types'
|
||||
|
||||
// 获取商品列表
|
||||
export const getItems = (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
|
||||
const params = cookieId ? `?cookie_id=${cookieId}` : ''
|
||||
return get(`/items${params}`)
|
||||
export const getItems = async (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
|
||||
const url = cookieId ? `/items/cookie/${cookieId}` : '/items'
|
||||
const result = await get<{ items?: Item[] } | Item[]>(url)
|
||||
// 后端返回 { items: [...] } 或直接返回数组
|
||||
const items = Array.isArray(result) ? result : (result.items || [])
|
||||
return { success: true, data: items }
|
||||
}
|
||||
|
||||
// 删除商品
|
||||
|
||||
@ -5,47 +5,61 @@ import type { ApiResponse, NotificationChannel, MessageNotification } from '@/ty
|
||||
|
||||
// 获取通知渠道列表
|
||||
export const getNotificationChannels = (): Promise<{ success: boolean; data?: NotificationChannel[] }> => {
|
||||
return get('/api/notification-channels')
|
||||
return get('/notification-channels')
|
||||
}
|
||||
|
||||
// 添加通知渠道
|
||||
export const addNotificationChannel = (data: Partial<NotificationChannel>): Promise<ApiResponse> => {
|
||||
return post('/api/notification-channels', data)
|
||||
return post('/notification-channels', data)
|
||||
}
|
||||
|
||||
// 更新通知渠道
|
||||
export const updateNotificationChannel = (channelId: string, data: Partial<NotificationChannel>): Promise<ApiResponse> => {
|
||||
return put(`/api/notification-channels/${channelId}`, data)
|
||||
return put(`/notification-channels/${channelId}`, data)
|
||||
}
|
||||
|
||||
// 删除通知渠道
|
||||
export const deleteNotificationChannel = (channelId: string): Promise<ApiResponse> => {
|
||||
return del(`/api/notification-channels/${channelId}`)
|
||||
return del(`/notification-channels/${channelId}`)
|
||||
}
|
||||
|
||||
// 测试通知渠道
|
||||
export const testNotificationChannel = (channelId: string): Promise<ApiResponse> => {
|
||||
return post(`/api/notification-channels/${channelId}/test`)
|
||||
return post(`/notification-channels/${channelId}/test`)
|
||||
}
|
||||
|
||||
// ========== 消息通知 ==========
|
||||
|
||||
// 获取消息通知列表
|
||||
export const getMessageNotifications = (): Promise<{ success: boolean; data?: MessageNotification[] }> => {
|
||||
return get('/api/message-notifications')
|
||||
// 获取所有消息通知配置
|
||||
// 后端返回格式: { 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 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { success: true, data: notifications }
|
||||
}
|
||||
|
||||
// 添加消息通知
|
||||
export const addMessageNotification = (data: Partial<MessageNotification>): Promise<ApiResponse> => {
|
||||
return post('/api/message-notifications', data)
|
||||
}
|
||||
|
||||
// 更新消息通知
|
||||
export const updateMessageNotification = (notificationId: string, data: Partial<MessageNotification>): Promise<ApiResponse> => {
|
||||
return put(`/api/message-notifications/${notificationId}`, data)
|
||||
// 设置消息通知 - 后端接口需要 cookie_id 作为路径参数
|
||||
export const setMessageNotification = (cookieId: string, channelId: number, enabled: boolean): Promise<ApiResponse> => {
|
||||
return post(`/message-notifications/${cookieId}`, { channel_id: channelId, enabled })
|
||||
}
|
||||
|
||||
// 删除消息通知
|
||||
export const deleteMessageNotification = (notificationId: string): Promise<ApiResponse> => {
|
||||
return del(`/api/message-notifications/${notificationId}`)
|
||||
return del(`/message-notifications/${notificationId}`)
|
||||
}
|
||||
|
||||
// 删除账号的所有消息通知
|
||||
export const deleteAccountNotifications = (cookieId: string): Promise<ApiResponse> => {
|
||||
return del(`/message-notifications/account/${cookieId}`)
|
||||
}
|
||||
|
||||
@ -1,7 +1,35 @@
|
||||
import { post } from '@/utils/request'
|
||||
import type { Item } from '@/types'
|
||||
|
||||
// 搜索结果项类型
|
||||
export interface SearchResultItem {
|
||||
item_id: string
|
||||
title: string
|
||||
price: string
|
||||
seller_name?: string
|
||||
item_url?: string
|
||||
main_image?: string
|
||||
publish_time?: string
|
||||
tags?: string[]
|
||||
area?: string
|
||||
want_count?: number
|
||||
}
|
||||
|
||||
// 搜索商品
|
||||
export const searchItems = (keyword: string, accountId?: string): Promise<{ success: boolean; data?: Item[] }> => {
|
||||
return post('/api/items/search', { keyword, cookie_id: accountId })
|
||||
export const searchItems = async (
|
||||
keyword: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 20
|
||||
): Promise<{ success: boolean; data: SearchResultItem[]; total?: number; error?: string }> => {
|
||||
const result = await post<{
|
||||
success: boolean
|
||||
data?: SearchResultItem[]
|
||||
total?: number
|
||||
error?: string
|
||||
}>('/items/search', { keyword, page, page_size: pageSize })
|
||||
return {
|
||||
success: result.success,
|
||||
data: result.data || [],
|
||||
total: result.total,
|
||||
error: result.error
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,34 @@
|
||||
import { get, post, put } from '@/utils/request'
|
||||
import { get, put } from '@/utils/request'
|
||||
import type { ApiResponse, SystemSettings } from '@/types'
|
||||
|
||||
// 获取系统设置
|
||||
export const getSystemSettings = (): Promise<{ success: boolean; data?: SystemSettings }> => {
|
||||
return get('/system-settings')
|
||||
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 converted: SystemSettings = {}
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (booleanFields.includes(key)) {
|
||||
converted[key] = value === true || value === 'true'
|
||||
} else {
|
||||
converted[key] = value
|
||||
}
|
||||
}
|
||||
return { success: true, data: converted }
|
||||
}
|
||||
|
||||
// 更新系统设置
|
||||
export const updateSystemSettings = (data: Partial<SystemSettings>): Promise<ApiResponse> => {
|
||||
// 逐个更新设置项
|
||||
const promises = Object.entries(data).map(([key, value]) =>
|
||||
put(`/system-settings/${key}`, { value })
|
||||
)
|
||||
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
|
||||
export const updateSystemSettings = async (data: Partial<SystemSettings>): Promise<ApiResponse> => {
|
||||
// 逐个更新设置项,确保 value 是字符串
|
||||
const promises = Object.entries(data).map(([key, value]) => {
|
||||
// 将布尔值和数字转换为字符串
|
||||
const stringValue = typeof value === 'boolean' ? (value ? 'true' : 'false')
|
||||
: typeof value === 'number' ? String(value)
|
||||
: value
|
||||
return put(`/system-settings/${key}`, { value: stringValue })
|
||||
})
|
||||
await Promise.all(promises)
|
||||
return { success: true, message: '设置已保存' }
|
||||
}
|
||||
|
||||
// 获取 AI 设置
|
||||
@ -25,9 +41,11 @@ export const updateAISettings = (data: Record<string, unknown>): Promise<ApiResp
|
||||
return put('/ai-reply-settings', data)
|
||||
}
|
||||
|
||||
// 测试 AI 连接
|
||||
export const testAIConnection = (): Promise<ApiResponse> => {
|
||||
return post('/ai-reply-test/default')
|
||||
// TODO: 测试 AI 连接需要指定 cookie_id,后端接口为 POST /ai-reply-test/{cookie_id}
|
||||
// 系统设置页面的测试按钮暂时无法使用,需要先选择账号
|
||||
export const testAIConnection = async (): Promise<ApiResponse> => {
|
||||
// 后端需要有效的 cookie_id,这里返回提示信息
|
||||
return { success: false, message: 'AI 测试需要先选择一个账号,请在账号管理页面的 AI 设置中测试' }
|
||||
}
|
||||
|
||||
// 获取邮件设置
|
||||
@ -43,7 +61,8 @@ export const updateEmailSettings = (data: Record<string, unknown>): Promise<ApiR
|
||||
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
|
||||
}
|
||||
|
||||
// 测试邮件发送
|
||||
export const testEmailSend = (email: string): Promise<ApiResponse> => {
|
||||
return post('/send-verification-code', { email, type: 'test' })
|
||||
// TODO: 测试邮件发送功能需要后端支持 type: 'test' 参数
|
||||
// 当前后端的 /send-verification-code 接口只支持 'register' 和 'login' 类型
|
||||
export const testEmailSend = async (_email: string): Promise<ApiResponse> => {
|
||||
return { success: false, message: '邮件测试功能暂未实现,请检查 SMTP 配置后直接保存' }
|
||||
}
|
||||
|
||||
@ -21,17 +21,17 @@ export function Loading({ size = 'md', fullScreen = false, text }: LoadingProps)
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<Loader2 className={cn('text-primary-500', sizes[size])} />
|
||||
<Loader2 className={cn('text-blue-500', sizes[size])} />
|
||||
</motion.div>
|
||||
{text && (
|
||||
<p className="text-sm text-gray-500 font-medium">{text}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">{text}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
160
frontend/src/components/common/Select.tsx
Normal file
160
frontend/src/components/common/Select.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface SelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = '请选择',
|
||||
disabled = false,
|
||||
className,
|
||||
}: SelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const selectRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value)
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// 键盘导航
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
setIsOpen(!isOpen)
|
||||
break
|
||||
case 'Escape':
|
||||
setIsOpen(false)
|
||||
break
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
if (!isOpen) {
|
||||
setIsOpen(true)
|
||||
} else {
|
||||
const currentIndex = options.findIndex((opt) => opt.value === value)
|
||||
const nextIndex = Math.min(currentIndex + 1, options.length - 1)
|
||||
onChange(options[nextIndex].value)
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
if (isOpen) {
|
||||
const currentIndex = options.findIndex((opt) => opt.value === value)
|
||||
const prevIndex = Math.max(currentIndex - 1, 0)
|
||||
onChange(options[prevIndex].value)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={selectRef} className={cn('relative', className)}>
|
||||
{/* 触发器 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between gap-2',
|
||||
'px-3 py-2 rounded-md text-sm text-left',
|
||||
'bg-white dark:bg-slate-700',
|
||||
'border border-slate-300 dark:border-slate-600',
|
||||
'hover:border-blue-400 dark:hover:border-blue-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
|
||||
'transition-colors duration-150',
|
||||
disabled && 'opacity-60 cursor-not-allowed bg-slate-100 dark:bg-slate-800',
|
||||
isOpen && 'ring-2 ring-blue-500 border-transparent'
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
selectedOption ? 'text-slate-900 dark:text-slate-100' : 'text-slate-400'
|
||||
)}>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'w-4 h-4 text-slate-400 flex-shrink-0 transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className={cn(
|
||||
'absolute z-50 w-full mt-1',
|
||||
'bg-white dark:bg-slate-800',
|
||||
'border border-slate-200 dark:border-slate-700',
|
||||
'rounded-md shadow-lg',
|
||||
'max-h-60 overflow-auto',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-100'
|
||||
)}>
|
||||
{options.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-slate-400 text-center">
|
||||
暂无选项
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!option.disabled) {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}}
|
||||
disabled={option.disabled}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between gap-2',
|
||||
'px-3 py-2 text-sm text-left',
|
||||
'transition-colors duration-100',
|
||||
option.disabled
|
||||
? 'text-slate-400 cursor-not-allowed'
|
||||
: 'text-slate-700 dark:text-slate-200 hover:bg-blue-50 dark:hover:bg-slate-700',
|
||||
option.value === value && 'bg-blue-50 dark:bg-slate-700 text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.value === value && (
|
||||
<Check className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -11,11 +11,14 @@ export function MainLayout() {
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="lg:ml-56 min-h-screen flex flex-col">
|
||||
{/* Top navbar */}
|
||||
<TopNavbar />
|
||||
|
||||
{/* Tabs bar */}
|
||||
<TabsBar />
|
||||
{/* Fixed header area */}
|
||||
<div className="sticky top-0 z-30 bg-slate-50 dark:bg-slate-900">
|
||||
{/* Top navbar */}
|
||||
<TopNavbar />
|
||||
|
||||
{/* Tabs bar */}
|
||||
<TabsBar />
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
|
||||
@ -74,8 +74,9 @@ export function Sidebar() {
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150',
|
||||
'text-slate-400 hover:text-white hover:bg-white/10',
|
||||
isActive && 'bg-blue-600 text-white shadow-sm'
|
||||
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'
|
||||
)
|
||||
}
|
||||
>
|
||||
@ -106,24 +107,25 @@ export function Sidebar() {
|
||||
}}
|
||||
className={cn(
|
||||
'fixed top-0 left-0 h-screen w-56 z-50',
|
||||
'bg-[#001529] text-white',
|
||||
'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'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-14 flex items-center justify-between px-4 border-b border-white/5">
|
||||
<div className="h-14 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
|
||||
<MessageSquare className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-sm text-white">闲鱼管理系统</span>
|
||||
<span className="font-semibold text-sm text-slate-900 dark:text-white">闲鱼管理系统</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeMobileSidebar}
|
||||
className="lg:hidden p-1.5 hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-white"
|
||||
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>
|
||||
@ -139,7 +141,7 @@ export function Sidebar() {
|
||||
{user?.is_admin && (
|
||||
<>
|
||||
<div className="pt-4 pb-2 px-3">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<p className="text-xs font-medium text-slate-400 dark:text-gray-500 uppercase tracking-wider">
|
||||
管理员
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -23,7 +23,7 @@ const routeTitles: Record<string, string> = {
|
||||
'/dashboard': '仪表盘',
|
||||
'/accounts': '账号管理',
|
||||
'/items': '商品管理',
|
||||
'/keywords': '关键词管理',
|
||||
'/keywords': '自动回复',
|
||||
'/item-replies': '指定商品回复',
|
||||
'/orders': '订单管理',
|
||||
'/cards': '卡券管理',
|
||||
|
||||
@ -1,128 +1,217 @@
|
||||
|
||||
import { MessageSquare, Github, Globe, Users, Heart } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Github, Heart, Code, MessageCircle, Users, UserCheck, Bot, Truck, Bell, BarChart3, X } from 'lucide-react'
|
||||
|
||||
export function About() {
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<div className="max-w-5xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="w-24 h-24 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-600
|
||||
mx-auto mb-6 flex items-center justify-center shadow-lg"
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600
|
||||
mx-auto mb-4 flex items-center justify-center shadow-md"
|
||||
>
|
||||
<MessageSquare className="w-12 h-12 text-white" />
|
||||
<MessageSquare className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<motion.h1
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="text-3xl font-bold text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
闲鱼自动回复管理系统
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="text-slate-500 dark:text-slate-400 mt-2"
|
||||
>
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||
智能管理您的闲鱼店铺,提升客服效率
|
||||
</motion.p>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Groups */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<MessageCircle className="w-4 h-4 text-green-500" />
|
||||
微信群
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body text-center">
|
||||
<div
|
||||
className="w-[160px] h-[160px] mx-auto overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700
|
||||
cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-green-400"
|
||||
onClick={() => setPreviewImage('/static/wechat-group.png')}
|
||||
>
|
||||
<img
|
||||
src="/static/wechat-group.png"
|
||||
alt="微信群二维码"
|
||||
className="w-full h-full object-cover object-center"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
const parent = (e.target as HTMLImageElement).parentElement
|
||||
if (parent) {
|
||||
parent.innerHTML = '<p class="text-slate-400 dark:text-slate-500 py-12 text-sm">二维码未配置</p>'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">扫码加入微信技术交流群</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
QQ群
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body text-center">
|
||||
<div
|
||||
className="w-[160px] h-[160px] mx-auto overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700
|
||||
cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-blue-400"
|
||||
onClick={() => setPreviewImage('/static/qq-group.png')}
|
||||
>
|
||||
<img
|
||||
src="/static/qq-group.png"
|
||||
alt="QQ群二维码"
|
||||
className="w-full h-full object-cover object-center"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
const parent = (e.target as HTMLImageElement).parentElement
|
||||
if (parent) {
|
||||
parent.innerHTML = '<p class="text-slate-400 dark:text-slate-500 py-12 text-sm">二维码未配置</p>'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">扫码加入QQ技术交流群</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4">主要功能</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ title: '多账号管理', desc: '支持同时管理多个闲鱼账号' },
|
||||
{ title: '智能自动回复', desc: '基于关键词的智能消息回复' },
|
||||
{ title: 'AI 助手', desc: '接入 AI 模型,智能处理复杂问题' },
|
||||
{ title: '自动发货', desc: '订单自动发货,支持卡密发货' },
|
||||
{ title: '消息通知', desc: '多渠道消息推送通知' },
|
||||
{ title: '数据统计', desc: '订单、商品数据统计分析' },
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-slate-800"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{feature.title}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{feature.desc}</p>
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">主要功能</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{[
|
||||
{ title: '多账号管理', desc: '同时管理多个账号', icon: UserCheck, color: 'text-blue-500' },
|
||||
{ title: '智能回复', desc: '关键词自动回复', icon: MessageSquare, color: 'text-green-500' },
|
||||
{ title: 'AI 助手', desc: '智能处理复杂问题', icon: Bot, color: 'text-purple-500' },
|
||||
{ title: '自动发货', desc: '支持卡密发货', icon: Truck, color: 'text-orange-500' },
|
||||
{ title: '消息通知', desc: '多渠道推送', icon: Bell, color: 'text-pink-500' },
|
||||
{ title: '数据统计', desc: '订单商品分析', icon: BarChart3, color: 'text-cyan-500' },
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800 flex items-center gap-3"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg bg-white dark:bg-slate-700 flex items-center justify-center shadow-sm ${feature.color}`}>
|
||||
<feature.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-sm text-slate-900 dark:text-slate-100">{feature.title}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{feature.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contributors */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Code className="w-4 h-4" />
|
||||
贡献者
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="https://github.com/zhinianboke"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700
|
||||
hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">zhinianboke</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">项目作者</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/legeling"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700
|
||||
hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">legeling</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">前端重构</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4">相关链接</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-gray-900 text-white
|
||||
hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<Github className="w-6 h-6" />
|
||||
<span className="font-medium">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-blue-500 text-white
|
||||
hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Globe className="w-6 h-6" />
|
||||
<span className="font-medium">官方网站</span>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500 text-white
|
||||
hover:bg-emerald-600 transition-colors"
|
||||
>
|
||||
<Users className="w-6 h-6" />
|
||||
<span className="font-medium">交流群</span>
|
||||
</a>
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">相关链接</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="flex gap-3">
|
||||
<a
|
||||
href="https://github.com/zhinianboke/xianyu-auto-reply"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-900 text-white
|
||||
hover:bg-gray-800 transition-colors text-sm"
|
||||
>
|
||||
<Github className="w-4 h-4" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
|
||||
className="text-center py-6 text-slate-500 dark:text-slate-400"
|
||||
>
|
||||
<div className="text-center py-4 text-slate-500 dark:text-slate-400 text-sm">
|
||||
<p className="flex items-center justify-center gap-1">
|
||||
Made with <Heart className="w-4 h-4 text-red-500" /> by Open Source Community
|
||||
Made with <Heart className="w-3.5 h-3.5 text-red-500" /> by Open Source Community
|
||||
</p>
|
||||
<p className="text-sm mt-2">
|
||||
赞助商:划算云服务器{' '}
|
||||
<p className="mt-1 text-xs">
|
||||
赞助商:
|
||||
<a
|
||||
href="https://www.hsykj.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 dark:text-blue-400 hover:underline"
|
||||
className="text-blue-500 dark:text-blue-400 hover:underline ml-1"
|
||||
>
|
||||
www.hsykj.com
|
||||
划算云服务器
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
{previewImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
<div className="relative max-w-[90vw] max-h-[90vh]">
|
||||
<button
|
||||
onClick={() => setPreviewImage(null)}
|
||||
className="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="预览"
|
||||
className="max-w-full max-h-[85vh] rounded-lg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getAccountDetails, deleteAccount, updateAccount, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
|
||||
import { 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 { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { AccountDetail } from '@/types'
|
||||
|
||||
@ -10,6 +11,7 @@ type ModalType = 'qrcode' | 'password' | 'manual' | 'edit' | null
|
||||
|
||||
export function Accounts() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
@ -34,12 +36,11 @@ export function Accounts() {
|
||||
// 编辑账号状态
|
||||
const [editingAccount, setEditingAccount] = useState<AccountDetail | null>(null)
|
||||
const [editNote, setEditNote] = useState('')
|
||||
const [editUseAI, setEditUseAI] = useState(false)
|
||||
const [editUseDefault, setEditUseDefault] = useState(false)
|
||||
const [editCookie, setEditCookie] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getAccountDetails()
|
||||
@ -52,8 +53,9 @@ export function Accounts() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
// 清理扫码检查定时器
|
||||
const clearQrCheck = useCallback(() => {
|
||||
@ -111,14 +113,22 @@ export function Accounts() {
|
||||
case 'scanned':
|
||||
setQrStatus('scanned')
|
||||
break
|
||||
case 'processing':
|
||||
// 正在处理中,显示已扫描状态
|
||||
setQrStatus('scanned')
|
||||
break
|
||||
case 'success':
|
||||
case 'already_processed':
|
||||
// 登录成功或已处理完成
|
||||
setQrStatus('success')
|
||||
clearQrCheck()
|
||||
addToast({
|
||||
type: 'success',
|
||||
message: result.account_info?.is_new_account
|
||||
? `新账号 ${result.account_info.account_id} 添加成功`
|
||||
: `账号 ${result.account_info?.account_id} 登录成功`,
|
||||
: result.account_info?.account_id
|
||||
? `账号 ${result.account_info.account_id} 登录成功`
|
||||
: '账号登录成功',
|
||||
})
|
||||
setTimeout(() => {
|
||||
closeModal()
|
||||
@ -227,7 +237,7 @@ export function Accounts() {
|
||||
|
||||
const handleToggleEnabled = async (account: AccountDetail) => {
|
||||
try {
|
||||
await updateAccount(account.id, { enabled: !account.enabled })
|
||||
await updateAccountStatus(account.id, !account.enabled)
|
||||
addToast({ type: 'success', message: account.enabled ? '账号已禁用' : '账号已启用' })
|
||||
loadAccounts()
|
||||
} catch {
|
||||
@ -250,8 +260,6 @@ export function Accounts() {
|
||||
const openEditModal = (account: AccountDetail) => {
|
||||
setEditingAccount(account)
|
||||
setEditNote(account.note || '')
|
||||
setEditUseAI(account.use_ai_reply || false)
|
||||
setEditUseDefault(account.use_default_reply || false)
|
||||
setEditCookie(account.cookie || '')
|
||||
setActiveModal('edit')
|
||||
}
|
||||
@ -262,12 +270,20 @@ export function Accounts() {
|
||||
|
||||
setEditSaving(true)
|
||||
try {
|
||||
await updateAccount(editingAccount.id, {
|
||||
note: editNote.trim() || undefined,
|
||||
use_ai_reply: editUseAI,
|
||||
use_default_reply: editUseDefault,
|
||||
cookie: editCookie.trim() || undefined,
|
||||
})
|
||||
// 分别调用不同的 API 更新不同字段
|
||||
const promises: Promise<unknown>[] = []
|
||||
|
||||
// 更新备注
|
||||
if (editNote.trim() !== (editingAccount.note || '')) {
|
||||
promises.push(updateAccountRemark(editingAccount.id, editNote.trim()))
|
||||
}
|
||||
|
||||
// 更新 Cookie 值
|
||||
if (editCookie.trim() && editCookie.trim() !== editingAccount.cookie) {
|
||||
promises.push(updateAccountCookie(editingAccount.id, editCookie.trim()))
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
addToast({ type: 'success', message: '账号信息已更新' })
|
||||
closeModal()
|
||||
loadAccounts()
|
||||
@ -314,45 +330,45 @@ export function Accounts() {
|
||||
{/* 扫码登录 */}
|
||||
<button
|
||||
onClick={startQRCodeLogin}
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-indigo-200
|
||||
bg-blue-50 hover:bg-blue-100 transition-colors text-left"
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-blue-200 dark:border-blue-800
|
||||
bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||
<QrCode className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">扫码登录</p>
|
||||
<p className="text-xs text-gray-500">推荐方式</p>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">扫码登录</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">推荐方式</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 账号密码登录 */}
|
||||
<button
|
||||
onClick={() => setActiveModal('password')}
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
|
||||
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-slate-200 dark:border-slate-700
|
||||
hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4 text-gray-600" />
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">账号密码</p>
|
||||
<p className="text-xs text-gray-500">使用账号和密码</p>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">账号密码</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">使用账号和密码</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 手动输入 */}
|
||||
<button
|
||||
onClick={() => setActiveModal('manual')}
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
|
||||
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
|
||||
className="flex items-center gap-3 p-4 rounded-md border border-slate-200 dark:border-slate-700
|
||||
hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
||||
<Edit2 className="w-4 h-4 text-gray-600" />
|
||||
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
|
||||
<Edit2 className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">手动输入</p>
|
||||
<p className="text-xs text-gray-500">手动输入Cookie</p>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">手动输入</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">手动输入Cookie</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@ -383,7 +399,7 @@ export function Accounts() {
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="empty-state py-8">
|
||||
<p className="text-gray-500">暂无账号,请添加新账号</p>
|
||||
<p className="text-slate-500 dark:text-slate-400">暂无账号,请添加新账号</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -392,9 +408,23 @@ export function Accounts() {
|
||||
<tr key={account.id}>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
|
||||
<td>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded max-w-[120px] truncate block">
|
||||
{account.cookie?.substring(0, 25)}...
|
||||
</code>
|
||||
<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>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`inline-flex items-center gap-1.5 ${account.enabled !== false ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
@ -416,31 +446,33 @@ export function Accounts() {
|
||||
{account.note || '-'}
|
||||
</td>
|
||||
<td>
|
||||
<div className="table-actions">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(account)}
|
||||
className="table-action-btn"
|
||||
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"
|
||||
title={account.enabled !== false ? '禁用' : '启用'}
|
||||
>
|
||||
{account.enabled !== false ? (
|
||||
<PowerOff className="w-4 h-4 text-amber-500" />
|
||||
<><PowerOff className="w-3.5 h-3.5 text-amber-500" /><span className="text-amber-600 dark:text-amber-400">禁用</span></>
|
||||
) : (
|
||||
<Power className="w-4 h-4 text-green-500" />
|
||||
<><Power className="w-3.5 h-3.5 text-green-500" /><span className="text-green-600 dark:text-green-400">启用</span></>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(account)}
|
||||
className="table-action-btn"
|
||||
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"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-blue-500" />
|
||||
<Edit2 className="w-3.5 h-3.5 text-blue-500" />
|
||||
<span className="text-blue-600 dark:text-blue-400">编辑</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(account.id)}
|
||||
className="table-action-btn hover:!bg-red-50"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-500" />
|
||||
<span className="text-red-600 dark:text-red-400">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@ -466,14 +498,14 @@ export function Accounts() {
|
||||
{qrStatus === 'loading' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="w-10 h-10 text-blue-600 dark:text-blue-400 animate-spin" />
|
||||
<p className="text-sm text-gray-500">正在生成二维码...</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">正在生成二维码...</p>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'ready' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<img src={qrCodeUrl} alt="登录二维码" className="w-44 h-44 rounded-lg border" />
|
||||
<p className="text-sm text-gray-600">请使用闲鱼APP扫描二维码</p>
|
||||
<p className="text-xs text-gray-400">二维码有效期约5分钟</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">请使用闲鱼APP扫描二维码</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">二维码有效期约5分钟</p>
|
||||
</div>
|
||||
)}
|
||||
{qrStatus === 'scanned' && (
|
||||
@ -495,7 +527,7 @@ export function Accounts() {
|
||||
)}
|
||||
{qrStatus === 'expired' && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-gray-500">二维码已过期</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">二维码已过期</p>
|
||||
<button onClick={refreshQRCode} className="btn-ios-primary btn-sm">
|
||||
刷新二维码
|
||||
</button>
|
||||
@ -547,12 +579,12 @@ export function Accounts() {
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-600">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pwdShowBrowser}
|
||||
onChange={(e) => setPwdShowBrowser(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
||||
className="h-4 w-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
|
||||
/>
|
||||
显示浏览器(调试用)
|
||||
</label>
|
||||
@ -654,7 +686,7 @@ export function Accounts() {
|
||||
type="text"
|
||||
value={editingAccount.id}
|
||||
disabled
|
||||
className="input-ios"
|
||||
className="input-ios bg-slate-100 dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
@ -672,30 +704,17 @@ export function Accounts() {
|
||||
<textarea
|
||||
value={editCookie}
|
||||
onChange={(e) => setEditCookie(e.target.value)}
|
||||
className="input-ios h-20 resize-none font-mono text-xs"
|
||||
className="input-ios h-28 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>
|
||||
<div className="space-y-2">
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editUseAI}
|
||||
onChange={(e) => setEditUseAI(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
启用AI回复
|
||||
</label>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editUseDefault}
|
||||
onChange={(e) => setEditUseDefault(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
启用默认回复
|
||||
</label>
|
||||
</div>
|
||||
{/* AI回复和默认回复设置请在"自动回复"页面配置 */}
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 pt-2">
|
||||
提示:AI回复和默认回复设置请在"自动回复"页面配置
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={editSaving}>
|
||||
|
||||
@ -110,144 +110,133 @@ export function DataManagement() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">数据管理</h1>
|
||||
<p className="page-description">导入导出和清理系统数据</p>
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title ">
|
||||
<Download className="w-4 h-4" />
|
||||
数据导出
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{dataTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary-300
|
||||
hover:bg-primary-50/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
{/* 双列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 左列 - 数据导出 */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Download className="w-4 h-4 text-blue-500" />
|
||||
数据导出
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="space-y-3">
|
||||
{dataTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700
|
||||
hover:border-blue-300 dark:hover:border-blue-600 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900 dark:text-slate-100">{type.name}</h3>
|
||||
<p className="text-sm page-description">{type.desc}</p>
|
||||
<h3 className="font-medium text-slate-900 dark:text-slate-100 text-sm">{type.name}</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{type.desc}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleExport(type.id)}
|
||||
disabled={exporting !== null}
|
||||
className="btn-ios-secondary py-2 px-3 text-sm"
|
||||
className="btn-ios-secondary py-1.5 px-3 text-xs"
|
||||
>
|
||||
{exporting === type.id ? <ButtonLoading /> : <Download className="w-4 h-4" />}
|
||||
{exporting === type.id ? <ButtonLoading /> : <Download className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div
|
||||
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="bg-emerald-500 px-6 py-4 text-white">
|
||||
<h2 className="vben-card-title ">
|
||||
<Upload className="w-4 h-4" />
|
||||
数据导入
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div
|
||||
onClick={handleImportClick}
|
||||
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center
|
||||
hover:border-primary-400 transition-colors cursor-pointer"
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="w-12 h-12 text-blue-500 dark:text-blue-400 mx-auto mb-4 animate-spin" />
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-2">正在导入数据...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-12 h-12 text-slate-400 dark:text-slate-500 mx-auto mb-4" />
|
||||
<p className="text-slate-600 dark:text-slate-400 mb-2">点击选择文件上传</p>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">支持 JSON 格式的导出数据文件</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cleanup Section */}
|
||||
<div
|
||||
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="bg-red-500 px-6 py-4 text-white">
|
||||
<h2 className="vben-card-title ">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
数据清理
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{cleanupTypes.map((type) => (
|
||||
{/* 右列 */}
|
||||
<div className="space-y-4">
|
||||
{/* 数据导入 */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Upload className="w-4 h-4 text-emerald-500" />
|
||||
数据导入
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div
|
||||
key={type.id}
|
||||
className={`border rounded-xl p-4 ${
|
||||
type.danger
|
||||
? 'border-red-200 bg-red-50/50'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-amber-300 hover:bg-amber-50/30'
|
||||
} transition-colors`}
|
||||
onClick={handleImportClick}
|
||||
className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-6 text-center
|
||||
hover:border-emerald-400 dark:hover:border-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20
|
||||
transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{type.danger && (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-medium ${type.danger ? 'text-red-700' : 'text-slate-900 dark:text-slate-100'}`}>
|
||||
{type.name}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${type.danger ? 'text-red-600' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{type.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCleanup(type.id, type.danger)}
|
||||
disabled={cleaning !== null}
|
||||
className={`py-2 px-3 text-sm rounded-lg font-medium transition-colors ${
|
||||
type.danger
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'btn-ios-secondary'
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader2 className="w-10 h-10 text-emerald-500 mx-auto mb-3 animate-spin" />
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm">正在导入数据...</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Database className="w-10 h-10 text-slate-400 dark:text-slate-500 mx-auto mb-3" />
|
||||
<p className="text-slate-600 dark:text-slate-300 text-sm mb-1">点击选择文件上传</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">支持 JSON 格式的导出数据文件</p>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据清理 */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Trash2 className="w-4 h-4 text-amber-500" />
|
||||
数据清理
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="space-y-3">
|
||||
{cleanupTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border transition-colors ${
|
||||
type.danger
|
||||
? 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20'
|
||||
: 'border-slate-200 dark:border-slate-700 hover:border-amber-300 dark:hover:border-amber-600 hover:bg-amber-50/50 dark:hover:bg-amber-900/20'
|
||||
}`}
|
||||
>
|
||||
{cleaning === type.id ? <ButtonLoading /> : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
{type.danger && (
|
||||
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-medium text-sm ${type.danger ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100'}`}>
|
||||
{type.name}
|
||||
</h3>
|
||||
<p className={`text-xs mt-0.5 ${type.danger ? 'text-red-600 dark:text-red-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
||||
{type.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCleanup(type.id, type.danger)}
|
||||
disabled={cleaning !== null}
|
||||
className={`py-1.5 px-3 text-xs rounded-md font-medium transition-colors ${
|
||||
type.danger
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'btn-ios-secondary'
|
||||
}`}
|
||||
>
|
||||
{cleaning === type.id ? <ButtonLoading /> : '执行'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,16 +2,19 @@ import { useState, useEffect } from 'react'
|
||||
import { FileText, RefreshCw, Trash2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { getSystemLogs, clearSystemLogs, type SystemLog } from '@/api/admin'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Logs() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||
const [levelFilter, setLevelFilter] = useState('')
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getSystemLogs({ level: levelFilter || undefined })
|
||||
@ -26,8 +29,11 @@ export function Logs() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated) return
|
||||
if (!isAuthenticated || !token) return
|
||||
loadLogs()
|
||||
}, [levelFilter])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [_hasHydrated, isAuthenticated, token, levelFilter])
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('确定要清空所有系统日志吗?此操作不可恢复!')) return
|
||||
|
||||
@ -1,20 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import { ShieldAlert, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { getRiskLogs, clearRiskLogs, type RiskLog } from '@/api/admin'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
export function RiskLogs() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [logs, setLogs] = useState<RiskLog[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getRiskLogs({ cookie_id: selectedAccount || undefined })
|
||||
@ -29,6 +32,7 @@ export function RiskLogs() {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
@ -38,13 +42,15 @@ export function RiskLogs() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
loadLogs()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadLogs()
|
||||
}, [selectedAccount])
|
||||
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
|
||||
@ -82,39 +88,33 @@ export function RiskLogs() {
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="max-w-md">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-body">
|
||||
<div className="max-w-md">
|
||||
<div className="input-group">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onChange={setSelectedAccount}
|
||||
options={[
|
||||
{ value: '', label: '所有账号' },
|
||||
...accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="所有账号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs List */}
|
||||
<div
|
||||
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="bg-red-500 px-6 py-4 text-white
|
||||
flex items-center justify-between">
|
||||
<h2 className="vben-card-title ">
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<ShieldAlert className="w-4 h-4 text-amber-500" />
|
||||
风控日志
|
||||
</h2>
|
||||
<span className="badge-primary">{logs.length} 条记录</span>
|
||||
@ -146,7 +146,14 @@ export function RiskLogs() {
|
||||
<td>
|
||||
<span className="badge-danger">{log.risk_type}</span>
|
||||
</td>
|
||||
<td className="max-w-[300px] truncate text-slate-500 dark:text-slate-400">{log.message}</td>
|
||||
<td className="max-w-[300px] text-slate-500 dark:text-slate-400">
|
||||
<span
|
||||
className="block truncate cursor-help"
|
||||
title={log.message}
|
||||
>
|
||||
{log.message}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-slate-500 dark:text-slate-400 text-sm">
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</td>
|
||||
|
||||
@ -1,25 +1,19 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
|
||||
import { Users as UsersIcon, RefreshCw, Plus, Edit2, Trash2, Shield, ShieldOff, X, Loader2 } from 'lucide-react'
|
||||
import { getUsers, deleteUser, updateUser, addUser } from '@/api/admin'
|
||||
import { Users as UsersIcon, RefreshCw, Plus, Trash2 } from 'lucide-react'
|
||||
import { getUsers, deleteUser } from '@/api/admin'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { User } from '@/types'
|
||||
|
||||
export function Users() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [formUsername, setFormUsername] = useState('')
|
||||
const [formPassword, setFormPassword] = useState('')
|
||||
const [formEmail, setFormEmail] = useState('')
|
||||
const [formIsAdmin, setFormIsAdmin] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadUsers = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getUsers()
|
||||
@ -34,17 +28,13 @@ export function Users() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadUsers()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
const handleToggleAdmin = async (user: User) => {
|
||||
try {
|
||||
await updateUser(user.user_id, { is_admin: !user.is_admin })
|
||||
addToast({ type: 'success', message: user.is_admin ? '已取消管理员权限' : '已设为管理员' })
|
||||
loadUsers()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '操作失败' })
|
||||
}
|
||||
// TODO: 后端暂未实现 PUT /admin/users/{user_id} 接口
|
||||
const handleNotImplemented = (action: string) => {
|
||||
addToast({ type: 'warning', message: `${action}功能后端暂未实现` })
|
||||
}
|
||||
|
||||
const handleDelete = async (userId: number) => {
|
||||
@ -58,70 +48,6 @@ export function Users() {
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingUser(null)
|
||||
setFormUsername('')
|
||||
setFormPassword('')
|
||||
setFormEmail('')
|
||||
setFormIsAdmin(false)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setFormUsername(user.username)
|
||||
setFormPassword('')
|
||||
setFormEmail(user.email || '')
|
||||
setFormIsAdmin(user.is_admin)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingUser(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formUsername.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入用户名' })
|
||||
return
|
||||
}
|
||||
if (!editingUser && !formPassword) {
|
||||
addToast({ type: 'warning', message: '请输入密码' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
const data: Partial<User> & { password?: string } = {
|
||||
username: formUsername.trim(),
|
||||
email: formEmail.trim() || undefined,
|
||||
is_admin: formIsAdmin,
|
||||
}
|
||||
if (formPassword) data.password = formPassword
|
||||
await updateUser(editingUser.user_id, data)
|
||||
addToast({ type: 'success', message: '用户已更新' })
|
||||
} else {
|
||||
await addUser({
|
||||
username: formUsername.trim(),
|
||||
password: formPassword,
|
||||
email: formEmail.trim() || undefined,
|
||||
is_admin: formIsAdmin,
|
||||
})
|
||||
addToast({ type: 'success', message: '用户已添加' })
|
||||
}
|
||||
|
||||
closeModal()
|
||||
loadUsers()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '保存失败' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <PageLoading />
|
||||
}
|
||||
@ -135,11 +61,12 @@ export function Users() {
|
||||
<p className="page-description">管理系统用户账号</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={openAddModal} className="btn-ios-primary ">
|
||||
{/* TODO: 后端暂未实现 POST /admin/users 接口 */}
|
||||
<button onClick={() => handleNotImplemented('添加用户')} className="btn-ios-primary">
|
||||
<Plus className="w-4 h-4" />
|
||||
添加用户
|
||||
</button>
|
||||
<button onClick={loadUsers} className="btn-ios-secondary ">
|
||||
<button onClick={loadUsers} className="btn-ios-secondary">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
刷新
|
||||
</button>
|
||||
@ -147,14 +74,9 @@ export function Users() {
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div
|
||||
|
||||
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="vben-card-header
|
||||
flex items-center justify-between">
|
||||
<h2 className="vben-card-title ">
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header flex items-center justify-between">
|
||||
<h2 className="vben-card-title">
|
||||
<UsersIcon className="w-4 h-4" />
|
||||
用户列表
|
||||
</h2>
|
||||
@ -195,28 +117,10 @@ export function Users() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="">
|
||||
<button
|
||||
onClick={() => handleToggleAdmin(user)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
|
||||
title={user.is_admin ? '取消管理员' : '设为管理员'}
|
||||
>
|
||||
{user.is_admin ? (
|
||||
<ShieldOff className="w-4 h-4 text-amber-500" />
|
||||
) : (
|
||||
<Shield className="w-4 h-4 text-emerald-500" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleDelete(user.user_id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
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" />
|
||||
@ -231,81 +135,14 @@ export function Users() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑用户弹窗 */}
|
||||
{isModalOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-md">
|
||||
<div className="modal-header flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{editingUser ? '编辑用户' : '添加用户'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="p-1 hover:bg-slate-100 dark:bg-slate-700 rounded-lg">
|
||||
<X className="w-4 h-4 text-slate-500 dark:text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formUsername}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">
|
||||
密码{editingUser && '(留空则不修改)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder={editingUser ? '留空则不修改密码' : '请输入密码'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">邮箱(可选)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formEmail}
|
||||
onChange={(e) => setFormEmail(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="请输入邮箱"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-slate-700 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formIsAdmin}
|
||||
onChange={(e) => setFormIsAdmin(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
设为管理员
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={saving}>
|
||||
{saving ? (
|
||||
<span className="">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
保存中...
|
||||
</span>
|
||||
) : (
|
||||
'保存'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/* 提示信息 */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-body">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
提示:用户可通过注册页面自行注册账号。管理员可在此页面删除用户。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -141,13 +141,13 @@ export function Register() {
|
||||
|
||||
if (!registrationEnabled) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center max-w-sm">
|
||||
<div className="w-14 h-14 rounded-full bg-amber-100 mx-auto mb-4 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-8 text-center max-w-sm">
|
||||
<div className="w-14 h-14 rounded-full bg-amber-100 dark:bg-amber-900/30 mx-auto mb-4 flex items-center justify-center">
|
||||
<span className="text-2xl">🚫</span>
|
||||
</div>
|
||||
<h1 className="text-lg vben-card-title text-gray-900 mb-2">注册功能已关闭</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">管理员已关闭注册功能,如需账号请联系管理员</p>
|
||||
<h1 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-2">注册功能已关闭</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">管理员已关闭注册功能,如需账号请联系管理员</p>
|
||||
<Link to="/login" className="btn-ios-primary">
|
||||
返回登录
|
||||
</Link>
|
||||
@ -157,25 +157,25 @@ export function Register() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-6 transition-colors">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-600 text-white mx-auto mb-4 flex items-center justify-center">
|
||||
<MessageSquare className="w-6 h-6" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">用户注册</h1>
|
||||
<p className="text-sm page-description">创建您的账号以开始使用</p>
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">用户注册</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">创建您的账号以开始使用</p>
|
||||
</div>
|
||||
|
||||
{/* Register Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Username */}
|
||||
<div className="input-group">
|
||||
<label className="input-label">用户名</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
@ -190,7 +190,7 @@ export function Register() {
|
||||
<div className="input-group">
|
||||
<label className="input-label">邮箱地址</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@ -205,7 +205,7 @@ export function Register() {
|
||||
<div className="input-group">
|
||||
<label className="input-label">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
@ -216,7 +216,7 @@ export function Register() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
@ -227,7 +227,7 @@ export function Register() {
|
||||
<div className="input-group">
|
||||
<label className="input-label">确认密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
@ -259,12 +259,12 @@ export function Register() {
|
||||
src={captchaImage}
|
||||
alt="验证码"
|
||||
onClick={loadCaptcha}
|
||||
className="h-[38px] rounded border border-gray-300 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
className="h-[38px] rounded border border-slate-300 dark:border-slate-600 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
<p className={cn(
|
||||
'text-xs',
|
||||
captchaVerified ? 'text-green-600' : 'text-gray-400'
|
||||
captchaVerified ? 'text-green-600 dark:text-green-400' : 'text-slate-400'
|
||||
)}>
|
||||
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
|
||||
</p>
|
||||
@ -275,7 +275,7 @@ export function Register() {
|
||||
<label className="input-label">邮箱验证码</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
@ -307,7 +307,7 @@ export function Register() {
|
||||
</form>
|
||||
|
||||
{/* Login link */}
|
||||
<p className="text-center mt-6 text-gray-500 text-sm">
|
||||
<p className="text-center mt-6 text-slate-500 dark:text-slate-400 text-sm">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="text-blue-600 dark:text-blue-400 font-medium hover:text-indigo-700">
|
||||
立即登录
|
||||
@ -316,7 +316,7 @@ export function Register() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center mt-6 text-gray-400 text-xs">
|
||||
<p className="text-center mt-6 text-slate-400 text-xs">
|
||||
© {new Date().getFullYear()} 划算云服务器 ·
|
||||
<a href="https://www.hsykj.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:text-blue-400 ml-1 transition-colors">
|
||||
www.hsykj.com
|
||||
|
||||
@ -5,12 +5,15 @@ import { getCards, deleteCard, addCard, importCards } from '@/api/cards'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { Card, Account } from '@/types'
|
||||
|
||||
type ModalType = 'add' | 'import' | null
|
||||
|
||||
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[]>([])
|
||||
@ -28,6 +31,9 @@ export function Cards() {
|
||||
const [importLoading, setImportLoading] = useState(false)
|
||||
|
||||
const loadCards = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getCards(selectedAccount || undefined)
|
||||
@ -42,6 +48,9 @@ export function Cards() {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
@ -51,13 +60,15 @@ export function Cards() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
loadCards()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadCards()
|
||||
}, [selectedAccount])
|
||||
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这张卡券吗?')) return
|
||||
@ -182,18 +193,18 @@ export function Cards() {
|
||||
<div className="max-w-md">
|
||||
<div className="input-group">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setSelectedAccount}
|
||||
options={[
|
||||
{ value: '', label: '所有账号' },
|
||||
...accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="所有账号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ import { getAccountDetails } from '@/api/accounts'
|
||||
import { getKeywords } from '@/api/keywords'
|
||||
import { getOrders } from '@/api/orders'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { AccountDetail } from '@/types'
|
||||
|
||||
@ -17,6 +18,7 @@ interface DashboardStats {
|
||||
|
||||
export function Dashboard() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalAccounts: 0,
|
||||
@ -27,6 +29,7 @@ export function Dashboard() {
|
||||
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
||||
|
||||
const loadDashboard = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
@ -87,8 +90,9 @@ export function Dashboard() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadDashboard()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
if (loading) {
|
||||
return <PageLoading />
|
||||
|
||||
@ -3,31 +3,35 @@ import type { FormEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Truck, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getDeliveryRules, deleteDeliveryRule, updateDeliveryRule, addDeliveryRule } from '@/api/delivery'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { getCards } from '@/api/cards'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { DeliveryRule, Account } from '@/types'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { DeliveryRule, Card } from '@/types'
|
||||
|
||||
export function Delivery() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [rules, setRules] = useState<DeliveryRule[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
const [cards, setCards] = useState<Card[]>([])
|
||||
|
||||
// 弹窗状态
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
|
||||
const [formItemId, setFormItemId] = useState('')
|
||||
const [formDeliveryType, setFormDeliveryType] = useState<'card' | 'text' | 'api'>('card')
|
||||
const [formContent, setFormContent] = useState('')
|
||||
const [formKeyword, setFormKeyword] = useState('')
|
||||
const [formCardId, setFormCardId] = useState('')
|
||||
const [formDeliveryCount, setFormDeliveryCount] = useState(1)
|
||||
const [formDescription, setFormDescription] = useState('')
|
||||
const [formEnabled, setFormEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadRules = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getDeliveryRules(selectedAccount || undefined)
|
||||
const result = await getDeliveryRules()
|
||||
if (result.success) {
|
||||
setRules(result.data || [])
|
||||
}
|
||||
@ -38,27 +42,27 @@ export function Delivery() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
const loadCards = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
const result = await getCards()
|
||||
if (result.success) {
|
||||
setCards(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts()
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadCards()
|
||||
loadRules()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadRules()
|
||||
}, [selectedAccount])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
const handleToggleEnabled = async (rule: DeliveryRule) => {
|
||||
try {
|
||||
await updateDeliveryRule(rule.id, { enabled: !rule.enabled })
|
||||
await updateDeliveryRule(String(rule.id), { enabled: !rule.enabled })
|
||||
addToast({ type: 'success', message: rule.enabled ? '规则已禁用' : '规则已启用' })
|
||||
loadRules()
|
||||
} catch {
|
||||
@ -66,10 +70,10 @@ export function Delivery() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定要删除这条规则吗?')) return
|
||||
try {
|
||||
await deleteDeliveryRule(id)
|
||||
await deleteDeliveryRule(String(id))
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadRules()
|
||||
} catch {
|
||||
@ -78,23 +82,21 @@ export function Delivery() {
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!selectedAccount) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
return
|
||||
}
|
||||
setEditingRule(null)
|
||||
setFormItemId('')
|
||||
setFormDeliveryType('card')
|
||||
setFormContent('')
|
||||
setFormKeyword('')
|
||||
setFormCardId('')
|
||||
setFormDeliveryCount(1)
|
||||
setFormDescription('')
|
||||
setFormEnabled(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (rule: DeliveryRule) => {
|
||||
setEditingRule(rule)
|
||||
setFormItemId(rule.item_id || '')
|
||||
setFormDeliveryType((rule.delivery_type as 'card' | 'text' | 'api') || 'card')
|
||||
setFormContent(rule.delivery_content || '')
|
||||
setFormKeyword(rule.keyword)
|
||||
setFormCardId(String(rule.card_id))
|
||||
setFormDeliveryCount(rule.delivery_count)
|
||||
setFormDescription(rule.description || '')
|
||||
setFormEnabled(rule.enabled)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
@ -106,23 +108,27 @@ export function Delivery() {
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!selectedAccount && !editingRule) {
|
||||
addToast({ type: 'warning', message: '请先选择账号' })
|
||||
if (!formKeyword.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入触发关键词' })
|
||||
return
|
||||
}
|
||||
if (!formCardId) {
|
||||
addToast({ type: 'warning', message: '请选择卡券' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
cookie_id: editingRule?.cookie_id || selectedAccount,
|
||||
item_id: formItemId || undefined,
|
||||
delivery_type: formDeliveryType,
|
||||
delivery_content: formContent,
|
||||
keyword: formKeyword.trim(),
|
||||
card_id: Number(formCardId),
|
||||
delivery_count: formDeliveryCount,
|
||||
description: formDescription || undefined,
|
||||
enabled: formEnabled,
|
||||
}
|
||||
|
||||
if (editingRule) {
|
||||
await updateDeliveryRule(editingRule.id, data)
|
||||
await updateDeliveryRule(String(editingRule.id), data)
|
||||
addToast({ type: 'success', message: '规则已更新' })
|
||||
} else {
|
||||
await addDeliveryRule(data)
|
||||
@ -162,29 +168,6 @@ export function Delivery() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="max-w-md">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Rules List */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -204,10 +187,10 @@ export function Delivery() {
|
||||
<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>
|
||||
</tr>
|
||||
@ -225,20 +208,10 @@ export function Delivery() {
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<tr key={rule.id}>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.cookie_id}</td>
|
||||
<td className="text-sm">{rule.item_id || '所有商品'}</td>
|
||||
<td>
|
||||
{rule.delivery_type === 'card' ? (
|
||||
<span className="badge-info">卡密发货</span>
|
||||
) : rule.delivery_type === 'text' ? (
|
||||
<span className="badge-warning">固定文本</span>
|
||||
) : (
|
||||
<span className="badge-gray">其他</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate text-gray-500">
|
||||
{rule.delivery_content || '-'}
|
||||
</td>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.keyword}</td>
|
||||
<td className="text-sm">{rule.card_name || `卡券ID: ${rule.card_id}`}</td>
|
||||
<td className="text-center">{rule.delivery_count}</td>
|
||||
<td className="text-center text-slate-500">{rule.delivery_times || 0}</td>
|
||||
<td>
|
||||
{rule.enabled ? (
|
||||
<span className="badge-success">启用</span>
|
||||
@ -298,62 +271,67 @@ export function Delivery() {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">所属账号</label>
|
||||
<label className="input-label">触发关键词 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingRule?.cookie_id || selectedAccount || '请先选择账号'}
|
||||
disabled
|
||||
className="input-ios bg-gray-100 cursor-not-allowed"
|
||||
value={formKeyword}
|
||||
onChange={(e) => setFormKeyword(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="输入触发自动发货的关键词"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">关联卡券 *</label>
|
||||
<Select
|
||||
value={formCardId}
|
||||
onChange={setFormCardId}
|
||||
options={[
|
||||
{ value: '', label: '请选择卡券' },
|
||||
...cards.map((card) => ({
|
||||
value: String(card.id),
|
||||
label: card.keyword || card.card_content?.substring(0, 20) || `卡券 ${card.id}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="请选择卡券"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">商品ID(可选)</label>
|
||||
<label className="input-label">发货数量</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formItemId}
|
||||
onChange={(e) => setFormItemId(e.target.value)}
|
||||
type="number"
|
||||
value={formDeliveryCount}
|
||||
onChange={(e) => setFormDeliveryCount(Number(e.target.value) || 1)}
|
||||
className="input-ios"
|
||||
placeholder="留空表示适用于所有商品"
|
||||
min={1}
|
||||
placeholder="每次发货的卡密数量"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">发货类型</label>
|
||||
<select
|
||||
value={formDeliveryType}
|
||||
onChange={(e) => setFormDeliveryType(e.target.value as 'card' | 'text' | 'api')}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="card">卡密发货</option>
|
||||
<option value="text">固定文本</option>
|
||||
<option value="api">API接口</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">
|
||||
{formDeliveryType === 'card' ? '卡密说明' : formDeliveryType === 'api' ? 'API地址' : '发货内容'}
|
||||
</label>
|
||||
<label className="input-label">描述(可选)</label>
|
||||
<textarea
|
||||
value={formContent}
|
||||
onChange={(e) => setFormContent(e.target.value)}
|
||||
className="input-ios h-24 resize-none"
|
||||
placeholder={
|
||||
formDeliveryType === 'card'
|
||||
? '卡密将从卡券库中自动获取'
|
||||
: formDeliveryType === 'api'
|
||||
? '请输入API接口地址'
|
||||
: '请输入固定发货文本'
|
||||
}
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
className="input-ios h-20 resize-none"
|
||||
placeholder="规则描述,方便识别"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
启用此规则
|
||||
</label>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">启用此规则</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormEnabled(!formEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
formEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
||||
|
||||
@ -5,11 +5,14 @@ import { MessageCircle, RefreshCw, Plus, Edit2, Trash2, X, Loader2 } from 'lucid
|
||||
import { getItemReplies, deleteItemReply, addItemReply, updateItemReply } from '@/api/items'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { ItemReply, Account } from '@/types'
|
||||
|
||||
export function ItemReplies() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [replies, setReplies] = useState<ItemReply[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
@ -22,6 +25,7 @@ export function ItemReplies() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadReplies = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getItemReplies(selectedAccount || undefined)
|
||||
@ -36,6 +40,7 @@ export function ItemReplies() {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
@ -45,18 +50,20 @@ export function ItemReplies() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
loadReplies()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadReplies()
|
||||
}, [selectedAccount])
|
||||
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleDelete = async (reply: ItemReply) => {
|
||||
if (!confirm('确定要删除这条商品回复吗?')) return
|
||||
try {
|
||||
await deleteItemReply(id)
|
||||
await deleteItemReply(reply.cookie_id, reply.item_id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadReplies()
|
||||
} catch {
|
||||
@ -110,10 +117,10 @@ export function ItemReplies() {
|
||||
}
|
||||
|
||||
if (editingReply) {
|
||||
await updateItemReply(editingReply.id, data)
|
||||
await updateItemReply(editingReply.cookie_id, editingReply.item_id, data)
|
||||
addToast({ type: 'success', message: '回复已更新' })
|
||||
} else {
|
||||
await addItemReply(data)
|
||||
await addItemReply(selectedAccount, formItemId.trim(), data)
|
||||
addToast({ type: 'success', message: '回复已添加' })
|
||||
}
|
||||
|
||||
@ -156,20 +163,22 @@ export function ItemReplies() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="max-w-md">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="vben-card-body">
|
||||
<div className="max-w-xs">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onChange={setSelectedAccount}
|
||||
options={[
|
||||
{ value: '', label: '所有账号' },
|
||||
...accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="选择账号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@ -230,7 +239,7 @@ export function ItemReplies() {
|
||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(reply.id)}
|
||||
onClick={() => handleDelete(reply)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
|
||||
@ -1,23 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Package, RefreshCw, Search, Trash2, Download, CheckSquare, Square, Loader2 } from 'lucide-react'
|
||||
import { Package, RefreshCw, Search, Trash2, Download, CheckSquare, Square, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { getItems, deleteItem, fetchItemsFromAccount, batchDeleteItems } from '@/api/items'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { Item, Account } from '@/types'
|
||||
|
||||
export function Items() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set())
|
||||
const [fetching, setFetching] = useState(false)
|
||||
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0 })
|
||||
|
||||
const loadItems = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getItems(selectedAccount || undefined)
|
||||
@ -73,6 +79,9 @@ export function Items() {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
@ -82,18 +91,20 @@ export function Items() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
loadItems()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadItems()
|
||||
}, [selectedAccount])
|
||||
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleDelete = async (item: Item) => {
|
||||
if (!confirm('确定要删除这个商品吗?')) return
|
||||
try {
|
||||
await deleteItem(id)
|
||||
await deleteItem(item.cookie_id, item.item_id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
loadItems()
|
||||
} catch {
|
||||
@ -102,7 +113,7 @@ export function Items() {
|
||||
}
|
||||
|
||||
// 批量选择相关
|
||||
const toggleSelect = (id: string) => {
|
||||
const toggleSelect = (id: string | number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
@ -129,7 +140,11 @@ export function Items() {
|
||||
}
|
||||
if (!confirm(`确定要删除选中的 ${selectedIds.size} 个商品吗?`)) return
|
||||
try {
|
||||
await batchDeleteItems(Array.from(selectedIds))
|
||||
// 将选中的 ID 转换为 { cookie_id, item_id } 格式
|
||||
const itemsToDelete = items
|
||||
.filter((item) => selectedIds.has(item.id))
|
||||
.map((item) => ({ cookie_id: item.cookie_id, item_id: item.item_id }))
|
||||
await batchDeleteItems(itemsToDelete)
|
||||
addToast({ type: 'success', message: `成功删除 ${selectedIds.size} 个商品` })
|
||||
setSelectedIds(new Set())
|
||||
loadItems()
|
||||
@ -141,9 +156,11 @@ export function Items() {
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (!searchKeyword) return true
|
||||
const keyword = searchKeyword.toLowerCase()
|
||||
const title = item.item_title || item.title || ''
|
||||
const desc = item.item_detail || item.desc || ''
|
||||
return (
|
||||
item.title?.toLowerCase().includes(keyword) ||
|
||||
item.desc?.toLowerCase().includes(keyword) ||
|
||||
title.toLowerCase().includes(keyword) ||
|
||||
desc.toLowerCase().includes(keyword) ||
|
||||
item.item_id?.includes(keyword)
|
||||
)
|
||||
})
|
||||
@ -170,7 +187,7 @@ export function Items() {
|
||||
<button
|
||||
onClick={handleFetchItems}
|
||||
disabled={fetching}
|
||||
className="btn-ios-success"
|
||||
className="btn-ios-primary"
|
||||
>
|
||||
{fetching ? (
|
||||
<>
|
||||
@ -197,18 +214,18 @@ export function Items() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setSelectedAccount}
|
||||
options={[
|
||||
{ value: '', label: '所有账号' },
|
||||
...accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="所有账号"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">搜索商品</label>
|
||||
@ -288,22 +305,47 @@ export function Items() {
|
||||
</button>
|
||||
</td>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{item.cookie_id}</td>
|
||||
<td className="text-xs text-gray-500">{item.item_id}</td>
|
||||
<td className="max-w-[180px] truncate" title={item.title}>
|
||||
{item.title}
|
||||
<td className="text-xs text-gray-500">
|
||||
<a
|
||||
href={`https://www.goofish.com/item?id=${item.item_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-blue-500 flex items-center gap-1"
|
||||
>
|
||||
{item.item_id}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</td>
|
||||
<td className="max-w-[280px]">
|
||||
<div
|
||||
className="font-medium line-clamp-2 cursor-help"
|
||||
title={item.item_title || item.title || '-'}
|
||||
>
|
||||
{item.item_title || item.title || '-'}
|
||||
</div>
|
||||
{(item.item_detail || item.desc) && (
|
||||
<div
|
||||
className="text-xs text-gray-400 line-clamp-1 mt-0.5 cursor-help"
|
||||
title={item.item_detail || item.desc}
|
||||
>
|
||||
{item.item_detail || item.desc}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-amber-600 font-medium">
|
||||
{item.item_price || (item.price ? `¥${item.price}` : '-')}
|
||||
</td>
|
||||
<td className="text-amber-600 font-medium">¥{item.price}</td>
|
||||
<td>
|
||||
<span className={item.has_sku ? 'badge-success' : 'badge-gray'}>
|
||||
{item.has_sku ? '是' : '否'}
|
||||
<span className={(item.is_multi_spec || item.has_sku) ? 'badge-success' : 'badge-gray'}>
|
||||
{(item.is_multi_spec || item.has_sku) ? '是' : '否'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-gray-500">
|
||||
<td className="text-gray-500 text-xs">
|
||||
{item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
onClick={() => handleDelete(item)}
|
||||
className="table-action-btn hover:!bg-red-50"
|
||||
title="删除"
|
||||
>
|
||||
|
||||
@ -6,10 +6,13 @@ import { getKeywords, deleteKeyword, addKeyword, updateKeyword, exportKeywords,
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { Keyword, Account } from '@/types'
|
||||
|
||||
export function Keywords() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [keywords, setKeywords] = useState<Keyword[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
@ -25,6 +28,9 @@ export function Keywords() {
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const loadKeywords = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||
return
|
||||
}
|
||||
if (!selectedAccount) {
|
||||
setKeywords([])
|
||||
setLoading(false)
|
||||
@ -42,6 +48,9 @@ export function Keywords() {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
@ -54,14 +63,16 @@ export function Keywords() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
if (selectedAccount) {
|
||||
loadKeywords()
|
||||
}
|
||||
}, [selectedAccount])
|
||||
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||
|
||||
const openAddModal = () => {
|
||||
if (!selectedAccount) {
|
||||
@ -258,23 +269,23 @@ export function Keywords() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="max-w-md">
|
||||
<label className="input-label">选择账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
{accounts.length === 0 ? (
|
||||
<option value="">暂无账号</option>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
<div className="vben-card-body">
|
||||
<div className="max-w-md">
|
||||
<label className="input-label">选择账号</label>
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onChange={setSelectedAccount}
|
||||
options={
|
||||
accounts.length === 0
|
||||
? [{ value: '', label: '暂无账号' }]
|
||||
: accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
}))
|
||||
}
|
||||
placeholder="选择账号"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@ -328,12 +339,12 @@ export function Keywords() {
|
||||
keywords.map((keyword) => (
|
||||
<tr key={keyword.id}>
|
||||
<td className="font-medium">
|
||||
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded">
|
||||
<code className="bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded">
|
||||
{keyword.keyword}
|
||||
</code>
|
||||
</td>
|
||||
<td className="max-w-[300px]">
|
||||
<p className="truncate text-gray-600" title={keyword.reply}>
|
||||
<p className="truncate text-slate-600 dark:text-slate-300" title={keyword.reply}>
|
||||
{keyword.reply}
|
||||
</p>
|
||||
</td>
|
||||
@ -348,14 +359,14 @@ export function Keywords() {
|
||||
<div className="">
|
||||
<button
|
||||
onClick={() => openEditModal(keyword)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(keyword.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
@ -386,7 +397,7 @@ export function Keywords() {
|
||||
type="text"
|
||||
value={selectedAccount}
|
||||
disabled
|
||||
className="input-ios bg-gray-100 cursor-not-allowed"
|
||||
className="input-ios bg-slate-100 dark:bg-slate-700 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@ -408,17 +419,24 @@ export function Keywords() {
|
||||
placeholder="请输入自动回复内容"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fuzzyMatch}
|
||||
onChange={(e) => setFuzzyMatch(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400 focus:ring-primary-500"
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">使用模糊匹配</span>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">开启后,将在消息中模糊匹配该关键词</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFuzzyMatch(!fuzzyMatch)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
fuzzyMatch ? '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 ${
|
||||
fuzzyMatch ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
使用模糊匹配
|
||||
</label>
|
||||
<p className="text-xs text-gray-400">开启后,将在消息中模糊匹配该关键词</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
|
||||
@ -1,25 +1,30 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Mail, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getMessageNotifications, deleteMessageNotification, updateMessageNotification, addMessageNotification } from '@/api/notifications'
|
||||
import { Mail, RefreshCw, Plus, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getMessageNotifications, setMessageNotification, getNotificationChannels } from '@/api/notifications'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import type { MessageNotification } from '@/types'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { MessageNotification, NotificationChannel, Account } from '@/types'
|
||||
|
||||
export function MessageNotifications() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [notifications, setNotifications] = useState<MessageNotification[]>([])
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingNotification, setEditingNotification] = useState<MessageNotification | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formKeyword, setFormKeyword] = useState('')
|
||||
const [formAccountId, setFormAccountId] = useState('')
|
||||
const [formChannelId, setFormChannelId] = useState('')
|
||||
const [formEnabled, setFormEnabled] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadNotifications = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getMessageNotifications()
|
||||
@ -33,13 +38,38 @@ export function MessageNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadChannels = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
const result = await getNotificationChannels()
|
||||
if (result.success) {
|
||||
setChannels(result.data || [])
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadChannels()
|
||||
loadAccounts()
|
||||
loadNotifications()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
const handleToggleEnabled = async (notification: MessageNotification) => {
|
||||
try {
|
||||
await updateMessageNotification(notification.id, { enabled: !notification.enabled })
|
||||
await setMessageNotification(notification.cookie_id, notification.channel_id, !notification.enabled)
|
||||
addToast({ type: 'success', message: notification.enabled ? '通知已禁用' : '通知已启用' })
|
||||
loadNotifications()
|
||||
} catch {
|
||||
@ -47,64 +77,45 @@ export function MessageNotifications() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const handleDelete = async (notification: MessageNotification) => {
|
||||
if (!confirm('确定要删除这个消息通知吗?')) return
|
||||
try {
|
||||
await deleteMessageNotification(id)
|
||||
addToast({ type: 'success', message: '删除成功' })
|
||||
// 后端删除接口需要 notification_id,但我们没有这个字段
|
||||
// 改为禁用该通知
|
||||
await setMessageNotification(notification.cookie_id, notification.channel_id, false)
|
||||
addToast({ type: 'success', message: '通知已禁用' })
|
||||
loadNotifications()
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '删除失败' })
|
||||
addToast({ type: 'error', message: '操作失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
setEditingNotification(null)
|
||||
setFormName('')
|
||||
setFormKeyword('')
|
||||
setFormAccountId('')
|
||||
setFormChannelId('')
|
||||
setFormEnabled(true)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const openEditModal = (notification: MessageNotification) => {
|
||||
setEditingNotification(notification)
|
||||
setFormName(notification.name)
|
||||
setFormKeyword(notification.trigger_keyword || '')
|
||||
setFormChannelId(notification.channel_id || '')
|
||||
setFormEnabled(notification.enabled)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false)
|
||||
setEditingNotification(null)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!formName.trim()) {
|
||||
addToast({ type: 'warning', message: '请输入通知名称' })
|
||||
if (!formAccountId) {
|
||||
addToast({ type: 'warning', message: '请选择账号' })
|
||||
return
|
||||
}
|
||||
if (!formChannelId) {
|
||||
addToast({ type: 'warning', message: '请选择通知渠道' })
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
name: formName.trim(),
|
||||
trigger_keyword: formKeyword.trim() || undefined,
|
||||
channel_id: formChannelId.trim() || undefined,
|
||||
enabled: formEnabled,
|
||||
}
|
||||
|
||||
if (editingNotification) {
|
||||
await updateMessageNotification(editingNotification.id, data)
|
||||
addToast({ type: 'success', message: '通知已更新' })
|
||||
} else {
|
||||
await addMessageNotification(data)
|
||||
addToast({ type: 'success', message: '通知已添加' })
|
||||
}
|
||||
|
||||
await setMessageNotification(formAccountId, Number(formChannelId), formEnabled)
|
||||
addToast({ type: 'success', message: '通知已添加' })
|
||||
closeModal()
|
||||
loadNotifications()
|
||||
} catch {
|
||||
@ -156,8 +167,7 @@ export function MessageNotifications() {
|
||||
<table className="table-ios">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>触发关键词</th>
|
||||
<th>账号ID</th>
|
||||
<th>通知渠道</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
@ -166,24 +176,19 @@ export function MessageNotifications() {
|
||||
<tbody>
|
||||
{notifications.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-gray-500">
|
||||
<td colSpan={4} className="text-center py-8 text-gray-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Mail className="w-12 h-12 text-gray-300" />
|
||||
<p>暂无消息通知规则</p>
|
||||
<p>暂无消息通知配置</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<tr key={notification.id}>
|
||||
<td className="font-medium">{notification.name}</td>
|
||||
<td>
|
||||
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded text-sm">
|
||||
{notification.trigger_keyword || '全部消息'}
|
||||
</code>
|
||||
</td>
|
||||
<td className="text-sm text-gray-500">
|
||||
{notification.channel_id || '-'}
|
||||
<tr key={`${notification.cookie_id}-${notification.channel_id}`}>
|
||||
<td className="font-medium text-blue-600 dark:text-blue-400">{notification.cookie_id}</td>
|
||||
<td className="text-sm">
|
||||
{notification.channel_name || `渠道 ${notification.channel_id}`}
|
||||
</td>
|
||||
<td>
|
||||
{notification.enabled ? (
|
||||
@ -193,10 +198,10 @@ export function MessageNotifications() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(notification)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title={notification.enabled ? '禁用' : '启用'}
|
||||
>
|
||||
{notification.enabled ? (
|
||||
@ -206,15 +211,8 @@ export function MessageNotifications() {
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(notification)}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(notification.id)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||
onClick={() => handleDelete(notification)}
|
||||
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
@ -229,59 +227,64 @@ export function MessageNotifications() {
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 添加/编辑通知弹窗 */}
|
||||
{/* 添加通知弹窗 */}
|
||||
{isModalOpen && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content max-w-md">
|
||||
<div className="modal-header flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{editingNotification ? '编辑消息通知' : '添加消息通知'}
|
||||
</h2>
|
||||
<button onClick={closeModal} className="p-1 hover:bg-gray-100 rounded-lg">
|
||||
<h2 className="text-lg font-semibold">添加消息通知</h2>
|
||||
<button onClick={closeModal} className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg">
|
||||
<X className="w-4 h-4 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body space-y-4">
|
||||
<div>
|
||||
<label className="input-label">通知名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="如:订单通知"
|
||||
<div className="input-group">
|
||||
<label className="input-label">选择账号 *</label>
|
||||
<Select
|
||||
value={formAccountId}
|
||||
onChange={setFormAccountId}
|
||||
options={[
|
||||
{ value: '', label: '请选择账号' },
|
||||
...accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="请选择账号"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">触发关键词(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formKeyword}
|
||||
onChange={(e) => setFormKeyword(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="留空表示所有消息"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">通知渠道ID</label>
|
||||
<input
|
||||
type="text"
|
||||
<div className="input-group">
|
||||
<label className="input-label">选择通知渠道 *</label>
|
||||
<Select
|
||||
value={formChannelId}
|
||||
onChange={(e) => setFormChannelId(e.target.value)}
|
||||
className="input-ios"
|
||||
placeholder="输入渠道ID"
|
||||
onChange={setFormChannelId}
|
||||
options={[
|
||||
{ value: '', label: '请选择通知渠道' },
|
||||
...channels.map((channel) => ({
|
||||
value: String(channel.id),
|
||||
label: channel.name || channel.channel_name || `渠道 ${channel.id}`,
|
||||
})),
|
||||
]}
|
||||
placeholder="请选择通知渠道"
|
||||
/>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
启用此通知
|
||||
</label>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">启用此通知</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormEnabled(!formEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
formEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
||||
@ -289,7 +292,7 @@ export function MessageNotifications() {
|
||||
</button>
|
||||
<button type="submit" className="btn-ios-primary" disabled={saving}>
|
||||
{saving ? (
|
||||
<span className="">
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
保存中...
|
||||
</span>
|
||||
|
||||
@ -4,7 +4,9 @@ import { motion } from 'framer-motion'
|
||||
import { Bell, RefreshCw, Plus, Edit2, Trash2, Send, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||
import { getNotificationChannels, deleteNotificationChannel, updateNotificationChannel, testNotificationChannel, addNotificationChannel } from '@/api/notifications'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { NotificationChannel } from '@/types'
|
||||
|
||||
const channelTypeLabels: Record<string, string> = {
|
||||
@ -18,6 +20,7 @@ const channelTypeLabels: Record<string, string> = {
|
||||
|
||||
export function NotificationChannels() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
@ -29,6 +32,7 @@ export function NotificationChannels() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const loadChannels = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getNotificationChannels()
|
||||
@ -43,8 +47,9 @@ export function NotificationChannels() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadChannels()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
const handleToggleEnabled = async (channel: NotificationChannel) => {
|
||||
try {
|
||||
@ -280,20 +285,20 @@ export function NotificationChannels() {
|
||||
placeholder="如:我的邮箱通知"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">渠道类型</label>
|
||||
<select
|
||||
<Select
|
||||
value={formType}
|
||||
onChange={(e) => setFormType(e.target.value as typeof formType)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="email">邮件</option>
|
||||
<option value="wechat">微信</option>
|
||||
<option value="dingtalk">钉钉</option>
|
||||
<option value="feishu">飞书</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
onChange={(value) => setFormType(value as typeof formType)}
|
||||
options={[
|
||||
{ value: 'email', label: '邮件' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'dingtalk', label: '钉钉' },
|
||||
{ value: 'feishu', label: '飞书' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">配置 (JSON)</label>
|
||||
@ -307,15 +312,22 @@ export function NotificationChannels() {
|
||||
根据渠道类型填写对应的配置,如webhook_url、token等
|
||||
</p>
|
||||
</div>
|
||||
<label className=" text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formEnabled}
|
||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
||||
/>
|
||||
启用此渠道
|
||||
</label>
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">启用此渠道</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormEnabled(!formEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
formEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
||||
|
||||
@ -4,7 +4,9 @@ import { ShoppingCart, RefreshCw, Search, Trash2 } from 'lucide-react'
|
||||
import { getOrders, deleteOrder } from '@/api/orders'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading } from '@/components/common/Loading'
|
||||
import { Select } from '@/components/common/Select'
|
||||
import type { Order, Account } from '@/types'
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
@ -18,6 +20,7 @@ const statusMap: Record<string, { label: string; class: string }> = {
|
||||
|
||||
export function Orders() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
@ -26,6 +29,7 @@ export function Orders() {
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
|
||||
const loadOrders = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined)
|
||||
@ -40,6 +44,7 @@ export function Orders() {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
const data = await getAccounts()
|
||||
setAccounts(data)
|
||||
@ -49,13 +54,15 @@ export function Orders() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadAccounts()
|
||||
loadOrders()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadOrders()
|
||||
}, [selectedAccount, selectedStatus])
|
||||
}, [_hasHydrated, isAuthenticated, token, selectedAccount, selectedStatus])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除这个订单吗?')) return
|
||||
@ -102,49 +109,52 @@ export function Orders() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="input-label">筛选账号</label>
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有账号</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">订单状态</label>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="input-ios"
|
||||
>
|
||||
<option value="">所有状态</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="processed">已处理</option>
|
||||
<option value="shipped">已发货</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="input-label">搜索订单</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder="搜索订单ID或商品ID..."
|
||||
className="input-ios pl-12"
|
||||
<div 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>
|
||||
<Select
|
||||
value={selectedAccount}
|
||||
onChange={setSelectedAccount}
|
||||
options={[
|
||||
{ value: '', label: '所有账号' },
|
||||
...accounts.map((account) => ({
|
||||
value: account.id,
|
||||
label: account.id,
|
||||
})),
|
||||
]}
|
||||
placeholder="所有账号"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">订单状态</label>
|
||||
<Select
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
options={[
|
||||
{ value: '', label: '所有状态' },
|
||||
{ value: 'processing', label: '处理中' },
|
||||
{ value: 'processed', label: '已处理' },
|
||||
{ value: 'shipped', label: '已发货' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'cancelled', label: '已关闭' },
|
||||
]}
|
||||
placeholder="所有状态"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">搜索订单</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
placeholder="搜索订单ID或商品ID..."
|
||||
className="input-ios pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Search, ShoppingBag } from 'lucide-react'
|
||||
import { searchItems } from '@/api/search'
|
||||
import { getAccounts } from '@/api/accounts'
|
||||
import { Search, ShoppingBag, ExternalLink, MapPin, Heart } from 'lucide-react'
|
||||
import { searchItems, SearchResultItem } from '@/api/search'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { ButtonLoading } from '@/components/common/Loading'
|
||||
import type { Item, Account } from '@/types'
|
||||
|
||||
export function ItemSearch() {
|
||||
const { addToast } = useUIStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selectedAccount, setSelectedAccount] = useState('')
|
||||
const [results, setResults] = useState<Item[]>([])
|
||||
const [accounts, setAccounts] = useState<Account[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getAccounts().then(setAccounts).catch(() => {})
|
||||
}, [])
|
||||
const [results, setResults] = useState<SearchResultItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const handleSearch = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
@ -26,17 +19,29 @@ export function ItemSearch() {
|
||||
return
|
||||
}
|
||||
|
||||
addToast({ type: 'info', message: '正在搜索中,请稍候...' })
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await searchItems(keyword, selectedAccount || undefined)
|
||||
setResults([])
|
||||
const result = await searchItems(keyword.trim())
|
||||
|
||||
if (result.success) {
|
||||
setResults(result.data || [])
|
||||
setTotal(result.total || result.data.length)
|
||||
|
||||
if ((result.data || []).length === 0) {
|
||||
addToast({ type: 'info', message: '未找到相关商品' })
|
||||
} else {
|
||||
addToast({ type: 'success', message: `搜索完成,找到 ${result.data.length} 件商品` })
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
addToast({ type: 'warning', message: result.error })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
addToast({ type: 'error', message: '搜索失败' })
|
||||
addToast({ type: 'error', message: '搜索失败,请稍后重试' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -45,9 +50,14 @@ export function ItemSearch() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="page-title">商品搜索</h1>
|
||||
<p className="page-description">在闲鱼平台搜索商品</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="page-title">商品搜索</h1>
|
||||
<p className="page-description">在闲鱼平台搜索商品</p>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<span className="badge-primary">共 {total} 件商品</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
@ -56,87 +66,128 @@ export function ItemSearch() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="vben-card"
|
||||
>
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="输入关键词搜索商品..."
|
||||
className="input-ios pl-12"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full md:w-64">
|
||||
<select
|
||||
value={selectedAccount}
|
||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
||||
className="input-ios"
|
||||
<div className="vben-card-body">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 z-10" />
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="输入关键词搜索商品..."
|
||||
className="input-ios pl-12"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-ios-primary w-full md:w-32 flex items-center justify-center"
|
||||
>
|
||||
<option value="">使用所有账号搜索</option>
|
||||
{accounts.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-ios-primary w-full md:w-32 flex items-center justify-center"
|
||||
>
|
||||
{loading ? <ButtonLoading /> : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
{loading ? <ButtonLoading /> : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Results */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{results.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id || index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="vben-card group hover:shadow-ios-lg transition-all duration-300"
|
||||
>
|
||||
<div className="aspect-square bg-gray-100 relative overflow-hidden">
|
||||
{/* Placeholder for item image - in real app would use item.image_url */}
|
||||
<div className="absolute inset-0 flex items-center justify-center text-gray-300">
|
||||
<ShoppingBag className="w-12 h-12" />
|
||||
{results.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
>
|
||||
{results.map((item, index) => (
|
||||
<motion.a
|
||||
key={item.item_id || index}
|
||||
href={item.item_url || `https://www.goofish.com/item?id=${item.item_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.03 }}
|
||||
className="vben-card group hover:shadow-lg transition-all duration-300 overflow-hidden"
|
||||
>
|
||||
{/* 商品图片 */}
|
||||
<div className="aspect-square bg-slate-100 dark:bg-slate-800 relative overflow-hidden">
|
||||
{item.main_image ? (
|
||||
<img
|
||||
src={item.main_image}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-slate-300 dark:text-slate-600">
|
||||
<ShoppingBag className="w-12 h-12" />
|
||||
</div>
|
||||
)}
|
||||
{/* 外链图标 */}
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="bg-black/50 rounded-full p-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-gray-900 line-clamp-2 mb-2 h-12">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-red-500">¥{item.price}</span>
|
||||
<span className="text-sm text-gray-500">{item.cookie_id}</span>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<div className="p-3">
|
||||
<h3 className="font-medium text-slate-900 dark:text-slate-100 line-clamp-2 text-sm mb-2 min-h-[2.5rem]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-lg font-bold text-red-500">{item.price}</span>
|
||||
{item.want_count && item.want_count > 0 && (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||
<Heart className="w-3 h-3" />
|
||||
{item.want_count}人想要
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="truncate max-w-[60%]">{item.seller_name || '-'}</span>
|
||||
{item.area && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{item.area}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 标签 */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{item.tags.slice(0, 3).map((tag, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 rounded text-slate-600 dark:text-slate-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center text-sm text-gray-500">
|
||||
<span>ID: {item.item_id}</span>
|
||||
{item.has_sku && <span className="badge-info">多规格</span>}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</motion.a>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && results.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<ShoppingBag className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
<ShoppingBag className="w-16 h-16 text-slate-300 dark:text-slate-600 mx-auto mb-4" />
|
||||
<p>输入关键词开始搜索</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center gap-2 text-blue-500">
|
||||
<ButtonLoading />
|
||||
<span>正在搜索中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,16 +2,19 @@ import { useState, useEffect } from 'react'
|
||||
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw } from 'lucide-react'
|
||||
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend } from '@/api/settings'
|
||||
import { useUIStore } from '@/store/uiStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
|
||||
import type { SystemSettings } from '@/types'
|
||||
|
||||
export function Settings() {
|
||||
const { addToast } = useUIStore()
|
||||
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<SystemSettings | null>(null)
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await getSystemSettings()
|
||||
@ -26,8 +29,9 @@ export function Settings() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||
loadSettings()
|
||||
}, [])
|
||||
}, [_hasHydrated, isAuthenticated, token])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return
|
||||
@ -79,7 +83,7 @@ export function Settings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 max-w-4xl">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="page-header flex-between flex-wrap gap-4">
|
||||
<div>
|
||||
@ -98,192 +102,183 @@ export function Settings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* General Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
基础设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">允许用户注册</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">开启后允许新用户注册账号</p>
|
||||
{/* 双列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 左列 */}
|
||||
<div className="space-y-4">
|
||||
{/* General Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<SettingsIcon className="w-4 h-4" />
|
||||
基础设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">允许用户注册</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">开启后允许新用户注册账号</p>
|
||||
</div>
|
||||
<label className="switch-ios">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.registration_enabled ?? true}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
|
||||
/>
|
||||
<span className="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">显示默认登录信息</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">登录页面显示默认账号密码提示</p>
|
||||
</div>
|
||||
<label className="switch-ios">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.show_login_info ?? true}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
|
||||
/>
|
||||
<span className="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.registration_enabled ?? true}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
|
||||
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-slate-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">显示默认登录信息</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">登录页面显示默认账号密码提示</p>
|
||||
{/* AI Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Bot className="w-4 h-4 text-green-500" />
|
||||
AI 设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.ai_api_url || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_api_url: e.target.value } : null)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings?.ai_api_key || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
|
||||
placeholder="sk-..."
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">模型</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.ai_model || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_model: e.target.value } : null)}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleTestAI} className="btn-ios-secondary">
|
||||
测试 AI 连接
|
||||
</button>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.show_login_info ?? true}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
|
||||
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-slate-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<Bot className="w-4 h-4 text-green-500" />
|
||||
AI 设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.ai_api_url || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_api_url: e.target.value } : null)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="input-ios"
|
||||
/>
|
||||
{/* 右列 */}
|
||||
<div className="space-y-4">
|
||||
{/* Email Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Mail className="w-4 h-4 text-amber-500" />
|
||||
邮件设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">SMTP 服务器</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.smtp_host || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_host: e.target.value } : null)}
|
||||
placeholder="smtp.example.com"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings?.smtp_port || 465}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_port: parseInt(e.target.value) } : null)}
|
||||
placeholder="465"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">发件人邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings?.smtp_user || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_user: e.target.value } : null)}
|
||||
placeholder="noreply@example.com"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">密码/授权码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings?.smtp_password || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
|
||||
placeholder="••••••••"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleTestEmail} className="btn-ios-secondary">
|
||||
发送测试邮件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings?.ai_api_key || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
|
||||
placeholder="sk-..."
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">模型</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.ai_model || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, ai_model: e.target.value } : null)}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleTestAI} className="btn-ios-secondary">
|
||||
测试 AI 连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-amber-500" />
|
||||
邮件设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">SMTP 服务器</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings?.smtp_host || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_host: e.target.value } : null)}
|
||||
placeholder="smtp.example.com"
|
||||
className="input-ios"
|
||||
/>
|
||||
{/* Security Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title">
|
||||
<Shield className="w-4 h-4 text-red-500" />
|
||||
安全设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings?.smtp_port || 465}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_port: parseInt(e.target.value) } : null)}
|
||||
placeholder="465"
|
||||
className="input-ios"
|
||||
/>
|
||||
<div className="vben-card-body">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">启用登录验证码</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">登录时需要输入图形验证码</p>
|
||||
</div>
|
||||
<label className="switch-ios">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.login_captcha_enabled ?? false}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
|
||||
/>
|
||||
<span className="switch-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="input-group">
|
||||
<label className="input-label">发件人邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings?.smtp_user || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_user: e.target.value } : null)}
|
||||
placeholder="noreply@example.com"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label className="input-label">密码/授权码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings?.smtp_password || ''}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
|
||||
placeholder="••••••••"
|
||||
className="input-ios"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleTestEmail} className="btn-ios-secondary">
|
||||
发送测试邮件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div className="vben-card">
|
||||
<div className="vben-card-header">
|
||||
<h2 className="vben-card-title flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-red-500" />
|
||||
安全设置
|
||||
</h2>
|
||||
</div>
|
||||
<div className="vben-card-body">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">启用登录验证码</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">登录时需要输入图形验证码</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings?.login_captcha_enabled ?? false}
|
||||
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
|
||||
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
|
||||
peer-checked:after:translate-x-full peer-checked:after:border-white
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-slate-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -6,9 +6,11 @@ interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
_hasHydrated: boolean
|
||||
setAuth: (token: string, user: User) => void
|
||||
clearAuth: () => void
|
||||
updateUser: (user: Partial<User>) => void
|
||||
setHasHydrated: (state: boolean) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
@ -17,6 +19,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
_hasHydrated: false,
|
||||
|
||||
setAuth: (token, user) => {
|
||||
localStorage.setItem('auth_token', token)
|
||||
@ -35,6 +38,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: state.user ? { ...state.user, ...userData } : null,
|
||||
}))
|
||||
},
|
||||
|
||||
setHasHydrated: (state) => {
|
||||
set({ _hasHydrated: state })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
@ -43,6 +50,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
state?.setHasHydrated(true)
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@ -108,13 +108,60 @@
|
||||
}
|
||||
|
||||
.vben-card-title {
|
||||
@apply text-base font-semibold text-slate-800 dark:text-slate-100;
|
||||
@apply text-base font-semibold text-slate-800 dark:text-slate-100 flex items-center gap-2;
|
||||
}
|
||||
|
||||
.vben-card-body {
|
||||
@apply p-5;
|
||||
}
|
||||
|
||||
/* ==================== 开关组件 ==================== */
|
||||
.switch-ios {
|
||||
@apply relative inline-flex items-center cursor-pointer;
|
||||
}
|
||||
|
||||
.switch-ios input {
|
||||
@apply sr-only;
|
||||
}
|
||||
|
||||
.switch-ios .switch-slider {
|
||||
@apply w-11 h-6 bg-slate-200 dark:bg-slate-600 rounded-full
|
||||
transition-colors duration-200 ease-in-out
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
|
||||
after:bg-white after:border-slate-300 after:border after:rounded-full
|
||||
after:h-5 after:w-5 after:transition-all after:duration-200;
|
||||
}
|
||||
|
||||
.switch-ios input:checked + .switch-slider {
|
||||
@apply bg-blue-500;
|
||||
}
|
||||
|
||||
.switch-ios input:checked + .switch-slider::after {
|
||||
transform: translateX(100%);
|
||||
border-color: white;
|
||||
}
|
||||
|
||||
.switch-ios input:focus + .switch-slider {
|
||||
@apply ring-2 ring-blue-300 dark:ring-blue-800 ring-offset-2 dark:ring-offset-slate-800;
|
||||
}
|
||||
|
||||
/* ==================== 勾选框组件 ==================== */
|
||||
.checkbox-ios {
|
||||
@apply w-4 h-4 rounded border-slate-300 dark:border-slate-600
|
||||
text-blue-500 bg-white dark:bg-slate-700
|
||||
focus:ring-2 focus:ring-blue-500 focus:ring-offset-0
|
||||
transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.checkbox-ios:checked {
|
||||
@apply bg-blue-500 border-blue-500;
|
||||
}
|
||||
|
||||
/* 勾选框标签组合 */
|
||||
.checkbox-label {
|
||||
@apply flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer;
|
||||
}
|
||||
|
||||
/* 兼容旧类名 */
|
||||
.glass-card {
|
||||
@apply vben-card;
|
||||
@ -198,6 +245,20 @@
|
||||
@apply input-ios border-red-500 focus:border-red-500 focus:ring-red-500;
|
||||
}
|
||||
|
||||
/* 自定义下拉框样式 */
|
||||
select.input-ios {
|
||||
@apply appearance-none cursor-pointer
|
||||
bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2364748b%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')]
|
||||
bg-[length:1.25rem_1.25rem]
|
||||
bg-[right_0.5rem_center]
|
||||
bg-no-repeat
|
||||
pr-10;
|
||||
}
|
||||
|
||||
.dark select.input-ios {
|
||||
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2394a3b8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E');
|
||||
}
|
||||
|
||||
/* 输入框组 */
|
||||
.input-group {
|
||||
@apply space-y-1.5;
|
||||
|
||||
@ -54,14 +54,21 @@ export interface Keyword {
|
||||
|
||||
// 商品相关类型
|
||||
export interface Item {
|
||||
id: string
|
||||
id: string | number
|
||||
cookie_id: string
|
||||
item_id: string
|
||||
title: string
|
||||
title?: string
|
||||
item_title?: string
|
||||
desc?: string
|
||||
price: string
|
||||
has_sku: boolean
|
||||
multi_delivery: boolean
|
||||
item_description?: string
|
||||
item_detail?: string
|
||||
item_category?: string
|
||||
price?: string
|
||||
item_price?: string
|
||||
has_sku?: boolean
|
||||
is_multi_spec?: number | boolean
|
||||
multi_delivery?: boolean
|
||||
multi_quantity_delivery?: number | boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@ -114,18 +121,20 @@ export interface Card {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 发货规则相关类型
|
||||
// 发货规则相关类型 - 匹配后端接口
|
||||
export interface DeliveryRule {
|
||||
id: string
|
||||
cookie_id: string
|
||||
item_id?: string
|
||||
keyword?: string
|
||||
delivery_type: 'card' | 'text' | 'api'
|
||||
delivery_content?: string
|
||||
api_url?: string
|
||||
api_method?: string
|
||||
api_params?: string
|
||||
id: number
|
||||
keyword: string
|
||||
card_id: number
|
||||
delivery_count: number
|
||||
enabled: boolean
|
||||
description?: string
|
||||
delivery_times?: number
|
||||
card_name?: string
|
||||
card_type?: string
|
||||
is_multi_spec?: boolean
|
||||
spec_name?: string
|
||||
spec_value?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
@ -145,18 +154,13 @@ export interface NotificationChannel {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 消息通知相关类型
|
||||
// 消息通知相关类型 - 匹配后端接口
|
||||
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
|
||||
export interface MessageNotification {
|
||||
id: string
|
||||
cookie_id?: string
|
||||
name: string
|
||||
notification_type?: string
|
||||
trigger_keyword?: string
|
||||
channel_id?: string
|
||||
channel_ids?: string[]
|
||||
cookie_id: string
|
||||
channel_id: number
|
||||
channel_name?: string
|
||||
enabled: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// 系统设置相关类型
|
||||
|
||||
@ -61,7 +61,27 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/admin': {
|
||||
'/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,
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user