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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/authStore'
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { MainLayout } from '@/components/layout/MainLayout'
|
import { MainLayout } from '@/components/layout/MainLayout'
|
||||||
@ -25,24 +25,34 @@ import { verifyToken } from '@/api/auth'
|
|||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
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 [isChecking, setIsChecking] = useState(true)
|
||||||
const [isValid, setIsValid] = useState(false)
|
const [isValid, setIsValid] = useState(false)
|
||||||
|
const hasCheckedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 等待 zustand persist 完成 hydration
|
||||||
|
if (!_hasHydrated) return
|
||||||
|
|
||||||
|
// 防止重复检查
|
||||||
|
if (hasCheckedRef.current) return
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
const token = localStorage.getItem('auth_token')
|
// 优先使用 store 中的 token,其次是 localStorage
|
||||||
|
const token = storeToken || localStorage.getItem('auth_token')
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
setIsValid(false)
|
setIsValid(false)
|
||||||
|
hasCheckedRef.current = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已经认证,直接通过
|
// 如果 store 中已经认证且有用户信息,直接通过
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && storeToken) {
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
setIsValid(true)
|
setIsValid(true)
|
||||||
|
hasCheckedRef.current = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,14 +75,15 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
setIsValid(false)
|
setIsValid(false)
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
|
hasCheckedRef.current = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
checkAuth()
|
checkAuth()
|
||||||
}, [isAuthenticated, setAuth, clearAuth])
|
}, [_hasHydrated, isAuthenticated, storeToken, setAuth, clearAuth])
|
||||||
|
|
||||||
// 显示加载状态
|
// 等待 hydration 或检查完成
|
||||||
if (isChecking) {
|
if (!_hasHydrated || isChecking) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
<div 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 { get, post, put, del } from '@/utils/request'
|
||||||
import type { Account, AccountDetail, ApiResponse } from '@/types'
|
import type { Account, AccountDetail, ApiResponse } from '@/types'
|
||||||
|
|
||||||
// 获取账号列表
|
// 获取账号列表(返回账号ID数组)
|
||||||
export const getAccounts = (): Promise<Account[]> => {
|
export const getAccounts = async (): Promise<Account[]> => {
|
||||||
return get('/cookies')
|
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[]> => {
|
export const getAccountDetails = async (): Promise<AccountDetail[]> => {
|
||||||
return get('/cookies/details')
|
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> => {
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新账号
|
// 更新账号 Cookie 值
|
||||||
export const updateAccount = (id: string, data: Partial<Account>): Promise<ApiResponse> => {
|
export const updateAccountCookie = (id: string, value: string): Promise<ApiResponse> => {
|
||||||
return put(`/cookies/${id}`, data)
|
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'
|
import type { ApiResponse, User } from '@/types'
|
||||||
|
|
||||||
// ========== 用户管理 ==========
|
// ========== 用户管理 ==========
|
||||||
|
|
||||||
// 获取用户列表
|
// 获取用户列表
|
||||||
export const getUsers = (): Promise<{ success: boolean; data?: User[] }> => {
|
export const getUsers = async (): Promise<{ success: boolean; data?: User[] }> => {
|
||||||
return get('/admin/users')
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加用户
|
// TODO: 后端暂未实现 POST /admin/users 接口
|
||||||
export const addUser = (data: { username: string; password: string; email?: string; is_admin?: boolean }): Promise<ApiResponse> => {
|
// export const addUser = ...
|
||||||
return post('/admin/users', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新用户
|
// TODO: 后端暂未实现 PUT /admin/users/{userId} 接口
|
||||||
export const updateUser = (userId: number, data: Partial<User & { password?: string }>): Promise<ApiResponse> => {
|
// export const updateUser = ...
|
||||||
return put(`/admin/users/${userId}`, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除用户
|
// 删除用户
|
||||||
export const deleteUser = (userId: number): Promise<ApiResponse> => {
|
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()
|
const query = new URLSearchParams()
|
||||||
if (params?.page) query.set('page', String(params.page))
|
if (params?.page) query.set('page', String(params.page))
|
||||||
if (params?.limit) query.set('limit', String(params.limit))
|
if (params?.limit) query.set('limit', String(params.limit))
|
||||||
if (params?.level) query.set('level', params.level)
|
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()
|
const query = new URLSearchParams()
|
||||||
if (params?.page) query.set('page', String(params.page))
|
if (params?.page) query.set('page', String(params.page))
|
||||||
if (params?.limit) query.set('limit', String(params.limit))
|
if (params?.limit) query.set('limit', String(params.limit))
|
||||||
if (params?.cookie_id) query.set('cookie_id', params.cookie_id)
|
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'
|
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'
|
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'
|
import type { ApiResponse, DeliveryRule } from '@/types'
|
||||||
|
|
||||||
// 获取发货规则列表
|
// 获取发货规则列表
|
||||||
export const getDeliveryRules = (accountId?: string): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
|
export const getDeliveryRules = async (): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
|
||||||
const url = accountId ? `/api/delivery-rules?cookie_id=${accountId}` : '/api/delivery-rules'
|
const result = await get<DeliveryRule[] | { rules?: DeliveryRule[] }>('/delivery-rules')
|
||||||
return get(url)
|
// 后端可能返回数组或 { rules: [...] } 格式
|
||||||
|
const data = Array.isArray(result) ? result : (result.rules || [])
|
||||||
|
return { success: true, data }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加发货规则
|
// 添加发货规则
|
||||||
export const addDeliveryRule = (data: Partial<DeliveryRule>): Promise<ApiResponse> => {
|
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> => {
|
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> => {
|
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'
|
import type { Item, ItemReply, ApiResponse } from '@/types'
|
||||||
|
|
||||||
// 获取商品列表
|
// 获取商品列表
|
||||||
export const getItems = (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
|
export const getItems = async (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
|
||||||
const params = cookieId ? `?cookie_id=${cookieId}` : ''
|
const url = cookieId ? `/items/cookie/${cookieId}` : '/items'
|
||||||
return get(`/items${params}`)
|
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[] }> => {
|
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> => {
|
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> => {
|
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> => {
|
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> => {
|
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[] }> => {
|
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
|
||||||
return get('/api/message-notifications')
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加消息通知
|
// 设置消息通知 - 后端接口需要 cookie_id 作为路径参数
|
||||||
export const addMessageNotification = (data: Partial<MessageNotification>): Promise<ApiResponse> => {
|
export const setMessageNotification = (cookieId: string, channelId: number, enabled: boolean): Promise<ApiResponse> => {
|
||||||
return post('/api/message-notifications', data)
|
return post(`/message-notifications/${cookieId}`, { channel_id: channelId, enabled })
|
||||||
}
|
|
||||||
|
|
||||||
// 更新消息通知
|
|
||||||
export const updateMessageNotification = (notificationId: string, data: Partial<MessageNotification>): Promise<ApiResponse> => {
|
|
||||||
return put(`/api/message-notifications/${notificationId}`, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除消息通知
|
// 删除消息通知
|
||||||
export const deleteMessageNotification = (notificationId: string): Promise<ApiResponse> => {
|
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 { 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[] }> => {
|
export const searchItems = async (
|
||||||
return post('/api/items/search', { keyword, cookie_id: accountId })
|
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'
|
import type { ApiResponse, SystemSettings } from '@/types'
|
||||||
|
|
||||||
// 获取系统设置
|
// 获取系统设置
|
||||||
export const getSystemSettings = (): Promise<{ success: boolean; data?: SystemSettings }> => {
|
export const getSystemSettings = async (): Promise<{ success: boolean; data?: SystemSettings }> => {
|
||||||
return get('/system-settings')
|
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> => {
|
export const updateSystemSettings = async (data: Partial<SystemSettings>): Promise<ApiResponse> => {
|
||||||
// 逐个更新设置项
|
// 逐个更新设置项,确保 value 是字符串
|
||||||
const promises = Object.entries(data).map(([key, value]) =>
|
const promises = Object.entries(data).map(([key, value]) => {
|
||||||
put(`/system-settings/${key}`, { value })
|
// 将布尔值和数字转换为字符串
|
||||||
)
|
const stringValue = typeof value === 'boolean' ? (value ? 'true' : 'false')
|
||||||
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
|
: typeof value === 'number' ? String(value)
|
||||||
|
: value
|
||||||
|
return put(`/system-settings/${key}`, { value: stringValue })
|
||||||
|
})
|
||||||
|
await Promise.all(promises)
|
||||||
|
return { success: true, message: '设置已保存' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取 AI 设置
|
// 获取 AI 设置
|
||||||
@ -25,9 +41,11 @@ export const updateAISettings = (data: Record<string, unknown>): Promise<ApiResp
|
|||||||
return put('/ai-reply-settings', data)
|
return put('/ai-reply-settings', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试 AI 连接
|
// TODO: 测试 AI 连接需要指定 cookie_id,后端接口为 POST /ai-reply-test/{cookie_id}
|
||||||
export const testAIConnection = (): Promise<ApiResponse> => {
|
// 系统设置页面的测试按钮暂时无法使用,需要先选择账号
|
||||||
return post('/ai-reply-test/default')
|
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: '设置已保存' }))
|
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 测试邮件发送
|
// TODO: 测试邮件发送功能需要后端支持 type: 'test' 参数
|
||||||
export const testEmailSend = (email: string): Promise<ApiResponse> => {
|
// 当前后端的 /send-verification-code 接口只支持 'register' 和 'login' 类型
|
||||||
return post('/send-verification-code', { email, type: 'test' })
|
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 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
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>
|
</motion.div>
|
||||||
{text && (
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (fullScreen) {
|
if (fullScreen) {
|
||||||
return (
|
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}
|
{content}
|
||||||
</div>
|
</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 */}
|
{/* Main content area */}
|
||||||
<div className="lg:ml-56 min-h-screen flex flex-col">
|
<div className="lg:ml-56 min-h-screen flex flex-col">
|
||||||
|
{/* Fixed header area */}
|
||||||
|
<div className="sticky top-0 z-30 bg-slate-50 dark:bg-slate-900">
|
||||||
{/* Top navbar */}
|
{/* Top navbar */}
|
||||||
<TopNavbar />
|
<TopNavbar />
|
||||||
|
|
||||||
{/* Tabs bar */}
|
{/* Tabs bar */}
|
||||||
<TabsBar />
|
<TabsBar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Page content */}
|
{/* Page content */}
|
||||||
<main className="flex-1 p-4 lg:p-6">
|
<main className="flex-1 p-4 lg:p-6">
|
||||||
|
|||||||
@ -74,8 +74,9 @@ export function Sidebar() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150',
|
'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
|
||||||
isActive && 'bg-blue-600 text-white shadow-sm'
|
? '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(
|
className={cn(
|
||||||
'fixed top-0 left-0 h-screen w-56 z-50',
|
'fixed top-0 left-0 h-screen w-56 z-50',
|
||||||
'bg-[#001529] text-white',
|
'bg-white dark:bg-[#001529]',
|
||||||
'flex flex-col',
|
'flex flex-col',
|
||||||
'transition-transform duration-200 ease-out',
|
'transition-transform duration-200 ease-out',
|
||||||
|
'border-r border-slate-200 dark:border-slate-700',
|
||||||
'lg:translate-x-0',
|
'lg:translate-x-0',
|
||||||
!sidebarMobileOpen && '-translate-x-full lg:translate-x-0'
|
!sidebarMobileOpen && '-translate-x-full lg:translate-x-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* 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="flex items-center gap-2.5">
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
|
||||||
<MessageSquare className="w-4 h-4 text-white" />
|
<MessageSquare className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-sm text-white">闲鱼管理系统</span>
|
<span className="font-semibold text-sm text-slate-900 dark:text-white">闲鱼管理系统</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={closeMobileSidebar}
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -139,7 +141,7 @@ export function Sidebar() {
|
|||||||
{user?.is_admin && (
|
{user?.is_admin && (
|
||||||
<>
|
<>
|
||||||
<div className="pt-4 pb-2 px-3">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const routeTitles: Record<string, string> = {
|
|||||||
'/dashboard': '仪表盘',
|
'/dashboard': '仪表盘',
|
||||||
'/accounts': '账号管理',
|
'/accounts': '账号管理',
|
||||||
'/items': '商品管理',
|
'/items': '商品管理',
|
||||||
'/keywords': '关键词管理',
|
'/keywords': '自动回复',
|
||||||
'/item-replies': '指定商品回复',
|
'/item-replies': '指定商品回复',
|
||||||
'/orders': '订单管理',
|
'/orders': '订单管理',
|
||||||
'/cards': '卡券管理',
|
'/cards': '卡券管理',
|
||||||
|
|||||||
@ -1,128 +1,217 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { MessageSquare, Github, Globe, Users, Heart } from 'lucide-react'
|
import { MessageSquare, Github, Heart, Code, MessageCircle, Users, UserCheck, Bot, Truck, Bell, BarChart3, X } from 'lucide-react'
|
||||||
|
|
||||||
export function About() {
|
export function About() {
|
||||||
|
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
<div className="max-w-5xl mx-auto space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-6">
|
||||||
<div
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
mx-auto mb-4 flex items-center justify-center shadow-md"
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
className="w-24 h-24 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-600
|
|
||||||
mx-auto mb-6 flex items-center justify-center shadow-lg"
|
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-12 h-12 text-white" />
|
<MessageSquare className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<motion.h1
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
initial={{ y: 20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
|
|
||||||
className="text-3xl font-bold text-slate-900 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
闲鱼自动回复管理系统
|
闲鱼自动回复管理系统
|
||||||
</motion.h1>
|
</h1>
|
||||||
<motion.p
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
initial={{ y: 20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
|
|
||||||
className="text-slate-500 dark:text-slate-400 mt-2"
|
|
||||||
>
|
|
||||||
智能管理您的闲鱼店铺,提升客服效率
|
智能管理您的闲鱼店铺,提升客服效率
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<div
|
<div className="vben-card">
|
||||||
initial={{ y: 20, opacity: 0 }}
|
<div className="vben-card-header">
|
||||||
animate={{ y: 0, opacity: 1 }}
|
<h2 className="vben-card-title">主要功能</h2>
|
||||||
|
</div>
|
||||||
className="vben-card"
|
<div className="vben-card-body">
|
||||||
>
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<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: '同时管理多个账号', icon: UserCheck, color: 'text-blue-500' },
|
||||||
{ title: '智能自动回复', desc: '基于关键词的智能消息回复' },
|
{ title: '智能回复', desc: '关键词自动回复', icon: MessageSquare, color: 'text-green-500' },
|
||||||
{ title: 'AI 助手', desc: '接入 AI 模型,智能处理复杂问题' },
|
{ title: 'AI 助手', desc: '智能处理复杂问题', icon: Bot, color: 'text-purple-500' },
|
||||||
{ title: '自动发货', desc: '订单自动发货,支持卡密发货' },
|
{ title: '自动发货', desc: '支持卡密发货', icon: Truck, color: 'text-orange-500' },
|
||||||
{ title: '消息通知', desc: '多渠道消息推送通知' },
|
{ title: '消息通知', desc: '多渠道推送', icon: Bell, color: 'text-pink-500' },
|
||||||
{ title: '数据统计', desc: '订单、商品数据统计分析' },
|
{ title: '数据统计', desc: '订单商品分析', icon: BarChart3, color: 'text-cyan-500' },
|
||||||
].map((feature, index) => (
|
].map((feature, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-slate-800"
|
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800 flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
|
<div className={`w-10 h-10 rounded-lg bg-white dark:bg-slate-700 flex items-center justify-center shadow-sm ${feature.color}`}>
|
||||||
<div>
|
<feature.icon className="w-5 h-5" />
|
||||||
<p className="font-medium text-slate-900 dark:text-slate-100">{feature.title}</p>
|
</div>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">{feature.desc}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Contributors */}
|
||||||
<div
|
<div className="vben-card">
|
||||||
initial={{ y: 20, opacity: 0 }}
|
<div className="vben-card-header">
|
||||||
animate={{ y: 0, opacity: 1 }}
|
<h2 className="vben-card-title">
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
className="vben-card"
|
贡献者
|
||||||
>
|
</h2>
|
||||||
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4">相关链接</h2>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="vben-card-body">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href="https://github.com"
|
href="https://github.com/zhinianboke"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-3 p-4 rounded-xl bg-gray-900 text-white
|
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700
|
||||||
hover:bg-gray-800 transition-colors"
|
hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
|
||||||
>
|
>
|
||||||
<Github className="w-6 h-6" />
|
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||||
<span className="font-medium">GitHub</span>
|
<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>
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="https://github.com/legeling"
|
||||||
className="flex items-center gap-3 p-4 rounded-xl bg-blue-500 text-white
|
target="_blank"
|
||||||
hover:bg-blue-600 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<Globe className="w-6 h-6" />
|
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||||
<span className="font-medium">官方网站</span>
|
<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>
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<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
|
<a
|
||||||
href="#"
|
href="https://github.com/zhinianboke/xianyu-auto-reply"
|
||||||
className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500 text-white
|
target="_blank"
|
||||||
hover:bg-emerald-600 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<Users className="w-6 h-6" />
|
<Github className="w-4 h-4" />
|
||||||
<span className="font-medium">交流群</span>
|
<span>GitHub</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div
|
<div className="text-center py-4 text-slate-500 dark:text-slate-400 text-sm">
|
||||||
initial={{ y: 20, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
|
|
||||||
className="text-center py-6 text-slate-500 dark:text-slate-400"
|
|
||||||
>
|
|
||||||
<p className="flex items-center justify-center gap-1">
|
<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>
|
||||||
<p className="text-sm mt-2">
|
<p className="mt-1 text-xs">
|
||||||
赞助商:划算云服务器{' '}
|
赞助商:
|
||||||
<a
|
<a
|
||||||
href="https://www.hsykj.com"
|
href="https://www.hsykj.com"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2, Copy } from 'lucide-react'
|
||||||
import { getAccountDetails, deleteAccount, updateAccount, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
|
import { getAccountDetails, deleteAccount, updateAccountCookie, updateAccountStatus, updateAccountRemark, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
import type { AccountDetail } from '@/types'
|
import type { AccountDetail } from '@/types'
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ type ModalType = 'qrcode' | 'password' | 'manual' | 'edit' | null
|
|||||||
|
|
||||||
export function Accounts() {
|
export function Accounts() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
||||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||||
@ -34,12 +36,11 @@ export function Accounts() {
|
|||||||
// 编辑账号状态
|
// 编辑账号状态
|
||||||
const [editingAccount, setEditingAccount] = useState<AccountDetail | null>(null)
|
const [editingAccount, setEditingAccount] = useState<AccountDetail | null>(null)
|
||||||
const [editNote, setEditNote] = useState('')
|
const [editNote, setEditNote] = useState('')
|
||||||
const [editUseAI, setEditUseAI] = useState(false)
|
|
||||||
const [editUseDefault, setEditUseDefault] = useState(false)
|
|
||||||
const [editCookie, setEditCookie] = useState('')
|
const [editCookie, setEditCookie] = useState('')
|
||||||
const [editSaving, setEditSaving] = useState(false)
|
const [editSaving, setEditSaving] = useState(false)
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const data = await getAccountDetails()
|
const data = await getAccountDetails()
|
||||||
@ -52,8 +53,9 @@ export function Accounts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
// 清理扫码检查定时器
|
// 清理扫码检查定时器
|
||||||
const clearQrCheck = useCallback(() => {
|
const clearQrCheck = useCallback(() => {
|
||||||
@ -111,14 +113,22 @@ export function Accounts() {
|
|||||||
case 'scanned':
|
case 'scanned':
|
||||||
setQrStatus('scanned')
|
setQrStatus('scanned')
|
||||||
break
|
break
|
||||||
|
case 'processing':
|
||||||
|
// 正在处理中,显示已扫描状态
|
||||||
|
setQrStatus('scanned')
|
||||||
|
break
|
||||||
case 'success':
|
case 'success':
|
||||||
|
case 'already_processed':
|
||||||
|
// 登录成功或已处理完成
|
||||||
setQrStatus('success')
|
setQrStatus('success')
|
||||||
clearQrCheck()
|
clearQrCheck()
|
||||||
addToast({
|
addToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: result.account_info?.is_new_account
|
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
|
||||||
|
? `账号 ${result.account_info.account_id} 登录成功`
|
||||||
|
: '账号登录成功',
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
closeModal()
|
closeModal()
|
||||||
@ -227,7 +237,7 @@ export function Accounts() {
|
|||||||
|
|
||||||
const handleToggleEnabled = async (account: AccountDetail) => {
|
const handleToggleEnabled = async (account: AccountDetail) => {
|
||||||
try {
|
try {
|
||||||
await updateAccount(account.id, { enabled: !account.enabled })
|
await updateAccountStatus(account.id, !account.enabled)
|
||||||
addToast({ type: 'success', message: account.enabled ? '账号已禁用' : '账号已启用' })
|
addToast({ type: 'success', message: account.enabled ? '账号已禁用' : '账号已启用' })
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
} catch {
|
} catch {
|
||||||
@ -250,8 +260,6 @@ export function Accounts() {
|
|||||||
const openEditModal = (account: AccountDetail) => {
|
const openEditModal = (account: AccountDetail) => {
|
||||||
setEditingAccount(account)
|
setEditingAccount(account)
|
||||||
setEditNote(account.note || '')
|
setEditNote(account.note || '')
|
||||||
setEditUseAI(account.use_ai_reply || false)
|
|
||||||
setEditUseDefault(account.use_default_reply || false)
|
|
||||||
setEditCookie(account.cookie || '')
|
setEditCookie(account.cookie || '')
|
||||||
setActiveModal('edit')
|
setActiveModal('edit')
|
||||||
}
|
}
|
||||||
@ -262,12 +270,20 @@ export function Accounts() {
|
|||||||
|
|
||||||
setEditSaving(true)
|
setEditSaving(true)
|
||||||
try {
|
try {
|
||||||
await updateAccount(editingAccount.id, {
|
// 分别调用不同的 API 更新不同字段
|
||||||
note: editNote.trim() || undefined,
|
const promises: Promise<unknown>[] = []
|
||||||
use_ai_reply: editUseAI,
|
|
||||||
use_default_reply: editUseDefault,
|
// 更新备注
|
||||||
cookie: editCookie.trim() || undefined,
|
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: '账号信息已更新' })
|
addToast({ type: 'success', message: '账号信息已更新' })
|
||||||
closeModal()
|
closeModal()
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
@ -314,45 +330,45 @@ export function Accounts() {
|
|||||||
{/* 扫码登录 */}
|
{/* 扫码登录 */}
|
||||||
<button
|
<button
|
||||||
onClick={startQRCodeLogin}
|
onClick={startQRCodeLogin}
|
||||||
className="flex items-center gap-3 p-4 rounded-md border border-indigo-200
|
className="flex items-center gap-3 p-4 rounded-md border border-blue-200 dark:border-blue-800
|
||||||
bg-blue-50 hover:bg-blue-100 transition-colors text-left"
|
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">
|
<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" />
|
<QrCode className="w-4 h-4 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 text-sm">扫码登录</p>
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">扫码登录</p>
|
||||||
<p className="text-xs text-gray-500">推荐方式</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">推荐方式</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 账号密码登录 */}
|
{/* 账号密码登录 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveModal('password')}
|
onClick={() => setActiveModal('password')}
|
||||||
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
|
className="flex items-center gap-3 p-4 rounded-md border border-slate-200 dark:border-slate-700
|
||||||
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
|
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">
|
<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-gray-600" />
|
<Key className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 text-sm">账号密码</p>
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">账号密码</p>
|
||||||
<p className="text-xs text-gray-500">使用账号和密码</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">使用账号和密码</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 手动输入 */}
|
{/* 手动输入 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveModal('manual')}
|
onClick={() => setActiveModal('manual')}
|
||||||
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
|
className="flex items-center gap-3 p-4 rounded-md border border-slate-200 dark:border-slate-700
|
||||||
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
|
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">
|
<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-gray-600" />
|
<Edit2 className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900 text-sm">手动输入</p>
|
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm">手动输入</p>
|
||||||
<p className="text-xs text-gray-500">手动输入Cookie</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">手动输入Cookie</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -383,7 +399,7 @@ export function Accounts() {
|
|||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={7}>
|
||||||
<div className="empty-state py-8">
|
<div className="empty-state py-8">
|
||||||
<p className="text-gray-500">暂无账号,请添加新账号</p>
|
<p className="text-slate-500 dark:text-slate-400">暂无账号,请添加新账号</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -392,9 +408,23 @@ export function Accounts() {
|
|||||||
<tr key={account.id}>
|
<tr key={account.id}>
|
||||||
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
|
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
|
||||||
<td>
|
<td>
|
||||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded max-w-[120px] truncate block">
|
<div className="flex items-center gap-2">
|
||||||
{account.cookie?.substring(0, 25)}...
|
<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>
|
</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>
|
||||||
<td>
|
<td>
|
||||||
<span className={`inline-flex items-center gap-1.5 ${account.enabled !== false ? 'text-green-600' : 'text-gray-400'}`}>
|
<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 || '-'}
|
{account.note || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="table-actions">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleEnabled(account)}
|
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 ? '禁用' : '启用'}
|
title={account.enabled !== false ? '禁用' : '启用'}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(account)}
|
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="编辑"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(account.id)}
|
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="删除"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -466,14 +498,14 @@ export function Accounts() {
|
|||||||
{qrStatus === 'loading' && (
|
{qrStatus === 'loading' && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<Loader2 className="w-10 h-10 text-blue-600 dark:text-blue-400 animate-spin" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{qrStatus === 'ready' && (
|
{qrStatus === 'ready' && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<img src={qrCodeUrl} alt="登录二维码" className="w-44 h-44 rounded-lg border" />
|
<img src={qrCodeUrl} alt="登录二维码" className="w-44 h-44 rounded-lg border" />
|
||||||
<p className="text-sm text-gray-600">请使用闲鱼APP扫描二维码</p>
|
<p className="text-sm text-slate-600 dark:text-slate-300">请使用闲鱼APP扫描二维码</p>
|
||||||
<p className="text-xs text-gray-400">二维码有效期约5分钟</p>
|
<p className="text-xs text-slate-400 dark:text-slate-500">二维码有效期约5分钟</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{qrStatus === 'scanned' && (
|
{qrStatus === 'scanned' && (
|
||||||
@ -495,7 +527,7 @@ export function Accounts() {
|
|||||||
)}
|
)}
|
||||||
{qrStatus === 'expired' && (
|
{qrStatus === 'expired' && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<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 onClick={refreshQRCode} className="btn-ios-primary btn-sm">
|
||||||
刷新二维码
|
刷新二维码
|
||||||
</button>
|
</button>
|
||||||
@ -547,12 +579,12 @@ export function Accounts() {
|
|||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={pwdShowBrowser}
|
checked={pwdShowBrowser}
|
||||||
onChange={(e) => setPwdShowBrowser(e.target.checked)}
|
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>
|
</label>
|
||||||
@ -654,7 +686,7 @@ export function Accounts() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={editingAccount.id}
|
value={editingAccount.id}
|
||||||
disabled
|
disabled
|
||||||
className="input-ios"
|
className="input-ios bg-slate-100 dark:bg-slate-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
@ -672,30 +704,17 @@ export function Accounts() {
|
|||||||
<textarea
|
<textarea
|
||||||
value={editCookie}
|
value={editCookie}
|
||||||
onChange={(e) => setEditCookie(e.target.value)}
|
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值"
|
placeholder="更新Cookie值"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
当前Cookie长度: {editCookie.length} 字符
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{/* AI回复和默认回复设置请在"自动回复"页面配置 */}
|
||||||
<label className=" text-sm text-gray-700">
|
<p className="text-xs text-slate-500 dark:text-slate-400 pt-2">
|
||||||
<input
|
提示:AI回复和默认回复设置请在"自动回复"页面配置
|
||||||
type="checkbox"
|
</p>
|
||||||
checked={editUseAI}
|
|
||||||
onChange={(e) => setEditUseAI(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
启用AI回复
|
|
||||||
</label>
|
|
||||||
<label className=" text-sm text-gray-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editUseDefault}
|
|
||||||
onChange={(e) => setEditUseDefault(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
启用默认回复
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={editSaving}>
|
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={editSaving}>
|
||||||
|
|||||||
@ -110,79 +110,73 @@ export function DataManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="page-header">
|
||||||
<h1 className="page-title">数据管理</h1>
|
<h1 className="page-title">数据管理</h1>
|
||||||
<p className="page-description">导入导出和清理系统数据</p>
|
<p className="page-description">导入导出和清理系统数据</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Section */}
|
{/* 双列布局 */}
|
||||||
<div
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* 左列 - 数据导出 */}
|
||||||
|
<div className="vben-card">
|
||||||
className="vben-card"
|
|
||||||
>
|
|
||||||
<div className="vben-card-header">
|
<div className="vben-card-header">
|
||||||
<h2 className="vben-card-title">
|
<h2 className="vben-card-title">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4 text-blue-500" />
|
||||||
数据导出
|
数据导出
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="vben-card-body">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="space-y-3">
|
||||||
{dataTypes.map((type) => (
|
{dataTypes.map((type) => (
|
||||||
<div
|
<div
|
||||||
key={type.id}
|
key={type.id}
|
||||||
className="border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary-300
|
className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700
|
||||||
hover:bg-primary-50/30 transition-colors"
|
hover:border-blue-300 dark:hover:border-blue-600 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-slate-900 dark:text-slate-100">{type.name}</h3>
|
<h3 className="font-medium text-slate-900 dark:text-slate-100 text-sm">{type.name}</h3>
|
||||||
<p className="text-sm page-description">{type.desc}</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">{type.desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleExport(type.id)}
|
onClick={() => handleExport(type.id)}
|
||||||
disabled={exporting !== null}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Import Section */}
|
{/* 右列 */}
|
||||||
<div
|
<div className="space-y-4">
|
||||||
|
{/* 数据导入 */}
|
||||||
|
<div className="vben-card">
|
||||||
|
<div className="vben-card-header">
|
||||||
className="vben-card"
|
|
||||||
>
|
|
||||||
<div className="bg-emerald-500 px-6 py-4 text-white">
|
|
||||||
<h2 className="vben-card-title">
|
<h2 className="vben-card-title">
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4 text-emerald-500" />
|
||||||
数据导入
|
数据导入
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-5">
|
||||||
<div
|
<div
|
||||||
onClick={handleImportClick}
|
onClick={handleImportClick}
|
||||||
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center
|
className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-6 text-center
|
||||||
hover:border-primary-400 transition-colors cursor-pointer"
|
hover:border-emerald-400 dark:hover:border-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20
|
||||||
|
transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{importing ? (
|
{importing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-12 h-12 text-blue-500 dark:text-blue-400 mx-auto mb-4 animate-spin" />
|
<Loader2 className="w-10 h-10 text-emerald-500 mx-auto mb-3 animate-spin" />
|
||||||
<p className="text-slate-600 dark:text-slate-400 mb-2">正在导入数据...</p>
|
<p className="text-slate-600 dark:text-slate-400 text-sm">正在导入数据...</p>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Database className="w-12 h-12 text-slate-400 dark:text-slate-500 mx-auto mb-4" />
|
<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-400 mb-2">点击选择文件上传</p>
|
<p className="text-slate-600 dark:text-slate-300 text-sm mb-1">点击选择文件上传</p>
|
||||||
<p className="text-sm text-slate-400 dark:text-slate-500">支持 JSON 格式的导出数据文件</p>
|
<p className="text-xs text-slate-400 dark:text-slate-500">支持 JSON 格式的导出数据文件</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@ -196,40 +190,34 @@ export function DataManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cleanup Section */}
|
{/* 数据清理 */}
|
||||||
<div
|
<div className="vben-card">
|
||||||
|
<div className="vben-card-header">
|
||||||
|
|
||||||
|
|
||||||
className="vben-card"
|
|
||||||
>
|
|
||||||
<div className="bg-red-500 px-6 py-4 text-white">
|
|
||||||
<h2 className="vben-card-title">
|
<h2 className="vben-card-title">
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4 text-amber-500" />
|
||||||
数据清理
|
数据清理
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="vben-card-body">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-3">
|
||||||
{cleanupTypes.map((type) => (
|
{cleanupTypes.map((type) => (
|
||||||
<div
|
<div
|
||||||
key={type.id}
|
key={type.id}
|
||||||
className={`border rounded-xl p-4 ${
|
className={`flex items-center justify-between p-3 rounded-lg border transition-colors ${
|
||||||
type.danger
|
type.danger
|
||||||
? 'border-red-200 bg-red-50/50'
|
? '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 hover:bg-amber-50/30'
|
: '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'
|
||||||
} transition-colors`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start gap-2">
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{type.danger && (
|
{type.danger && (
|
||||||
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`font-medium ${type.danger ? 'text-red-700' : 'text-slate-900 dark:text-slate-100'}`}>
|
<h3 className={`font-medium text-sm ${type.danger ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100'}`}>
|
||||||
{type.name}
|
{type.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-sm mt-1 ${type.danger ? 'text-red-600' : 'text-slate-500 dark:text-slate-400'}`}>
|
<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}
|
{type.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -237,7 +225,7 @@ export function DataManagement() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleCleanup(type.id, type.danger)}
|
onClick={() => handleCleanup(type.id, type.danger)}
|
||||||
disabled={cleaning !== null}
|
disabled={cleaning !== null}
|
||||||
className={`py-2 px-3 text-sm rounded-lg font-medium transition-colors ${
|
className={`py-1.5 px-3 text-xs rounded-md font-medium transition-colors ${
|
||||||
type.danger
|
type.danger
|
||||||
? 'bg-red-500 text-white hover:bg-red-600'
|
? 'bg-red-500 text-white hover:bg-red-600'
|
||||||
: 'btn-ios-secondary'
|
: 'btn-ios-secondary'
|
||||||
@ -246,11 +234,12 @@ export function DataManagement() {
|
|||||||
{cleaning === type.id ? <ButtonLoading /> : '执行'}
|
{cleaning === type.id ? <ButtonLoading /> : '执行'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 { FileText, RefreshCw, Trash2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
|
||||||
import { getSystemLogs, clearSystemLogs, type SystemLog } from '@/api/admin'
|
import { getSystemLogs, clearSystemLogs, type SystemLog } from '@/api/admin'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
|
||||||
export function Logs() {
|
export function Logs() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||||
const [levelFilter, setLevelFilter] = useState('')
|
const [levelFilter, setLevelFilter] = useState('')
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getSystemLogs({ level: levelFilter || undefined })
|
const result = await getSystemLogs({ level: levelFilter || undefined })
|
||||||
@ -26,8 +29,11 @@ export function Logs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated) return
|
||||||
|
if (!isAuthenticated || !token) return
|
||||||
loadLogs()
|
loadLogs()
|
||||||
}, [levelFilter])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [_hasHydrated, isAuthenticated, token, levelFilter])
|
||||||
|
|
||||||
const handleClear = async () => {
|
const handleClear = async () => {
|
||||||
if (!confirm('确定要清空所有系统日志吗?此操作不可恢复!')) return
|
if (!confirm('确定要清空所有系统日志吗?此操作不可恢复!')) return
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
import { ShieldAlert, RefreshCw, Trash2 } from 'lucide-react'
|
import { ShieldAlert, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import { getRiskLogs, clearRiskLogs, type RiskLog } from '@/api/admin'
|
import { getRiskLogs, clearRiskLogs, type RiskLog } from '@/api/admin'
|
||||||
import { getAccounts } from '@/api/accounts'
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { Account } from '@/types'
|
import type { Account } from '@/types'
|
||||||
|
|
||||||
export function RiskLogs() {
|
export function RiskLogs() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [logs, setLogs] = useState<RiskLog[]>([])
|
const [logs, setLogs] = useState<RiskLog[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
const [selectedAccount, setSelectedAccount] = useState('')
|
const [selectedAccount, setSelectedAccount] = useState('')
|
||||||
|
|
||||||
const loadLogs = async () => {
|
const loadLogs = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getRiskLogs({ cookie_id: selectedAccount || undefined })
|
const result = await getRiskLogs({ cookie_id: selectedAccount || undefined })
|
||||||
@ -29,6 +32,7 @@ export function RiskLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const data = await getAccounts()
|
||||||
setAccounts(data)
|
setAccounts(data)
|
||||||
@ -38,13 +42,15 @@ export function RiskLogs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadLogs()
|
loadLogs()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadLogs()
|
loadLogs()
|
||||||
}, [selectedAccount])
|
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||||
|
|
||||||
const handleClear = async () => {
|
const handleClear = async () => {
|
||||||
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
|
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
|
||||||
@ -82,39 +88,33 @@ export function RiskLogs() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div
|
<div className="vben-card">
|
||||||
|
<div className="vben-card-body">
|
||||||
|
|
||||||
className="vben-card"
|
|
||||||
>
|
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
|
<div className="input-group">
|
||||||
<label className="input-label">筛选账号</label>
|
<label className="input-label">筛选账号</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={setSelectedAccount}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: '', label: '所有账号' },
|
||||||
<option value="">所有账号</option>
|
...accounts.map((account) => ({
|
||||||
{accounts.map((account) => (
|
value: account.id,
|
||||||
<option key={account.id} value={account.id}>
|
label: account.id,
|
||||||
{account.id}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
placeholder="所有账号"
|
||||||
</select>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs List */}
|
{/* Logs List */}
|
||||||
<div
|
<div className="vben-card">
|
||||||
|
<div className="vben-card-header">
|
||||||
|
|
||||||
|
|
||||||
className="vben-card"
|
|
||||||
>
|
|
||||||
<div className="bg-red-500 px-6 py-4 text-white
|
|
||||||
flex items-center justify-between">
|
|
||||||
<h2 className="vben-card-title">
|
<h2 className="vben-card-title">
|
||||||
<ShieldAlert className="w-4 h-4" />
|
<ShieldAlert className="w-4 h-4 text-amber-500" />
|
||||||
风控日志
|
风控日志
|
||||||
</h2>
|
</h2>
|
||||||
<span className="badge-primary">{logs.length} 条记录</span>
|
<span className="badge-primary">{logs.length} 条记录</span>
|
||||||
@ -146,7 +146,14 @@ export function RiskLogs() {
|
|||||||
<td>
|
<td>
|
||||||
<span className="badge-danger">{log.risk_type}</span>
|
<span className="badge-danger">{log.risk_type}</span>
|
||||||
</td>
|
</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">
|
<td className="text-slate-500 dark:text-slate-400 text-sm">
|
||||||
{new Date(log.created_at).toLocaleString()}
|
{new Date(log.created_at).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,25 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { FormEvent } from 'react'
|
import { Users as UsersIcon, RefreshCw, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { getUsers, deleteUser } from '@/api/admin'
|
||||||
import { Users as UsersIcon, RefreshCw, Plus, Edit2, Trash2, Shield, ShieldOff, X, Loader2 } from 'lucide-react'
|
|
||||||
import { getUsers, deleteUser, updateUser, addUser } from '@/api/admin'
|
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
export function Users() {
|
export function Users() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [users, setUsers] = useState<User[]>([])
|
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 () => {
|
const loadUsers = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getUsers()
|
const result = await getUsers()
|
||||||
@ -34,17 +28,13 @@ export function Users() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
const handleToggleAdmin = async (user: User) => {
|
// TODO: 后端暂未实现 PUT /admin/users/{user_id} 接口
|
||||||
try {
|
const handleNotImplemented = (action: string) => {
|
||||||
await updateUser(user.user_id, { is_admin: !user.is_admin })
|
addToast({ type: 'warning', message: `${action}功能后端暂未实现` })
|
||||||
addToast({ type: 'success', message: user.is_admin ? '已取消管理员权限' : '已设为管理员' })
|
|
||||||
loadUsers()
|
|
||||||
} catch {
|
|
||||||
addToast({ type: 'error', message: '操作失败' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (userId: number) => {
|
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) {
|
if (loading) {
|
||||||
return <PageLoading />
|
return <PageLoading />
|
||||||
}
|
}
|
||||||
@ -135,7 +61,8 @@ export function Users() {
|
|||||||
<p className="page-description">管理系统用户账号</p>
|
<p className="page-description">管理系统用户账号</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<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" />
|
<Plus className="w-4 h-4" />
|
||||||
添加用户
|
添加用户
|
||||||
</button>
|
</button>
|
||||||
@ -147,13 +74,8 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Users List */}
|
{/* Users List */}
|
||||||
<div
|
<div className="vben-card">
|
||||||
|
<div className="vben-card-header flex items-center justify-between">
|
||||||
|
|
||||||
className="vben-card"
|
|
||||||
>
|
|
||||||
<div className="vben-card-header
|
|
||||||
flex items-center justify-between">
|
|
||||||
<h2 className="vben-card-title">
|
<h2 className="vben-card-title">
|
||||||
<UsersIcon className="w-4 h-4" />
|
<UsersIcon className="w-4 h-4" />
|
||||||
用户列表
|
用户列表
|
||||||
@ -195,28 +117,10 @@ export function Users() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="">
|
<div className="flex gap-1">
|
||||||
<button
|
|
||||||
onClick={() => handleToggleAdmin(user)}
|
|
||||||
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
|
|
||||||
title={user.is_admin ? '取消管理员' : '设为管理员'}
|
|
||||||
>
|
|
||||||
{user.is_admin ? (
|
|
||||||
<ShieldOff className="w-4 h-4 text-amber-500" />
|
|
||||||
) : (
|
|
||||||
<Shield className="w-4 h-4 text-emerald-500" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(user)}
|
|
||||||
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
|
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.user_id)}
|
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="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
@ -231,81 +135,14 @@ export function Users() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 添加/编辑用户弹窗 */}
|
{/* 提示信息 */}
|
||||||
{isModalOpen && (
|
<div className="vben-card">
|
||||||
<div className="modal-overlay">
|
<div className="vben-card-body">
|
||||||
<div className="modal-content max-w-md">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
<div className="modal-header flex items-center justify-between">
|
提示:用户可通过注册页面自行注册账号。管理员可在此页面删除用户。
|
||||||
<h2 className="text-lg font-semibold">
|
</p>
|
||||||
{editingUser ? '编辑用户' : '添加用户'}
|
|
||||||
</h2>
|
|
||||||
<button onClick={closeModal} className="p-1 hover:bg-slate-100 dark:bg-slate-700 rounded-lg">
|
|
||||||
<X className="w-4 h-4 text-slate-500 dark:text-slate-400" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="modal-body space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="input-label">用户名</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formUsername}
|
|
||||||
onChange={(e) => setFormUsername(e.target.value)}
|
|
||||||
className="input-ios"
|
|
||||||
placeholder="请输入用户名"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="input-label">
|
|
||||||
密码{editingUser && '(留空则不修改)'}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={formPassword}
|
|
||||||
onChange={(e) => setFormPassword(e.target.value)}
|
|
||||||
className="input-ios"
|
|
||||||
placeholder={editingUser ? '留空则不修改密码' : '请输入密码'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="input-label">邮箱(可选)</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={formEmail}
|
|
||||||
onChange={(e) => setFormEmail(e.target.value)}
|
|
||||||
className="input-ios"
|
|
||||||
placeholder="请输入邮箱"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className=" text-sm text-slate-700 dark:text-slate-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formIsAdmin}
|
|
||||||
onChange={(e) => setFormIsAdmin(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
|
||||||
/>
|
|
||||||
设为管理员
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-ios-primary" disabled={saving}>
|
|
||||||
{saving ? (
|
|
||||||
<span className="">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
保存中...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'保存'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -141,13 +141,13 @@ export function Register() {
|
|||||||
|
|
||||||
if (!registrationEnabled) {
|
if (!registrationEnabled) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 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="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 mx-auto mb-4 flex items-center justify-center">
|
<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>
|
<span className="text-2xl">🚫</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-lg vben-card-title text-gray-900 mb-2">注册功能已关闭</h1>
|
<h1 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-2">注册功能已关闭</h1>
|
||||||
<p className="text-sm text-gray-500 mb-6">管理员已关闭注册功能,如需账号请联系管理员</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6">管理员已关闭注册功能,如需账号请联系管理员</p>
|
||||||
<Link to="/login" className="btn-ios-primary">
|
<Link to="/login" className="btn-ios-primary">
|
||||||
返回登录
|
返回登录
|
||||||
</Link>
|
</Link>
|
||||||
@ -157,25 +157,25 @@ export function Register() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-full max-w-md">
|
||||||
{/* Mobile header */}
|
{/* Mobile header */}
|
||||||
<div className="text-center mb-6">
|
<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">
|
<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" />
|
<MessageSquare className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-gray-900">用户注册</h1>
|
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">用户注册</h1>
|
||||||
<p className="text-sm page-description">创建您的账号以开始使用</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">创建您的账号以开始使用</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Register Card */}
|
{/* 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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">用户名</label>
|
<label className="input-label">用户名</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
@ -190,7 +190,7 @@ export function Register() {
|
|||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">邮箱地址</label>
|
<label className="input-label">邮箱地址</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
@ -205,7 +205,7 @@ export function Register() {
|
|||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">密码</label>
|
<label className="input-label">密码</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
@ -216,7 +216,7 @@ export function Register() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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" />}
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
@ -227,7 +227,7 @@ export function Register() {
|
|||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">确认密码</label>
|
<label className="input-label">确认密码</label>
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
@ -259,12 +259,12 @@ export function Register() {
|
|||||||
src={captchaImage}
|
src={captchaImage}
|
||||||
alt="验证码"
|
alt="验证码"
|
||||||
onClick={loadCaptcha}
|
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>
|
</div>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'text-xs',
|
'text-xs',
|
||||||
captchaVerified ? 'text-green-600' : 'text-gray-400'
|
captchaVerified ? 'text-green-600 dark:text-green-400' : 'text-slate-400'
|
||||||
)}>
|
)}>
|
||||||
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
|
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
|
||||||
</p>
|
</p>
|
||||||
@ -275,7 +275,7 @@ export function Register() {
|
|||||||
<label className="input-label">邮箱验证码</label>
|
<label className="input-label">邮箱验证码</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
@ -307,7 +307,7 @@ export function Register() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Login link */}
|
{/* 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">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* 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()} 划算云服务器 ·
|
© {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">
|
<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
|
www.hsykj.com
|
||||||
|
|||||||
@ -5,12 +5,15 @@ import { getCards, deleteCard, addCard, importCards } from '@/api/cards'
|
|||||||
import { getAccounts } from '@/api/accounts'
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { Card, Account } from '@/types'
|
import type { Card, Account } from '@/types'
|
||||||
|
|
||||||
type ModalType = 'add' | 'import' | null
|
type ModalType = 'add' | 'import' | null
|
||||||
|
|
||||||
export function Cards() {
|
export function Cards() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [cards, setCards] = useState<Card[]>([])
|
const [cards, setCards] = useState<Card[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
@ -28,6 +31,9 @@ export function Cards() {
|
|||||||
const [importLoading, setImportLoading] = useState(false)
|
const [importLoading, setImportLoading] = useState(false)
|
||||||
|
|
||||||
const loadCards = async () => {
|
const loadCards = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getCards(selectedAccount || undefined)
|
const result = await getCards(selectedAccount || undefined)
|
||||||
@ -42,6 +48,9 @@ export function Cards() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const data = await getAccounts()
|
||||||
setAccounts(data)
|
setAccounts(data)
|
||||||
@ -51,13 +60,15 @@ export function Cards() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadCards()
|
loadCards()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadCards()
|
loadCards()
|
||||||
}, [selectedAccount])
|
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('确定要删除这张卡券吗?')) return
|
if (!confirm('确定要删除这张卡券吗?')) return
|
||||||
@ -182,18 +193,18 @@ export function Cards() {
|
|||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">筛选账号</label>
|
<label className="input-label">筛选账号</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={setSelectedAccount}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: '', label: '所有账号' },
|
||||||
<option value="">所有账号</option>
|
...accounts.map((account) => ({
|
||||||
{accounts.map((account) => (
|
value: account.id,
|
||||||
<option key={account.id} value={account.id}>
|
label: account.id,
|
||||||
{account.id}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
placeholder="所有账号"
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { getAccountDetails } from '@/api/accounts'
|
|||||||
import { getKeywords } from '@/api/keywords'
|
import { getKeywords } from '@/api/keywords'
|
||||||
import { getOrders } from '@/api/orders'
|
import { getOrders } from '@/api/orders'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
import type { AccountDetail } from '@/types'
|
import type { AccountDetail } from '@/types'
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ interface DashboardStats {
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
totalAccounts: 0,
|
totalAccounts: 0,
|
||||||
@ -27,6 +29,7 @@ export function Dashboard() {
|
|||||||
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
const [accounts, setAccounts] = useState<AccountDetail[]>([])
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
@ -87,8 +90,9 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadDashboard()
|
loadDashboard()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <PageLoading />
|
return <PageLoading />
|
||||||
|
|||||||
@ -3,31 +3,35 @@ import type { FormEvent } from 'react'
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Truck, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
import { Truck, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||||
import { getDeliveryRules, deleteDeliveryRule, updateDeliveryRule, addDeliveryRule } from '@/api/delivery'
|
import { getDeliveryRules, deleteDeliveryRule, updateDeliveryRule, addDeliveryRule } from '@/api/delivery'
|
||||||
import { getAccounts } from '@/api/accounts'
|
import { getCards } from '@/api/cards'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
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() {
|
export function Delivery() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [rules, setRules] = useState<DeliveryRule[]>([])
|
const [rules, setRules] = useState<DeliveryRule[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [cards, setCards] = useState<Card[]>([])
|
||||||
const [selectedAccount, setSelectedAccount] = useState('')
|
|
||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
|
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
|
||||||
const [formItemId, setFormItemId] = useState('')
|
const [formKeyword, setFormKeyword] = useState('')
|
||||||
const [formDeliveryType, setFormDeliveryType] = useState<'card' | 'text' | 'api'>('card')
|
const [formCardId, setFormCardId] = useState('')
|
||||||
const [formContent, setFormContent] = useState('')
|
const [formDeliveryCount, setFormDeliveryCount] = useState(1)
|
||||||
|
const [formDescription, setFormDescription] = useState('')
|
||||||
const [formEnabled, setFormEnabled] = useState(true)
|
const [formEnabled, setFormEnabled] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const loadRules = async () => {
|
const loadRules = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getDeliveryRules(selectedAccount || undefined)
|
const result = await getDeliveryRules()
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setRules(result.data || [])
|
setRules(result.data || [])
|
||||||
}
|
}
|
||||||
@ -38,27 +42,27 @@ export function Delivery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadCards = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const result = await getCards()
|
||||||
setAccounts(data)
|
if (result.success) {
|
||||||
|
setCards(result.data || [])
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAccounts()
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
|
loadCards()
|
||||||
loadRules()
|
loadRules()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadRules()
|
|
||||||
}, [selectedAccount])
|
|
||||||
|
|
||||||
const handleToggleEnabled = async (rule: DeliveryRule) => {
|
const handleToggleEnabled = async (rule: DeliveryRule) => {
|
||||||
try {
|
try {
|
||||||
await updateDeliveryRule(rule.id, { enabled: !rule.enabled })
|
await updateDeliveryRule(String(rule.id), { enabled: !rule.enabled })
|
||||||
addToast({ type: 'success', message: rule.enabled ? '规则已禁用' : '规则已启用' })
|
addToast({ type: 'success', message: rule.enabled ? '规则已禁用' : '规则已启用' })
|
||||||
loadRules()
|
loadRules()
|
||||||
} catch {
|
} catch {
|
||||||
@ -66,10 +70,10 @@ export function Delivery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!confirm('确定要删除这条规则吗?')) return
|
if (!confirm('确定要删除这条规则吗?')) return
|
||||||
try {
|
try {
|
||||||
await deleteDeliveryRule(id)
|
await deleteDeliveryRule(String(id))
|
||||||
addToast({ type: 'success', message: '删除成功' })
|
addToast({ type: 'success', message: '删除成功' })
|
||||||
loadRules()
|
loadRules()
|
||||||
} catch {
|
} catch {
|
||||||
@ -78,23 +82,21 @@ export function Delivery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
if (!selectedAccount) {
|
|
||||||
addToast({ type: 'warning', message: '请先选择账号' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setEditingRule(null)
|
setEditingRule(null)
|
||||||
setFormItemId('')
|
setFormKeyword('')
|
||||||
setFormDeliveryType('card')
|
setFormCardId('')
|
||||||
setFormContent('')
|
setFormDeliveryCount(1)
|
||||||
|
setFormDescription('')
|
||||||
setFormEnabled(true)
|
setFormEnabled(true)
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditModal = (rule: DeliveryRule) => {
|
const openEditModal = (rule: DeliveryRule) => {
|
||||||
setEditingRule(rule)
|
setEditingRule(rule)
|
||||||
setFormItemId(rule.item_id || '')
|
setFormKeyword(rule.keyword)
|
||||||
setFormDeliveryType((rule.delivery_type as 'card' | 'text' | 'api') || 'card')
|
setFormCardId(String(rule.card_id))
|
||||||
setFormContent(rule.delivery_content || '')
|
setFormDeliveryCount(rule.delivery_count)
|
||||||
|
setFormDescription(rule.description || '')
|
||||||
setFormEnabled(rule.enabled)
|
setFormEnabled(rule.enabled)
|
||||||
setIsModalOpen(true)
|
setIsModalOpen(true)
|
||||||
}
|
}
|
||||||
@ -106,23 +108,27 @@ export function Delivery() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedAccount && !editingRule) {
|
if (!formKeyword.trim()) {
|
||||||
addToast({ type: 'warning', message: '请先选择账号' })
|
addToast({ type: 'warning', message: '请输入触发关键词' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formCardId) {
|
||||||
|
addToast({ type: 'warning', message: '请选择卡券' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
cookie_id: editingRule?.cookie_id || selectedAccount,
|
keyword: formKeyword.trim(),
|
||||||
item_id: formItemId || undefined,
|
card_id: Number(formCardId),
|
||||||
delivery_type: formDeliveryType,
|
delivery_count: formDeliveryCount,
|
||||||
delivery_content: formContent,
|
description: formDescription || undefined,
|
||||||
enabled: formEnabled,
|
enabled: formEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingRule) {
|
if (editingRule) {
|
||||||
await updateDeliveryRule(editingRule.id, data)
|
await updateDeliveryRule(String(editingRule.id), data)
|
||||||
addToast({ type: 'success', message: '规则已更新' })
|
addToast({ type: 'success', message: '规则已更新' })
|
||||||
} else {
|
} else {
|
||||||
await addDeliveryRule(data)
|
await addDeliveryRule(data)
|
||||||
@ -162,29 +168,6 @@ export function Delivery() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Rules List */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -204,10 +187,10 @@ export function Delivery() {
|
|||||||
<table className="table-ios">
|
<table className="table-ios">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>账号ID</th>
|
<th>触发关键词</th>
|
||||||
<th>商品ID</th>
|
<th>关联卡券</th>
|
||||||
<th>发货类型</th>
|
<th>发货数量</th>
|
||||||
<th>发货内容</th>
|
<th>已发次数</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -225,20 +208,10 @@ export function Delivery() {
|
|||||||
) : (
|
) : (
|
||||||
rules.map((rule) => (
|
rules.map((rule) => (
|
||||||
<tr key={rule.id}>
|
<tr key={rule.id}>
|
||||||
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.cookie_id}</td>
|
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.keyword}</td>
|
||||||
<td className="text-sm">{rule.item_id || '所有商品'}</td>
|
<td className="text-sm">{rule.card_name || `卡券ID: ${rule.card_id}`}</td>
|
||||||
<td>
|
<td className="text-center">{rule.delivery_count}</td>
|
||||||
{rule.delivery_type === 'card' ? (
|
<td className="text-center text-slate-500">{rule.delivery_times || 0}</td>
|
||||||
<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>
|
<td>
|
||||||
{rule.enabled ? (
|
{rule.enabled ? (
|
||||||
<span className="badge-success">启用</span>
|
<span className="badge-success">启用</span>
|
||||||
@ -298,62 +271,67 @@ export function Delivery() {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="modal-body space-y-4">
|
<div className="modal-body space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="input-label">所属账号</label>
|
<label className="input-label">触发关键词 *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editingRule?.cookie_id || selectedAccount || '请先选择账号'}
|
value={formKeyword}
|
||||||
disabled
|
onChange={(e) => setFormKeyword(e.target.value)}
|
||||||
className="input-ios bg-gray-100 cursor-not-allowed"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label className="input-label">商品ID(可选)</label>
|
<label className="input-label">发货数量</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="number"
|
||||||
value={formItemId}
|
value={formDeliveryCount}
|
||||||
onChange={(e) => setFormItemId(e.target.value)}
|
onChange={(e) => setFormDeliveryCount(Number(e.target.value) || 1)}
|
||||||
className="input-ios"
|
className="input-ios"
|
||||||
placeholder="留空表示适用于所有商品"
|
min={1}
|
||||||
|
placeholder="每次发货的卡密数量"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="input-label">发货类型</label>
|
<label className="input-label">描述(可选)</label>
|
||||||
<select
|
|
||||||
value={formDeliveryType}
|
|
||||||
onChange={(e) => setFormDeliveryType(e.target.value as 'card' | 'text' | 'api')}
|
|
||||||
className="input-ios"
|
|
||||||
>
|
|
||||||
<option value="card">卡密发货</option>
|
|
||||||
<option value="text">固定文本</option>
|
|
||||||
<option value="api">API接口</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="input-label">
|
|
||||||
{formDeliveryType === 'card' ? '卡密说明' : formDeliveryType === 'api' ? 'API地址' : '发货内容'}
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
value={formContent}
|
value={formDescription}
|
||||||
onChange={(e) => setFormContent(e.target.value)}
|
onChange={(e) => setFormDescription(e.target.value)}
|
||||||
className="input-ios h-24 resize-none"
|
className="input-ios h-20 resize-none"
|
||||||
placeholder={
|
placeholder="规则描述,方便识别"
|
||||||
formDeliveryType === 'card'
|
|
||||||
? '卡密将从卡券库中自动获取'
|
|
||||||
: formDeliveryType === 'api'
|
|
||||||
? '请输入API接口地址'
|
|
||||||
: '请输入固定发货文本'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className=" text-sm text-gray-700">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<input
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">启用此规则</span>
|
||||||
type="checkbox"
|
<button
|
||||||
checked={formEnabled}
|
type="button"
|
||||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
onClick={() => setFormEnabled(!formEnabled)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
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>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
<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 { getItemReplies, deleteItemReply, addItemReply, updateItemReply } from '@/api/items'
|
||||||
import { getAccounts } from '@/api/accounts'
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { ItemReply, Account } from '@/types'
|
import type { ItemReply, Account } from '@/types'
|
||||||
|
|
||||||
export function ItemReplies() {
|
export function ItemReplies() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [replies, setReplies] = useState<ItemReply[]>([])
|
const [replies, setReplies] = useState<ItemReply[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
@ -22,6 +25,7 @@ export function ItemReplies() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const loadReplies = async () => {
|
const loadReplies = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getItemReplies(selectedAccount || undefined)
|
const result = await getItemReplies(selectedAccount || undefined)
|
||||||
@ -36,6 +40,7 @@ export function ItemReplies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const data = await getAccounts()
|
||||||
setAccounts(data)
|
setAccounts(data)
|
||||||
@ -45,18 +50,20 @@ export function ItemReplies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadReplies()
|
loadReplies()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadReplies()
|
loadReplies()
|
||||||
}, [selectedAccount])
|
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (reply: ItemReply) => {
|
||||||
if (!confirm('确定要删除这条商品回复吗?')) return
|
if (!confirm('确定要删除这条商品回复吗?')) return
|
||||||
try {
|
try {
|
||||||
await deleteItemReply(id)
|
await deleteItemReply(reply.cookie_id, reply.item_id)
|
||||||
addToast({ type: 'success', message: '删除成功' })
|
addToast({ type: 'success', message: '删除成功' })
|
||||||
loadReplies()
|
loadReplies()
|
||||||
} catch {
|
} catch {
|
||||||
@ -110,10 +117,10 @@ export function ItemReplies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (editingReply) {
|
if (editingReply) {
|
||||||
await updateItemReply(editingReply.id, data)
|
await updateItemReply(editingReply.cookie_id, editingReply.item_id, data)
|
||||||
addToast({ type: 'success', message: '回复已更新' })
|
addToast({ type: 'success', message: '回复已更新' })
|
||||||
} else {
|
} else {
|
||||||
await addItemReply(data)
|
await addItemReply(selectedAccount, formItemId.trim(), data)
|
||||||
addToast({ type: 'success', message: '回复已添加' })
|
addToast({ type: 'success', message: '回复已添加' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -156,20 +163,22 @@ export function ItemReplies() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="vben-card"
|
className="vben-card"
|
||||||
>
|
>
|
||||||
<div className="max-w-md">
|
<div className="vben-card-body">
|
||||||
|
<div className="max-w-xs">
|
||||||
<label className="input-label">筛选账号</label>
|
<label className="input-label">筛选账号</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={setSelectedAccount}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: '', label: '所有账号' },
|
||||||
<option value="">所有账号</option>
|
...accounts.map((account) => ({
|
||||||
{accounts.map((account) => (
|
value: account.id,
|
||||||
<option key={account.id} value={account.id}>
|
label: account.id,
|
||||||
{account.id}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
placeholder="选择账号"
|
||||||
</select>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -230,7 +239,7 @@ export function ItemReplies() {
|
|||||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(reply.id)}
|
onClick={() => handleDelete(reply)}
|
||||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
title="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,23 +1,29 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { getItems, deleteItem, fetchItemsFromAccount, batchDeleteItems } from '@/api/items'
|
||||||
import { getAccounts } from '@/api/accounts'
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { Item, Account } from '@/types'
|
import type { Item, Account } from '@/types'
|
||||||
|
|
||||||
export function Items() {
|
export function Items() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [items, setItems] = useState<Item[]>([])
|
const [items, setItems] = useState<Item[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
const [selectedAccount, setSelectedAccount] = useState('')
|
const [selectedAccount, setSelectedAccount] = useState('')
|
||||||
const [searchKeyword, setSearchKeyword] = 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 [fetching, setFetching] = useState(false)
|
||||||
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0 })
|
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0 })
|
||||||
|
|
||||||
const loadItems = async () => {
|
const loadItems = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getItems(selectedAccount || undefined)
|
const result = await getItems(selectedAccount || undefined)
|
||||||
@ -73,6 +79,9 @@ export function Items() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const data = await getAccounts()
|
||||||
setAccounts(data)
|
setAccounts(data)
|
||||||
@ -82,18 +91,20 @@ export function Items() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadItems()
|
loadItems()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadItems()
|
loadItems()
|
||||||
}, [selectedAccount])
|
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (item: Item) => {
|
||||||
if (!confirm('确定要删除这个商品吗?')) return
|
if (!confirm('确定要删除这个商品吗?')) return
|
||||||
try {
|
try {
|
||||||
await deleteItem(id)
|
await deleteItem(item.cookie_id, item.item_id)
|
||||||
addToast({ type: 'success', message: '删除成功' })
|
addToast({ type: 'success', message: '删除成功' })
|
||||||
loadItems()
|
loadItems()
|
||||||
} catch {
|
} catch {
|
||||||
@ -102,7 +113,7 @@ export function Items() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 批量选择相关
|
// 批量选择相关
|
||||||
const toggleSelect = (id: string) => {
|
const toggleSelect = (id: string | number) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
if (next.has(id)) {
|
if (next.has(id)) {
|
||||||
@ -129,7 +140,11 @@ export function Items() {
|
|||||||
}
|
}
|
||||||
if (!confirm(`确定要删除选中的 ${selectedIds.size} 个商品吗?`)) return
|
if (!confirm(`确定要删除选中的 ${selectedIds.size} 个商品吗?`)) return
|
||||||
try {
|
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} 个商品` })
|
addToast({ type: 'success', message: `成功删除 ${selectedIds.size} 个商品` })
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
loadItems()
|
loadItems()
|
||||||
@ -141,9 +156,11 @@ export function Items() {
|
|||||||
const filteredItems = items.filter((item) => {
|
const filteredItems = items.filter((item) => {
|
||||||
if (!searchKeyword) return true
|
if (!searchKeyword) return true
|
||||||
const keyword = searchKeyword.toLowerCase()
|
const keyword = searchKeyword.toLowerCase()
|
||||||
|
const title = item.item_title || item.title || ''
|
||||||
|
const desc = item.item_detail || item.desc || ''
|
||||||
return (
|
return (
|
||||||
item.title?.toLowerCase().includes(keyword) ||
|
title.toLowerCase().includes(keyword) ||
|
||||||
item.desc?.toLowerCase().includes(keyword) ||
|
desc.toLowerCase().includes(keyword) ||
|
||||||
item.item_id?.includes(keyword)
|
item.item_id?.includes(keyword)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -170,7 +187,7 @@ export function Items() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleFetchItems}
|
onClick={handleFetchItems}
|
||||||
disabled={fetching}
|
disabled={fetching}
|
||||||
className="btn-ios-success"
|
className="btn-ios-primary"
|
||||||
>
|
>
|
||||||
{fetching ? (
|
{fetching ? (
|
||||||
<>
|
<>
|
||||||
@ -197,18 +214,18 @@ export function Items() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">筛选账号</label>
|
<label className="input-label">筛选账号</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={setSelectedAccount}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: '', label: '所有账号' },
|
||||||
<option value="">所有账号</option>
|
...accounts.map((account) => ({
|
||||||
{accounts.map((account) => (
|
value: account.id,
|
||||||
<option key={account.id} value={account.id}>
|
label: account.id,
|
||||||
{account.id}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
placeholder="所有账号"
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label className="input-label">搜索商品</label>
|
<label className="input-label">搜索商品</label>
|
||||||
@ -288,22 +305,47 @@ export function Items() {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="font-medium text-blue-600 dark:text-blue-400">{item.cookie_id}</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="text-xs text-gray-500">
|
||||||
<td className="max-w-[180px] truncate" title={item.title}>
|
<a
|
||||||
{item.title}
|
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>
|
||||||
<td className="text-amber-600 font-medium">¥{item.price}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<span className={item.has_sku ? 'badge-success' : 'badge-gray'}>
|
<span className={(item.is_multi_spec || item.has_sku) ? 'badge-success' : 'badge-gray'}>
|
||||||
{item.has_sku ? '是' : '否'}
|
{(item.is_multi_spec || item.has_sku) ? '是' : '否'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-gray-500">
|
<td className="text-gray-500 text-xs">
|
||||||
{item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}
|
{item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item.id)}
|
onClick={() => handleDelete(item)}
|
||||||
className="table-action-btn hover:!bg-red-50"
|
className="table-action-btn hover:!bg-red-50"
|
||||||
title="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -6,10 +6,13 @@ import { getKeywords, deleteKeyword, addKeyword, updateKeyword, exportKeywords,
|
|||||||
import { getAccounts } from '@/api/accounts'
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { Keyword, Account } from '@/types'
|
import type { Keyword, Account } from '@/types'
|
||||||
|
|
||||||
export function Keywords() {
|
export function Keywords() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [keywords, setKeywords] = useState<Keyword[]>([])
|
const [keywords, setKeywords] = useState<Keyword[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
@ -25,6 +28,9 @@ export function Keywords() {
|
|||||||
const importInputRef = useRef<HTMLInputElement | null>(null)
|
const importInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const loadKeywords = async () => {
|
const loadKeywords = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!selectedAccount) {
|
if (!selectedAccount) {
|
||||||
setKeywords([])
|
setKeywords([])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@ -42,6 +48,9 @@ export function Keywords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const data = await getAccounts()
|
||||||
setAccounts(data)
|
setAccounts(data)
|
||||||
@ -54,14 +63,16 @@ export function Keywords() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
if (selectedAccount) {
|
if (selectedAccount) {
|
||||||
loadKeywords()
|
loadKeywords()
|
||||||
}
|
}
|
||||||
}, [selectedAccount])
|
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
if (!selectedAccount) {
|
if (!selectedAccount) {
|
||||||
@ -258,23 +269,23 @@ export function Keywords() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="vben-card"
|
className="vben-card"
|
||||||
>
|
>
|
||||||
|
<div className="vben-card-body">
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<label className="input-label">选择账号</label>
|
<label className="input-label">选择账号</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={setSelectedAccount}
|
||||||
className="input-ios"
|
options={
|
||||||
>
|
accounts.length === 0
|
||||||
{accounts.length === 0 ? (
|
? [{ value: '', label: '暂无账号' }]
|
||||||
<option value="">暂无账号</option>
|
: accounts.map((account) => ({
|
||||||
) : (
|
value: account.id,
|
||||||
accounts.map((account) => (
|
label: account.id,
|
||||||
<option key={account.id} value={account.id}>
|
}))
|
||||||
{account.id}
|
}
|
||||||
</option>
|
placeholder="选择账号"
|
||||||
))
|
/>
|
||||||
)}
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -328,12 +339,12 @@ export function Keywords() {
|
|||||||
keywords.map((keyword) => (
|
keywords.map((keyword) => (
|
||||||
<tr key={keyword.id}>
|
<tr key={keyword.id}>
|
||||||
<td className="font-medium">
|
<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}
|
{keyword.keyword}
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td className="max-w-[300px]">
|
<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}
|
{keyword.reply}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@ -348,14 +359,14 @@ export function Keywords() {
|
|||||||
<div className="">
|
<div className="">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(keyword)}
|
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="编辑"
|
title="编辑"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(keyword.id)}
|
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="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
@ -386,7 +397,7 @@ export function Keywords() {
|
|||||||
type="text"
|
type="text"
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
disabled
|
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>
|
||||||
<div>
|
<div>
|
||||||
@ -408,17 +419,24 @@ export function Keywords() {
|
|||||||
placeholder="请输入自动回复内容"
|
placeholder="请输入自动回复内容"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<label className=" text-sm text-gray-700">
|
<div>
|
||||||
<input
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">使用模糊匹配</span>
|
||||||
type="checkbox"
|
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">开启后,将在消息中模糊匹配该关键词</p>
|
||||||
checked={fuzzyMatch}
|
</div>
|
||||||
onChange={(e) => setFuzzyMatch(e.target.checked)}
|
<button
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400 focus:ring-primary-500"
|
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'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
使用模糊匹配
|
</button>
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-400">开启后,将在消息中模糊匹配该关键词</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
|
|||||||
@ -1,25 +1,30 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import type { FormEvent } from 'react'
|
import type { FormEvent } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Mail, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
import { Mail, RefreshCw, Plus, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||||
import { getMessageNotifications, deleteMessageNotification, updateMessageNotification, addMessageNotification } from '@/api/notifications'
|
import { getMessageNotifications, setMessageNotification, getNotificationChannels } from '@/api/notifications'
|
||||||
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
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() {
|
export function MessageNotifications() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [notifications, setNotifications] = useState<MessageNotification[]>([])
|
const [notifications, setNotifications] = useState<MessageNotification[]>([])
|
||||||
|
const [channels, setChannels] = useState<NotificationChannel[]>([])
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
const [editingNotification, setEditingNotification] = useState<MessageNotification | null>(null)
|
const [formAccountId, setFormAccountId] = useState('')
|
||||||
const [formName, setFormName] = useState('')
|
|
||||||
const [formKeyword, setFormKeyword] = useState('')
|
|
||||||
const [formChannelId, setFormChannelId] = useState('')
|
const [formChannelId, setFormChannelId] = useState('')
|
||||||
const [formEnabled, setFormEnabled] = useState(true)
|
const [formEnabled, setFormEnabled] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const loadNotifications = async () => {
|
const loadNotifications = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getMessageNotifications()
|
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(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
|
loadChannels()
|
||||||
|
loadAccounts()
|
||||||
loadNotifications()
|
loadNotifications()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
const handleToggleEnabled = async (notification: MessageNotification) => {
|
const handleToggleEnabled = async (notification: MessageNotification) => {
|
||||||
try {
|
try {
|
||||||
await updateMessageNotification(notification.id, { enabled: !notification.enabled })
|
await setMessageNotification(notification.cookie_id, notification.channel_id, !notification.enabled)
|
||||||
addToast({ type: 'success', message: notification.enabled ? '通知已禁用' : '通知已启用' })
|
addToast({ type: 'success', message: notification.enabled ? '通知已禁用' : '通知已启用' })
|
||||||
loadNotifications()
|
loadNotifications()
|
||||||
} catch {
|
} catch {
|
||||||
@ -47,64 +77,45 @@ export function MessageNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (notification: MessageNotification) => {
|
||||||
if (!confirm('确定要删除这个消息通知吗?')) return
|
if (!confirm('确定要删除这个消息通知吗?')) return
|
||||||
try {
|
try {
|
||||||
await deleteMessageNotification(id)
|
// 后端删除接口需要 notification_id,但我们没有这个字段
|
||||||
addToast({ type: 'success', message: '删除成功' })
|
// 改为禁用该通知
|
||||||
|
await setMessageNotification(notification.cookie_id, notification.channel_id, false)
|
||||||
|
addToast({ type: 'success', message: '通知已禁用' })
|
||||||
loadNotifications()
|
loadNotifications()
|
||||||
} catch {
|
} catch {
|
||||||
addToast({ type: 'error', message: '删除失败' })
|
addToast({ type: 'error', message: '操作失败' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
setEditingNotification(null)
|
setFormAccountId('')
|
||||||
setFormName('')
|
|
||||||
setFormKeyword('')
|
|
||||||
setFormChannelId('')
|
setFormChannelId('')
|
||||||
setFormEnabled(true)
|
setFormEnabled(true)
|
||||||
setIsModalOpen(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 = () => {
|
const closeModal = () => {
|
||||||
setIsModalOpen(false)
|
setIsModalOpen(false)
|
||||||
setEditingNotification(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!formName.trim()) {
|
if (!formAccountId) {
|
||||||
addToast({ type: 'warning', message: '请输入通知名称' })
|
addToast({ type: 'warning', message: '请选择账号' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!formChannelId) {
|
||||||
|
addToast({ type: 'warning', message: '请选择通知渠道' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const data = {
|
await setMessageNotification(formAccountId, Number(formChannelId), formEnabled)
|
||||||
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: '通知已添加' })
|
addToast({ type: 'success', message: '通知已添加' })
|
||||||
}
|
|
||||||
|
|
||||||
closeModal()
|
closeModal()
|
||||||
loadNotifications()
|
loadNotifications()
|
||||||
} catch {
|
} catch {
|
||||||
@ -156,8 +167,7 @@ export function MessageNotifications() {
|
|||||||
<table className="table-ios">
|
<table className="table-ios">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>名称</th>
|
<th>账号ID</th>
|
||||||
<th>触发关键词</th>
|
|
||||||
<th>通知渠道</th>
|
<th>通知渠道</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@ -166,24 +176,19 @@ export function MessageNotifications() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{notifications.length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<tr>
|
<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">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Mail className="w-12 h-12 text-gray-300" />
|
<Mail className="w-12 h-12 text-gray-300" />
|
||||||
<p>暂无消息通知规则</p>
|
<p>暂无消息通知配置</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notification) => (
|
notifications.map((notification) => (
|
||||||
<tr key={notification.id}>
|
<tr key={`${notification.cookie_id}-${notification.channel_id}`}>
|
||||||
<td className="font-medium">{notification.name}</td>
|
<td className="font-medium text-blue-600 dark:text-blue-400">{notification.cookie_id}</td>
|
||||||
<td>
|
<td className="text-sm">
|
||||||
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded text-sm">
|
{notification.channel_name || `渠道 ${notification.channel_id}`}
|
||||||
{notification.trigger_keyword || '全部消息'}
|
|
||||||
</code>
|
|
||||||
</td>
|
|
||||||
<td className="text-sm text-gray-500">
|
|
||||||
{notification.channel_id || '-'}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{notification.enabled ? (
|
{notification.enabled ? (
|
||||||
@ -193,10 +198,10 @@ export function MessageNotifications() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleEnabled(notification)}
|
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 ? '禁用' : '启用'}
|
title={notification.enabled ? '禁用' : '启用'}
|
||||||
>
|
>
|
||||||
{notification.enabled ? (
|
{notification.enabled ? (
|
||||||
@ -206,15 +211,8 @@ export function MessageNotifications() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(notification)}
|
onClick={() => handleDelete(notification)}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
title="编辑"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(notification.id)}
|
|
||||||
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
|
|
||||||
title="删除"
|
title="删除"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-500" />
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
@ -229,59 +227,64 @@ export function MessageNotifications() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 添加/编辑通知弹窗 */}
|
{/* 添加通知弹窗 */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal-content max-w-md">
|
<div className="modal-content max-w-md">
|
||||||
<div className="modal-header flex items-center justify-between">
|
<div className="modal-header flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">添加消息通知</h2>
|
||||||
{editingNotification ? '编辑消息通知' : '添加消息通知'}
|
<button onClick={closeModal} className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg">
|
||||||
</h2>
|
|
||||||
<button onClick={closeModal} className="p-1 hover:bg-gray-100 rounded-lg">
|
|
||||||
<X className="w-4 h-4 text-gray-500" />
|
<X className="w-4 h-4 text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="modal-body space-y-4">
|
<div className="modal-body space-y-4">
|
||||||
<div>
|
<div className="input-group">
|
||||||
<label className="input-label">通知名称</label>
|
<label className="input-label">选择账号 *</label>
|
||||||
<input
|
<Select
|
||||||
type="text"
|
value={formAccountId}
|
||||||
value={formName}
|
onChange={setFormAccountId}
|
||||||
onChange={(e) => setFormName(e.target.value)}
|
options={[
|
||||||
className="input-ios"
|
{ value: '', label: '请选择账号' },
|
||||||
placeholder="如:订单通知"
|
...accounts.map((account) => ({
|
||||||
|
value: account.id,
|
||||||
|
label: account.id,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="请选择账号"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="input-group">
|
||||||
<label className="input-label">触发关键词(可选)</label>
|
<label className="input-label">选择通知渠道 *</label>
|
||||||
<input
|
<Select
|
||||||
type="text"
|
|
||||||
value={formKeyword}
|
|
||||||
onChange={(e) => setFormKeyword(e.target.value)}
|
|
||||||
className="input-ios"
|
|
||||||
placeholder="留空表示所有消息"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="input-label">通知渠道ID</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formChannelId}
|
value={formChannelId}
|
||||||
onChange={(e) => setFormChannelId(e.target.value)}
|
onChange={setFormChannelId}
|
||||||
className="input-ios"
|
options={[
|
||||||
placeholder="输入渠道ID"
|
{ value: '', label: '请选择通知渠道' },
|
||||||
|
...channels.map((channel) => ({
|
||||||
|
value: String(channel.id),
|
||||||
|
label: channel.name || channel.channel_name || `渠道 ${channel.id}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
placeholder="请选择通知渠道"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className=" text-sm text-gray-700">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<input
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">启用此通知</span>
|
||||||
type="checkbox"
|
<button
|
||||||
checked={formEnabled}
|
type="button"
|
||||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
onClick={() => setFormEnabled(!formEnabled)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
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>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
||||||
@ -289,7 +292,7 @@ export function MessageNotifications() {
|
|||||||
</button>
|
</button>
|
||||||
<button type="submit" className="btn-ios-primary" disabled={saving}>
|
<button type="submit" className="btn-ios-primary" disabled={saving}>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<span className="">
|
<span className="flex items-center gap-2">
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
保存中...
|
保存中...
|
||||||
</span>
|
</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 { Bell, RefreshCw, Plus, Edit2, Trash2, Send, Power, PowerOff, X, Loader2 } from 'lucide-react'
|
||||||
import { getNotificationChannels, deleteNotificationChannel, updateNotificationChannel, testNotificationChannel, addNotificationChannel } from '@/api/notifications'
|
import { getNotificationChannels, deleteNotificationChannel, updateNotificationChannel, testNotificationChannel, addNotificationChannel } from '@/api/notifications'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { NotificationChannel } from '@/types'
|
import type { NotificationChannel } from '@/types'
|
||||||
|
|
||||||
const channelTypeLabels: Record<string, string> = {
|
const channelTypeLabels: Record<string, string> = {
|
||||||
@ -18,6 +20,7 @@ const channelTypeLabels: Record<string, string> = {
|
|||||||
|
|
||||||
export function NotificationChannels() {
|
export function NotificationChannels() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [channels, setChannels] = useState<NotificationChannel[]>([])
|
const [channels, setChannels] = useState<NotificationChannel[]>([])
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
@ -29,6 +32,7 @@ export function NotificationChannels() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const loadChannels = async () => {
|
const loadChannels = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getNotificationChannels()
|
const result = await getNotificationChannels()
|
||||||
@ -43,8 +47,9 @@ export function NotificationChannels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadChannels()
|
loadChannels()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
const handleToggleEnabled = async (channel: NotificationChannel) => {
|
const handleToggleEnabled = async (channel: NotificationChannel) => {
|
||||||
try {
|
try {
|
||||||
@ -280,20 +285,20 @@ export function NotificationChannels() {
|
|||||||
placeholder="如:我的邮箱通知"
|
placeholder="如:我的邮箱通知"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="input-group">
|
||||||
<label className="input-label">渠道类型</label>
|
<label className="input-label">渠道类型</label>
|
||||||
<select
|
<Select
|
||||||
value={formType}
|
value={formType}
|
||||||
onChange={(e) => setFormType(e.target.value as typeof formType)}
|
onChange={(value) => setFormType(value as typeof formType)}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: 'email', label: '邮件' },
|
||||||
<option value="email">邮件</option>
|
{ value: 'wechat', label: '微信' },
|
||||||
<option value="wechat">微信</option>
|
{ value: 'dingtalk', label: '钉钉' },
|
||||||
<option value="dingtalk">钉钉</option>
|
{ value: 'feishu', label: '飞书' },
|
||||||
<option value="feishu">飞书</option>
|
{ value: 'webhook', label: 'Webhook' },
|
||||||
<option value="webhook">Webhook</option>
|
{ value: 'telegram', label: 'Telegram' },
|
||||||
<option value="telegram">Telegram</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="input-label">配置 (JSON)</label>
|
<label className="input-label">配置 (JSON)</label>
|
||||||
@ -307,15 +312,22 @@ export function NotificationChannels() {
|
|||||||
根据渠道类型填写对应的配置,如webhook_url、token等
|
根据渠道类型填写对应的配置,如webhook_url、token等
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className=" text-sm text-gray-700">
|
<div className="flex items-center justify-between pt-2">
|
||||||
<input
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">启用此渠道</span>
|
||||||
type="checkbox"
|
<button
|
||||||
checked={formEnabled}
|
type="button"
|
||||||
onChange={(e) => setFormEnabled(e.target.checked)}
|
onClick={() => setFormEnabled(!formEnabled)}
|
||||||
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
|
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>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
|
<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 { getOrders, deleteOrder } from '@/api/orders'
|
||||||
import { getAccounts } from '@/api/accounts'
|
import { getAccounts } from '@/api/accounts'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading } from '@/components/common/Loading'
|
import { PageLoading } from '@/components/common/Loading'
|
||||||
|
import { Select } from '@/components/common/Select'
|
||||||
import type { Order, Account } from '@/types'
|
import type { Order, Account } from '@/types'
|
||||||
|
|
||||||
const statusMap: Record<string, { label: string; class: string }> = {
|
const statusMap: Record<string, { label: string; class: string }> = {
|
||||||
@ -18,6 +20,7 @@ const statusMap: Record<string, { label: string; class: string }> = {
|
|||||||
|
|
||||||
export function Orders() {
|
export function Orders() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [orders, setOrders] = useState<Order[]>([])
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
@ -26,6 +29,7 @@ export function Orders() {
|
|||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
|
|
||||||
const loadOrders = async () => {
|
const loadOrders = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined)
|
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined)
|
||||||
@ -40,6 +44,7 @@ export function Orders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
const loadAccounts = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
const data = await getAccounts()
|
const data = await getAccounts()
|
||||||
setAccounts(data)
|
setAccounts(data)
|
||||||
@ -49,13 +54,15 @@ export function Orders() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadOrders()
|
loadOrders()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadOrders()
|
loadOrders()
|
||||||
}, [selectedAccount, selectedStatus])
|
}, [_hasHydrated, isAuthenticated, token, selectedAccount, selectedStatus])
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('确定要删除这个订单吗?')) return
|
if (!confirm('确定要删除这个订单吗?')) return
|
||||||
@ -102,51 +109,54 @@ export function Orders() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="vben-card"
|
className="vben-card"
|
||||||
>
|
>
|
||||||
|
<div className="vben-card-body">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div className="input-group">
|
||||||
<label className="input-label">筛选账号</label>
|
<label className="input-label">筛选账号</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedAccount}
|
value={selectedAccount}
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
onChange={setSelectedAccount}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: '', label: '所有账号' },
|
||||||
<option value="">所有账号</option>
|
...accounts.map((account) => ({
|
||||||
{accounts.map((account) => (
|
value: account.id,
|
||||||
<option key={account.id} value={account.id}>
|
label: account.id,
|
||||||
{account.id}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
placeholder="所有账号"
|
||||||
</select>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="input-group">
|
||||||
<label className="input-label">订单状态</label>
|
<label className="input-label">订单状态</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedStatus}
|
value={selectedStatus}
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
onChange={setSelectedStatus}
|
||||||
className="input-ios"
|
options={[
|
||||||
>
|
{ value: '', label: '所有状态' },
|
||||||
<option value="">所有状态</option>
|
{ value: 'processing', label: '处理中' },
|
||||||
<option value="processing">处理中</option>
|
{ value: 'processed', label: '已处理' },
|
||||||
<option value="processed">已处理</option>
|
{ value: 'shipped', label: '已发货' },
|
||||||
<option value="shipped">已发货</option>
|
{ value: 'completed', label: '已完成' },
|
||||||
<option value="completed">已完成</option>
|
{ value: 'cancelled', label: '已关闭' },
|
||||||
<option value="cancelled">已关闭</option>
|
]}
|
||||||
</select>
|
placeholder="所有状态"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="input-group">
|
||||||
<label className="input-label">搜索订单</label>
|
<label className="input-label">搜索订单</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
placeholder="搜索订单ID或商品ID..."
|
placeholder="搜索订单ID或商品ID..."
|
||||||
className="input-ios pl-12"
|
className="input-ios pl-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Orders List */}
|
{/* Orders List */}
|
||||||
|
|||||||
@ -1,23 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Search, ShoppingBag } from 'lucide-react'
|
import { Search, ShoppingBag, ExternalLink, MapPin, Heart } from 'lucide-react'
|
||||||
import { searchItems } from '@/api/search'
|
import { searchItems, SearchResultItem } from '@/api/search'
|
||||||
import { getAccounts } from '@/api/accounts'
|
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
import { ButtonLoading } from '@/components/common/Loading'
|
import { ButtonLoading } from '@/components/common/Loading'
|
||||||
import type { Item, Account } from '@/types'
|
|
||||||
|
|
||||||
export function ItemSearch() {
|
export function ItemSearch() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [selectedAccount, setSelectedAccount] = useState('')
|
const [results, setResults] = useState<SearchResultItem[]>([])
|
||||||
const [results, setResults] = useState<Item[]>([])
|
const [total, setTotal] = useState(0)
|
||||||
const [accounts, setAccounts] = useState<Account[]>([])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
getAccounts().then(setAccounts).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSearch = async (e?: React.FormEvent) => {
|
const handleSearch = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
@ -26,17 +19,29 @@ export function ItemSearch() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addToast({ type: 'info', message: '正在搜索中,请稍候...' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await searchItems(keyword, selectedAccount || undefined)
|
setResults([])
|
||||||
|
const result = await searchItems(keyword.trim())
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setResults(result.data || [])
|
setResults(result.data || [])
|
||||||
|
setTotal(result.total || result.data.length)
|
||||||
|
|
||||||
if ((result.data || []).length === 0) {
|
if ((result.data || []).length === 0) {
|
||||||
addToast({ type: 'info', message: '未找到相关商品' })
|
addToast({ type: 'info', message: '未找到相关商品' })
|
||||||
|
} else {
|
||||||
|
addToast({ type: 'success', message: `搜索完成,找到 ${result.data.length} 件商品` })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
addToast({ type: 'warning', message: result.error })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
addToast({ type: 'error', message: '搜索失败' })
|
addToast({ type: 'error', message: '搜索失败,请稍后重试' })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -45,10 +50,15 @@ export function ItemSearch() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="page-title">商品搜索</h1>
|
<h1 className="page-title">商品搜索</h1>
|
||||||
<p className="page-description">在闲鱼平台搜索商品</p>
|
<p className="page-description">在闲鱼平台搜索商品</p>
|
||||||
</div>
|
</div>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="badge-primary">共 {total} 件商品</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -56,9 +66,10 @@ export function ItemSearch() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="vben-card"
|
className="vben-card"
|
||||||
>
|
>
|
||||||
|
<div className="vben-card-body">
|
||||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={keyword}
|
value={keyword}
|
||||||
@ -67,20 +78,6 @@ export function ItemSearch() {
|
|||||||
className="input-ios pl-12"
|
className="input-ios pl-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full md:w-64">
|
|
||||||
<select
|
|
||||||
value={selectedAccount}
|
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
|
||||||
className="input-ios"
|
|
||||||
>
|
|
||||||
<option value="">使用所有账号搜索</option>
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<option key={account.id} value={account.id}>
|
|
||||||
{account.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@ -89,54 +86,108 @@ export function ItemSearch() {
|
|||||||
{loading ? <ButtonLoading /> : '搜索'}
|
{loading ? <ButtonLoading /> : '搜索'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
|
{results.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.1 }}
|
transition={{ delay: 0.1 }}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||||
>
|
>
|
||||||
{results.map((item, index) => (
|
{results.map((item, index) => (
|
||||||
<motion.div
|
<motion.a
|
||||||
key={item.id || index}
|
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 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.05 }}
|
transition={{ delay: index * 0.03 }}
|
||||||
className="vben-card group hover:shadow-ios-lg transition-all duration-300"
|
className="vben-card group hover:shadow-lg transition-all duration-300 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="aspect-square bg-gray-100 relative overflow-hidden">
|
{/* 商品图片 */}
|
||||||
{/* Placeholder for item image - in real app would use item.image_url */}
|
<div className="aspect-square bg-slate-100 dark:bg-slate-800 relative overflow-hidden">
|
||||||
<div className="absolute inset-0 flex items-center justify-center text-gray-300">
|
{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" />
|
<ShoppingBag className="w-12 h-12" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors" />
|
)}
|
||||||
|
{/* 外链图标 */}
|
||||||
|
<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 className="p-4">
|
</div>
|
||||||
<h3 className="font-medium text-gray-900 line-clamp-2 mb-2 h-12">
|
</div>
|
||||||
|
|
||||||
|
{/* 商品信息 */}
|
||||||
|
<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}
|
{item.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-lg font-bold text-red-500">¥{item.price}</span>
|
<span className="text-lg font-bold text-red-500">{item.price}</span>
|
||||||
<span className="text-sm text-gray-500">{item.cookie_id}</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>
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center text-sm text-gray-500">
|
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>ID: {item.item_id}</span>
|
<span className="truncate max-w-[60%]">{item.seller_name || '-'}</span>
|
||||||
{item.has_sku && <span className="badge-info">多规格</span>}
|
{item.area && (
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{item.area}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
</motion.div>
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.a>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!loading && results.length === 0 && (
|
{!loading && results.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500">
|
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||||
<ShoppingBag className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
<ShoppingBag className="w-16 h-16 text-slate-300 dark:text-slate-600 mx-auto mb-4" />
|
||||||
<p>输入关键词开始搜索</p>
|
<p>输入关键词开始搜索</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,19 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw } from 'lucide-react'
|
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw } from 'lucide-react'
|
||||||
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend } from '@/api/settings'
|
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend } from '@/api/settings'
|
||||||
import { useUIStore } from '@/store/uiStore'
|
import { useUIStore } from '@/store/uiStore'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
|
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
|
||||||
import type { SystemSettings } from '@/types'
|
import type { SystemSettings } from '@/types'
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const { addToast } = useUIStore()
|
const { addToast } = useUIStore()
|
||||||
|
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [settings, setSettings] = useState<SystemSettings | null>(null)
|
const [settings, setSettings] = useState<SystemSettings | null>(null)
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await getSystemSettings()
|
const result = await getSystemSettings()
|
||||||
@ -26,8 +29,9 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!_hasHydrated || !isAuthenticated || !token) return
|
||||||
loadSettings()
|
loadSettings()
|
||||||
}, [])
|
}, [_hasHydrated, isAuthenticated, token])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!settings) return
|
if (!settings) return
|
||||||
@ -79,7 +83,7 @@ export function Settings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-4xl">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="page-header flex-between flex-wrap gap-4">
|
<div className="page-header flex-between flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -98,10 +102,14 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 双列布局 */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{/* 左列 */}
|
||||||
|
<div className="space-y-4">
|
||||||
{/* General Settings */}
|
{/* General Settings */}
|
||||||
<div className="vben-card">
|
<div className="vben-card">
|
||||||
<div className="vben-card-header">
|
<div className="vben-card-header">
|
||||||
<h2 className="vben-card-title flex items-center gap-2">
|
<h2 className="vben-card-title">
|
||||||
<SettingsIcon className="w-4 h-4" />
|
<SettingsIcon className="w-4 h-4" />
|
||||||
基础设置
|
基础设置
|
||||||
</h2>
|
</h2>
|
||||||
@ -112,19 +120,13 @@ export function Settings() {
|
|||||||
<p className="font-medium text-slate-900 dark:text-slate-100">允许用户注册</p>
|
<p className="font-medium text-slate-900 dark:text-slate-100">允许用户注册</p>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">开启后允许新用户注册账号</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">开启后允许新用户注册账号</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="switch-ios">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings?.registration_enabled ?? true}
|
checked={settings?.registration_enabled ?? true}
|
||||||
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
|
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
|
<span className="switch-slider"></span>
|
||||||
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -133,19 +135,13 @@ export function Settings() {
|
|||||||
<p className="font-medium text-slate-900 dark:text-slate-100">显示默认登录信息</p>
|
<p className="font-medium text-slate-900 dark:text-slate-100">显示默认登录信息</p>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">登录页面显示默认账号密码提示</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">登录页面显示默认账号密码提示</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="switch-ios">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings?.show_login_info ?? true}
|
checked={settings?.show_login_info ?? true}
|
||||||
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
|
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
|
<span className="switch-slider"></span>
|
||||||
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -154,7 +150,7 @@ export function Settings() {
|
|||||||
{/* AI Settings */}
|
{/* AI Settings */}
|
||||||
<div className="vben-card">
|
<div className="vben-card">
|
||||||
<div className="vben-card-header">
|
<div className="vben-card-header">
|
||||||
<h2 className="vben-card-title flex items-center gap-2">
|
<h2 className="vben-card-title">
|
||||||
<Bot className="w-4 h-4 text-green-500" />
|
<Bot className="w-4 h-4 text-green-500" />
|
||||||
AI 设置
|
AI 设置
|
||||||
</h2>
|
</h2>
|
||||||
@ -195,11 +191,14 @@ export function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右列 */}
|
||||||
|
<div className="space-y-4">
|
||||||
{/* Email Settings */}
|
{/* Email Settings */}
|
||||||
<div className="vben-card">
|
<div className="vben-card">
|
||||||
<div className="vben-card-header">
|
<div className="vben-card-header">
|
||||||
<h2 className="vben-card-title flex items-center gap-2">
|
<h2 className="vben-card-title">
|
||||||
<Mail className="w-4 h-4 text-amber-500" />
|
<Mail className="w-4 h-4 text-amber-500" />
|
||||||
邮件设置
|
邮件设置
|
||||||
</h2>
|
</h2>
|
||||||
@ -258,7 +257,7 @@ export function Settings() {
|
|||||||
{/* Security Settings */}
|
{/* Security Settings */}
|
||||||
<div className="vben-card">
|
<div className="vben-card">
|
||||||
<div className="vben-card-header">
|
<div className="vben-card-header">
|
||||||
<h2 className="vben-card-title flex items-center gap-2">
|
<h2 className="vben-card-title">
|
||||||
<Shield className="w-4 h-4 text-red-500" />
|
<Shield className="w-4 h-4 text-red-500" />
|
||||||
安全设置
|
安全设置
|
||||||
</h2>
|
</h2>
|
||||||
@ -269,23 +268,19 @@ export function Settings() {
|
|||||||
<p className="font-medium text-slate-900 dark:text-slate-100">启用登录验证码</p>
|
<p className="font-medium text-slate-900 dark:text-slate-100">启用登录验证码</p>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">登录时需要输入图形验证码</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400">登录时需要输入图形验证码</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
<label className="switch-ios">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings?.login_captcha_enabled ?? false}
|
checked={settings?.login_captcha_enabled ?? false}
|
||||||
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
|
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
|
<span className="switch-slider"></span>
|
||||||
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,11 @@ interface AuthState {
|
|||||||
token: string | null
|
token: string | null
|
||||||
user: User | null
|
user: User | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
_hasHydrated: boolean
|
||||||
setAuth: (token: string, user: User) => void
|
setAuth: (token: string, user: User) => void
|
||||||
clearAuth: () => void
|
clearAuth: () => void
|
||||||
updateUser: (user: Partial<User>) => void
|
updateUser: (user: Partial<User>) => void
|
||||||
|
setHasHydrated: (state: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@ -17,6 +19,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
_hasHydrated: false,
|
||||||
|
|
||||||
setAuth: (token, user) => {
|
setAuth: (token, user) => {
|
||||||
localStorage.setItem('auth_token', token)
|
localStorage.setItem('auth_token', token)
|
||||||
@ -35,6 +38,10 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: state.user ? { ...state.user, ...userData } : null,
|
user: state.user ? { ...state.user, ...userData } : null,
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setHasHydrated: (state) => {
|
||||||
|
set({ _hasHydrated: state })
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
@ -43,6 +50,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
user: state.user,
|
user: state.user,
|
||||||
isAuthenticated: state.isAuthenticated
|
isAuthenticated: state.isAuthenticated
|
||||||
}),
|
}),
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
state?.setHasHydrated(true)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -108,13 +108,60 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vben-card-title {
|
.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 {
|
.vben-card-body {
|
||||||
@apply p-5;
|
@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 {
|
.glass-card {
|
||||||
@apply vben-card;
|
@apply vben-card;
|
||||||
@ -198,6 +245,20 @@
|
|||||||
@apply input-ios border-red-500 focus:border-red-500 focus:ring-red-500;
|
@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 {
|
.input-group {
|
||||||
@apply space-y-1.5;
|
@apply space-y-1.5;
|
||||||
|
|||||||
@ -54,14 +54,21 @@ export interface Keyword {
|
|||||||
|
|
||||||
// 商品相关类型
|
// 商品相关类型
|
||||||
export interface Item {
|
export interface Item {
|
||||||
id: string
|
id: string | number
|
||||||
cookie_id: string
|
cookie_id: string
|
||||||
item_id: string
|
item_id: string
|
||||||
title: string
|
title?: string
|
||||||
|
item_title?: string
|
||||||
desc?: string
|
desc?: string
|
||||||
price: string
|
item_description?: string
|
||||||
has_sku: boolean
|
item_detail?: string
|
||||||
multi_delivery: boolean
|
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
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -114,18 +121,20 @@ export interface Card {
|
|||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发货规则相关类型
|
// 发货规则相关类型 - 匹配后端接口
|
||||||
export interface DeliveryRule {
|
export interface DeliveryRule {
|
||||||
id: string
|
id: number
|
||||||
cookie_id: string
|
keyword: string
|
||||||
item_id?: string
|
card_id: number
|
||||||
keyword?: string
|
delivery_count: number
|
||||||
delivery_type: 'card' | 'text' | 'api'
|
|
||||||
delivery_content?: string
|
|
||||||
api_url?: string
|
|
||||||
api_method?: string
|
|
||||||
api_params?: string
|
|
||||||
enabled: boolean
|
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
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -145,18 +154,13 @@ export interface NotificationChannel {
|
|||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息通知相关类型
|
// 消息通知相关类型 - 匹配后端接口
|
||||||
|
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
|
||||||
export interface MessageNotification {
|
export interface MessageNotification {
|
||||||
id: string
|
cookie_id: string
|
||||||
cookie_id?: string
|
channel_id: number
|
||||||
name: string
|
channel_name?: string
|
||||||
notification_type?: string
|
|
||||||
trigger_keyword?: string
|
|
||||||
channel_id?: string
|
|
||||||
channel_ids?: string[]
|
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 系统设置相关类型
|
// 系统设置相关类型
|
||||||
|
|||||||
@ -61,7 +61,27 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
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',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user