fix: 修复账号管理、商品管理、商品搜索等多个页面问题

- 账号管理:修复编辑/启用/禁用功能,正确调用后端API
- 商品管理:修复商品列表显示,支持标题悬停查看完整内容
- 商品搜索:重写搜索页面,正确显示搜索结果和图片
- 关于页面:优化二维码显示,添加点击放大和悬停效果
- 更新favicon为简约聊天气泡图标
- 统一自动回复命名
This commit is contained in:
“legeling” 2025-11-28 00:31:18 +08:00
parent 543eed80e9
commit 02dea67e41
42 changed files with 1818 additions and 1299 deletions

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import React, { useEffect, useState, useRef } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/authStore'
import { MainLayout } from '@/components/layout/MainLayout'
@ -25,24 +25,34 @@ import { verifyToken } from '@/api/auth'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, setAuth, clearAuth } = useAuthStore()
const { isAuthenticated, setAuth, clearAuth, token: storeToken, _hasHydrated } = useAuthStore()
const [isChecking, setIsChecking] = useState(true)
const [isValid, setIsValid] = useState(false)
const hasCheckedRef = useRef(false)
useEffect(() => {
// 等待 zustand persist 完成 hydration
if (!_hasHydrated) return
// 防止重复检查
if (hasCheckedRef.current) return
const checkAuth = async () => {
const token = localStorage.getItem('auth_token')
// 优先使用 store 中的 token其次是 localStorage
const token = storeToken || localStorage.getItem('auth_token')
if (!token) {
setIsChecking(false)
setIsValid(false)
hasCheckedRef.current = true
return
}
// 如果已经认证,直接通过
if (isAuthenticated) {
// 如果 store 中已经认证且有用户信息,直接通过
if (isAuthenticated && storeToken) {
setIsChecking(false)
setIsValid(true)
hasCheckedRef.current = true
return
}
@ -65,14 +75,15 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
setIsValid(false)
} finally {
setIsChecking(false)
hasCheckedRef.current = true
}
}
checkAuth()
}, [isAuthenticated, setAuth, clearAuth])
}, [_hasHydrated, isAuthenticated, storeToken, setAuth, clearAuth])
// 显示加载状态
if (isChecking) {
// 等待 hydration 或检查完成
if (!_hasHydrated || isChecking) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>

View File

@ -1,24 +1,73 @@
import { get, post, put, del } from '@/utils/request'
import type { Account, AccountDetail, ApiResponse } from '@/types'
// 获取账号列表
export const getAccounts = (): Promise<Account[]> => {
return get('/cookies')
// 获取账号列表返回账号ID数组
export const getAccounts = async (): Promise<Account[]> => {
const ids: string[] = await get('/cookies')
// 后端返回的是账号ID数组转换为Account对象数组
return ids.map(id => ({
id,
cookie: '',
enabled: true,
use_ai_reply: false,
use_default_reply: false,
auto_confirm: false
}))
}
// 获取账号详情列表
export const getAccountDetails = (): Promise<AccountDetail[]> => {
return get('/cookies/details')
export const getAccountDetails = async (): Promise<AccountDetail[]> => {
interface BackendAccountDetail {
id: string
value: string
enabled: boolean
auto_confirm: boolean
remark?: string
pause_duration?: number
}
const data = await get<BackendAccountDetail[]>('/cookies/details')
// 后端返回 value 字段,前端使用 cookie 字段
return data.map((item) => ({
id: item.id,
cookie: item.value,
enabled: item.enabled,
auto_confirm: item.auto_confirm,
note: item.remark,
pause_duration: item.pause_duration,
use_ai_reply: false,
use_default_reply: false,
}))
}
// 添加账号
export const addAccount = (data: { id: string; cookie: string }): Promise<ApiResponse> => {
return post('/cookies', data)
// 后端需要 id 和 value 字段
return post('/cookies', { id: data.id, value: data.cookie })
}
// 更新账号
export const updateAccount = (id: string, data: Partial<Account>): Promise<ApiResponse> => {
return put(`/cookies/${id}`, data)
// 更新账号 Cookie 值
export const updateAccountCookie = (id: string, value: string): Promise<ApiResponse> => {
return put(`/cookies/${id}`, { id, value })
}
// 更新账号启用/禁用状态
export const updateAccountStatus = (id: string, enabled: boolean): Promise<ApiResponse> => {
return put(`/cookies/${id}/status`, { enabled })
}
// 更新账号备注
export const updateAccountRemark = (id: string, remark: string): Promise<ApiResponse> => {
return put(`/cookies/${id}/remark`, { remark })
}
// 更新账号自动确认设置
export const updateAccountAutoConfirm = (id: string, autoConfirm: boolean): Promise<ApiResponse> => {
return put(`/cookies/${id}/auto-confirm`, { auto_confirm: autoConfirm })
}
// 更新账号暂停时间
export const updateAccountPauseDuration = (id: string, pauseDuration: number): Promise<ApiResponse> => {
return put(`/cookies/${id}/pause-duration`, { pause_duration: pauseDuration })
}
// 删除账号

View File

@ -1,22 +1,33 @@
import { get, post, put, del } from '@/utils/request'
import { get, post, del } from '@/utils/request'
import type { ApiResponse, User } from '@/types'
// ========== 用户管理 ==========
// 获取用户列表
export const getUsers = (): Promise<{ success: boolean; data?: User[] }> => {
return get('/admin/users')
export const getUsers = async (): Promise<{ success: boolean; data?: User[] }> => {
const result = await get<{ users: Array<{
id: number
username: string
email?: string
is_admin: boolean
cookie_count?: number
card_count?: number
}> }>('/admin/users')
// 后端返回 { users: [...] } 格式,转换字段名
const users: User[] = (result.users || []).map(u => ({
user_id: u.id,
username: u.username,
email: u.email,
is_admin: u.is_admin,
}))
return { success: true, data: users }
}
// 添加用户
export const addUser = (data: { username: string; password: string; email?: string; is_admin?: boolean }): Promise<ApiResponse> => {
return post('/admin/users', data)
}
// TODO: 后端暂未实现 POST /admin/users 接口
// export const addUser = ...
// 更新用户
export const updateUser = (userId: number, data: Partial<User & { password?: string }>): Promise<ApiResponse> => {
return put(`/admin/users/${userId}`, data)
}
// TODO: 后端暂未实现 PUT /admin/users/{userId} 接口
// export const updateUser = ...
// 删除用户
export const deleteUser = (userId: number): Promise<ApiResponse> => {
@ -34,12 +45,21 @@ export interface SystemLog {
}
// 获取系统日志
export const getSystemLogs = (params?: { page?: number; limit?: number; level?: string }): Promise<{ success: boolean; data?: SystemLog[]; total?: number }> => {
export const getSystemLogs = async (params?: { page?: number; limit?: number; level?: string }): Promise<{ success: boolean; data?: SystemLog[]; total?: number }> => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.limit) query.set('limit', String(params.limit))
if (params?.level) query.set('level', params.level)
return get(`/admin/logs?${query.toString()}`)
const result = await get<{ logs?: string[]; total?: number }>(`/admin/logs?${query.toString()}`)
// 后端返回 { logs: [...] } 格式,转换为 SystemLog 数组
const logs: SystemLog[] = (result.logs || []).map((log, index) => ({
id: String(index),
level: log.includes('ERROR') ? 'error' : log.includes('WARNING') ? 'warning' : 'info',
message: log,
module: 'system',
created_at: new Date().toISOString(),
}))
return { success: true, data: logs, total: result.total }
}
// 清空系统日志
@ -58,12 +78,32 @@ export interface RiskLog {
}
// 获取风控日志
export const getRiskLogs = (params?: { page?: number; limit?: number; cookie_id?: string }): Promise<{ success: boolean; data?: RiskLog[]; total?: number }> => {
export const getRiskLogs = async (params?: { page?: number; limit?: number; cookie_id?: string }): Promise<{ success: boolean; data?: RiskLog[]; total?: number }> => {
const query = new URLSearchParams()
if (params?.page) query.set('page', String(params.page))
if (params?.limit) query.set('limit', String(params.limit))
if (params?.cookie_id) query.set('cookie_id', params.cookie_id)
return get(`/admin/risk-control-logs?${query.toString()}`)
const result = await get<{ success: boolean; data?: Array<{
id: number
cookie_id: string
event_type: string
event_description: string
processing_result: string
processing_status: string
error_message: string | null
created_at: string
updated_at: string
cookie_name: string
}>; total?: number }>(`/admin/risk-control-logs?${query.toString()}`)
// 转换后端格式为前端格式
const logs: RiskLog[] = (result.data || []).map(item => ({
id: String(item.id),
cookie_id: item.cookie_id || item.cookie_name,
risk_type: item.event_type,
message: item.event_description || item.processing_result,
created_at: item.created_at,
}))
return { success: true, data: logs, total: result.total }
}
// 清空风控日志

View File

@ -2,9 +2,12 @@ import { get, post, del } from '@/utils/request'
import type { ApiResponse, Card } from '@/types'
// 获取卡券列表
export const getCards = (accountId?: string): Promise<{ success: boolean; data?: Card[] }> => {
export const getCards = async (accountId?: string): Promise<{ success: boolean; data?: Card[] }> => {
const url = accountId ? `/cards?cookie_id=${accountId}` : '/cards'
return get(url)
const result = await get<Card[] | { cards?: Card[] }>(url)
// 后端可能返回数组或 { cards: [...] } 格式
const data = Array.isArray(result) ? result : (result.cards || [])
return { success: true, data }
}
// 获取账号的卡券列表

View File

@ -2,24 +2,26 @@ import { get, post, put, del } from '@/utils/request'
import type { ApiResponse, DeliveryRule } from '@/types'
// 获取发货规则列表
export const getDeliveryRules = (accountId?: string): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
const url = accountId ? `/api/delivery-rules?cookie_id=${accountId}` : '/api/delivery-rules'
return get(url)
export const getDeliveryRules = async (): Promise<{ success: boolean; data?: DeliveryRule[] }> => {
const result = await get<DeliveryRule[] | { rules?: DeliveryRule[] }>('/delivery-rules')
// 后端可能返回数组或 { rules: [...] } 格式
const data = Array.isArray(result) ? result : (result.rules || [])
return { success: true, data }
}
// 添加发货规则
export const addDeliveryRule = (data: Partial<DeliveryRule>): Promise<ApiResponse> => {
return post('/api/delivery-rules', data)
return post('/delivery-rules', data)
}
// 更新发货规则
export const updateDeliveryRule = (ruleId: string, data: Partial<DeliveryRule>): Promise<ApiResponse> => {
return put(`/api/delivery-rules/${ruleId}`, data)
return put(`/delivery-rules/${ruleId}`, data)
}
// 删除发货规则
export const deleteDeliveryRule = (ruleId: string): Promise<ApiResponse> => {
return del(`/api/delivery-rules/${ruleId}`)
return del(`/delivery-rules/${ruleId}`)
}
// 获取账号的发货规则

View File

@ -2,9 +2,12 @@ import { get, post, put, del } from '@/utils/request'
import type { Item, ItemReply, ApiResponse } from '@/types'
// 获取商品列表
export const getItems = (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
const params = cookieId ? `?cookie_id=${cookieId}` : ''
return get(`/items${params}`)
export const getItems = async (cookieId?: string): Promise<{ success: boolean; data: Item[] }> => {
const url = cookieId ? `/items/cookie/${cookieId}` : '/items'
const result = await get<{ items?: Item[] } | Item[]>(url)
// 后端返回 { items: [...] } 或直接返回数组
const items = Array.isArray(result) ? result : (result.items || [])
return { success: true, data: items }
}
// 删除商品

View File

@ -5,47 +5,61 @@ import type { ApiResponse, NotificationChannel, MessageNotification } from '@/ty
// 获取通知渠道列表
export const getNotificationChannels = (): Promise<{ success: boolean; data?: NotificationChannel[] }> => {
return get('/api/notification-channels')
return get('/notification-channels')
}
// 添加通知渠道
export const addNotificationChannel = (data: Partial<NotificationChannel>): Promise<ApiResponse> => {
return post('/api/notification-channels', data)
return post('/notification-channels', data)
}
// 更新通知渠道
export const updateNotificationChannel = (channelId: string, data: Partial<NotificationChannel>): Promise<ApiResponse> => {
return put(`/api/notification-channels/${channelId}`, data)
return put(`/notification-channels/${channelId}`, data)
}
// 删除通知渠道
export const deleteNotificationChannel = (channelId: string): Promise<ApiResponse> => {
return del(`/api/notification-channels/${channelId}`)
return del(`/notification-channels/${channelId}`)
}
// 测试通知渠道
export const testNotificationChannel = (channelId: string): Promise<ApiResponse> => {
return post(`/api/notification-channels/${channelId}/test`)
return post(`/notification-channels/${channelId}/test`)
}
// ========== 消息通知 ==========
// 获取消息通知列表
export const getMessageNotifications = (): Promise<{ success: boolean; data?: MessageNotification[] }> => {
return get('/api/message-notifications')
// 获取所有消息通知配置
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
export const getMessageNotifications = async (): Promise<{ success: boolean; data?: MessageNotification[] }> => {
const result = await get<Record<string, Record<string, { enabled: boolean; channel_name?: string }>>>('/message-notifications')
// 将嵌套对象转换为数组
const notifications: MessageNotification[] = []
for (const [cookieId, channels] of Object.entries(result || {})) {
for (const [channelId, config] of Object.entries(channels || {})) {
notifications.push({
cookie_id: cookieId,
channel_id: Number(channelId),
channel_name: config.channel_name,
enabled: config.enabled,
})
}
}
return { success: true, data: notifications }
}
// 添加消息通知
export const addMessageNotification = (data: Partial<MessageNotification>): Promise<ApiResponse> => {
return post('/api/message-notifications', data)
}
// 更新消息通知
export const updateMessageNotification = (notificationId: string, data: Partial<MessageNotification>): Promise<ApiResponse> => {
return put(`/api/message-notifications/${notificationId}`, data)
// 设置消息通知 - 后端接口需要 cookie_id 作为路径参数
export const setMessageNotification = (cookieId: string, channelId: number, enabled: boolean): Promise<ApiResponse> => {
return post(`/message-notifications/${cookieId}`, { channel_id: channelId, enabled })
}
// 删除消息通知
export const deleteMessageNotification = (notificationId: string): Promise<ApiResponse> => {
return del(`/api/message-notifications/${notificationId}`)
return del(`/message-notifications/${notificationId}`)
}
// 删除账号的所有消息通知
export const deleteAccountNotifications = (cookieId: string): Promise<ApiResponse> => {
return del(`/message-notifications/account/${cookieId}`)
}

View File

@ -1,7 +1,35 @@
import { post } from '@/utils/request'
import type { Item } from '@/types'
// 搜索结果项类型
export interface SearchResultItem {
item_id: string
title: string
price: string
seller_name?: string
item_url?: string
main_image?: string
publish_time?: string
tags?: string[]
area?: string
want_count?: number
}
// 搜索商品
export const searchItems = (keyword: string, accountId?: string): Promise<{ success: boolean; data?: Item[] }> => {
return post('/api/items/search', { keyword, cookie_id: accountId })
export const searchItems = async (
keyword: string,
page: number = 1,
pageSize: number = 20
): Promise<{ success: boolean; data: SearchResultItem[]; total?: number; error?: string }> => {
const result = await post<{
success: boolean
data?: SearchResultItem[]
total?: number
error?: string
}>('/items/search', { keyword, page, page_size: pageSize })
return {
success: result.success,
data: result.data || [],
total: result.total,
error: result.error
}
}

View File

@ -1,18 +1,34 @@
import { get, post, put } from '@/utils/request'
import { get, put } from '@/utils/request'
import type { ApiResponse, SystemSettings } from '@/types'
// 获取系统设置
export const getSystemSettings = (): Promise<{ success: boolean; data?: SystemSettings }> => {
return get('/system-settings')
export const getSystemSettings = async (): Promise<{ success: boolean; data?: SystemSettings }> => {
const data = await get<Record<string, unknown>>('/system-settings')
// 将字符串 'true'/'false' 转换为布尔值
const booleanFields = ['registration_enabled', 'show_login_info', 'login_captcha_enabled', 'show_default_login']
const converted: SystemSettings = {}
for (const [key, value] of Object.entries(data)) {
if (booleanFields.includes(key)) {
converted[key] = value === true || value === 'true'
} else {
converted[key] = value
}
}
return { success: true, data: converted }
}
// 更新系统设置
export const updateSystemSettings = (data: Partial<SystemSettings>): Promise<ApiResponse> => {
// 逐个更新设置项
const promises = Object.entries(data).map(([key, value]) =>
put(`/system-settings/${key}`, { value })
)
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
export const updateSystemSettings = async (data: Partial<SystemSettings>): Promise<ApiResponse> => {
// 逐个更新设置项,确保 value 是字符串
const promises = Object.entries(data).map(([key, value]) => {
// 将布尔值和数字转换为字符串
const stringValue = typeof value === 'boolean' ? (value ? 'true' : 'false')
: typeof value === 'number' ? String(value)
: value
return put(`/system-settings/${key}`, { value: stringValue })
})
await Promise.all(promises)
return { success: true, message: '设置已保存' }
}
// 获取 AI 设置
@ -25,9 +41,11 @@ export const updateAISettings = (data: Record<string, unknown>): Promise<ApiResp
return put('/ai-reply-settings', data)
}
// 测试 AI 连接
export const testAIConnection = (): Promise<ApiResponse> => {
return post('/ai-reply-test/default')
// TODO: 测试 AI 连接需要指定 cookie_id后端接口为 POST /ai-reply-test/{cookie_id}
// 系统设置页面的测试按钮暂时无法使用,需要先选择账号
export const testAIConnection = async (): Promise<ApiResponse> => {
// 后端需要有效的 cookie_id这里返回提示信息
return { success: false, message: 'AI 测试需要先选择一个账号,请在账号管理页面的 AI 设置中测试' }
}
// 获取邮件设置
@ -43,7 +61,8 @@ export const updateEmailSettings = (data: Record<string, unknown>): Promise<ApiR
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
}
// 测试邮件发送
export const testEmailSend = (email: string): Promise<ApiResponse> => {
return post('/send-verification-code', { email, type: 'test' })
// TODO: 测试邮件发送功能需要后端支持 type: 'test' 参数
// 当前后端的 /send-verification-code 接口只支持 'register' 和 'login' 类型
export const testEmailSend = async (_email: string): Promise<ApiResponse> => {
return { success: false, message: '邮件测试功能暂未实现,请检查 SMTP 配置后直接保存' }
}

View File

@ -21,17 +21,17 @@ export function Loading({ size = 'md', fullScreen = false, text }: LoadingProps)
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
>
<Loader2 className={cn('text-primary-500', sizes[size])} />
<Loader2 className={cn('text-blue-500', sizes[size])} />
</motion.div>
{text && (
<p className="text-sm text-gray-500 font-medium">{text}</p>
<p className="text-sm text-slate-500 dark:text-slate-400 font-medium">{text}</p>
)}
</div>
)
if (fullScreen) {
return (
<div className="fixed inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm z-50 flex items-center justify-center">
{content}
</div>
)

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

View File

@ -11,11 +11,14 @@ export function MainLayout() {
{/* Main content area */}
<div className="lg:ml-56 min-h-screen flex flex-col">
{/* Top navbar */}
<TopNavbar />
{/* Tabs bar */}
<TabsBar />
{/* Fixed header area */}
<div className="sticky top-0 z-30 bg-slate-50 dark:bg-slate-900">
{/* Top navbar */}
<TopNavbar />
{/* Tabs bar */}
<TabsBar />
</div>
{/* Page content */}
<main className="flex-1 p-4 lg:p-6">

View File

@ -74,8 +74,9 @@ export function Sidebar() {
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-all duration-150',
'text-slate-400 hover:text-white hover:bg-white/10',
isActive && 'bg-blue-600 text-white shadow-sm'
isActive
? 'bg-blue-600 text-white dark:text-white hover:text-white hover:bg-blue-700 shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10'
)
}
>
@ -106,24 +107,25 @@ export function Sidebar() {
}}
className={cn(
'fixed top-0 left-0 h-screen w-56 z-50',
'bg-[#001529] text-white',
'bg-white dark:bg-[#001529]',
'flex flex-col',
'transition-transform duration-200 ease-out',
'border-r border-slate-200 dark:border-slate-700',
'lg:translate-x-0',
!sidebarMobileOpen && '-translate-x-full lg:translate-x-0'
)}
>
{/* Header */}
<div className="h-14 flex items-center justify-between px-4 border-b border-white/5">
<div className="h-14 flex items-center justify-between px-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-blue-500 flex items-center justify-center">
<MessageSquare className="w-4 h-4 text-white" />
</div>
<span className="font-semibold text-sm text-white"></span>
<span className="font-semibold text-sm text-slate-900 dark:text-white"></span>
</div>
<button
onClick={closeMobileSidebar}
className="lg:hidden p-1.5 hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-white"
className="lg:hidden p-1.5 hover:bg-slate-100 dark:hover:bg-white/10 rounded transition-colors text-slate-400 hover:text-slate-900 dark:hover:text-white"
>
<X className="w-4 h-4" />
</button>
@ -139,7 +141,7 @@ export function Sidebar() {
{user?.is_admin && (
<>
<div className="pt-4 pb-2 px-3">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wider">
<p className="text-xs font-medium text-slate-400 dark:text-gray-500 uppercase tracking-wider">
</p>
</div>

View File

@ -23,7 +23,7 @@ const routeTitles: Record<string, string> = {
'/dashboard': '仪表盘',
'/accounts': '账号管理',
'/items': '商品管理',
'/keywords': '关键词管理',
'/keywords': '自动回复',
'/item-replies': '指定商品回复',
'/orders': '订单管理',
'/cards': '卡券管理',

View File

@ -1,128 +1,217 @@
import { MessageSquare, Github, Globe, Users, Heart } from 'lucide-react'
import { useState } from 'react'
import { MessageSquare, Github, Heart, Code, MessageCircle, Users, UserCheck, Bot, Truck, Bell, BarChart3, X } from 'lucide-react'
export function About() {
const [previewImage, setPreviewImage] = useState<string | null>(null)
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="max-w-5xl mx-auto space-y-4">
{/* Header */}
<div className="text-center mb-8">
<div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="w-24 h-24 rounded-3xl bg-gradient-to-br from-primary-500 to-primary-600
mx-auto mb-6 flex items-center justify-center shadow-lg"
<div className="text-center mb-6">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-blue-500 to-blue-600
mx-auto mb-4 flex items-center justify-center shadow-md"
>
<MessageSquare className="w-12 h-12 text-white" />
<MessageSquare className="w-8 h-8 text-white" />
</div>
<motion.h1
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="text-3xl font-bold text-slate-900 dark:text-slate-100"
>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
</motion.h1>
<motion.p
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="text-slate-500 dark:text-slate-400 mt-2"
>
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
</motion.p>
</p>
</div>
{/* Contact Groups */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<MessageCircle className="w-4 h-4 text-green-500" />
</h2>
</div>
<div className="vben-card-body text-center">
<div
className="w-[160px] h-[160px] mx-auto overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700
cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-green-400"
onClick={() => setPreviewImage('/static/wechat-group.png')}
>
<img
src="/static/wechat-group.png"
alt="微信群二维码"
className="w-full h-full object-cover object-center"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
const parent = (e.target as HTMLImageElement).parentElement
if (parent) {
parent.innerHTML = '<p class="text-slate-400 dark:text-slate-500 py-12 text-sm">二维码未配置</p>'
}
}}
/>
</div>
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400"></p>
</div>
</div>
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Users className="w-4 h-4 text-blue-500" />
QQ群
</h2>
</div>
<div className="vben-card-body text-center">
<div
className="w-[160px] h-[160px] mx-auto overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700
cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg hover:border-blue-400"
onClick={() => setPreviewImage('/static/qq-group.png')}
>
<img
src="/static/qq-group.png"
alt="QQ群二维码"
className="w-full h-full object-cover object-center"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
const parent = (e.target as HTMLImageElement).parentElement
if (parent) {
parent.innerHTML = '<p class="text-slate-400 dark:text-slate-500 py-12 text-sm">二维码未配置</p>'
}
}}
/>
</div>
<p className="mt-3 text-sm text-slate-500 dark:text-slate-400">QQ技术交流群</p>
</div>
</div>
</div>
{/* Features */}
<div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="vben-card"
>
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ title: '多账号管理', desc: '支持同时管理多个闲鱼账号' },
{ title: '智能自动回复', desc: '基于关键词的智能消息回复' },
{ title: 'AI 助手', desc: '接入 AI 模型,智能处理复杂问题' },
{ title: '自动发货', desc: '订单自动发货,支持卡密发货' },
{ title: '消息通知', desc: '多渠道消息推送通知' },
{ title: '数据统计', desc: '订单、商品数据统计分析' },
].map((feature, index) => (
<div
key={index}
className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-slate-800"
>
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">{feature.title}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{feature.desc}</p>
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title"></h2>
</div>
<div className="vben-card-body">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ title: '多账号管理', desc: '同时管理多个账号', icon: UserCheck, color: 'text-blue-500' },
{ title: '智能回复', desc: '关键词自动回复', icon: MessageSquare, color: 'text-green-500' },
{ title: 'AI 助手', desc: '智能处理复杂问题', icon: Bot, color: 'text-purple-500' },
{ title: '自动发货', desc: '支持卡密发货', icon: Truck, color: 'text-orange-500' },
{ title: '消息通知', desc: '多渠道推送', icon: Bell, color: 'text-pink-500' },
{ title: '数据统计', desc: '订单商品分析', icon: BarChart3, color: 'text-cyan-500' },
].map((feature, index) => (
<div
key={index}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800 flex items-center gap-3"
>
<div className={`w-10 h-10 rounded-lg bg-white dark:bg-slate-700 flex items-center justify-center shadow-sm ${feature.color}`}>
<feature.icon className="w-5 h-5" />
</div>
<div className="text-left">
<p className="font-medium text-sm text-slate-900 dark:text-slate-100">{feature.title}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{feature.desc}</p>
</div>
</div>
</div>
))}
))}
</div>
</div>
</div>
{/* Contributors */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Code className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body">
<div className="flex flex-wrap gap-3">
<a
href="https://github.com/zhinianboke"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700
hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">zhinianboke</span>
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
</a>
<a
href="https://github.com/legeling"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-100 dark:bg-slate-700
hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors"
>
<Github className="w-4 h-4 text-slate-600 dark:text-slate-300" />
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">legeling</span>
<span className="text-xs text-slate-500 dark:text-slate-400"></span>
</a>
</div>
</div>
</div>
{/* Links */}
<div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="vben-card"
>
<h2 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-4"></h2>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4 rounded-xl bg-gray-900 text-white
hover:bg-gray-800 transition-colors"
>
<Github className="w-6 h-6" />
<span className="font-medium">GitHub</span>
</a>
<a
href="#"
className="flex items-center gap-3 p-4 rounded-xl bg-blue-500 text-white
hover:bg-blue-600 transition-colors"
>
<Globe className="w-6 h-6" />
<span className="font-medium"></span>
</a>
<a
href="#"
className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500 text-white
hover:bg-emerald-600 transition-colors"
>
<Users className="w-6 h-6" />
<span className="font-medium"></span>
</a>
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title"></h2>
</div>
<div className="vben-card-body">
<div className="flex gap-3">
<a
href="https://github.com/zhinianboke/xianyu-auto-reply"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-900 text-white
hover:bg-gray-800 transition-colors text-sm"
>
<Github className="w-4 h-4" />
<span>GitHub</span>
</a>
</div>
</div>
</div>
{/* Footer */}
<div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="text-center py-6 text-slate-500 dark:text-slate-400"
>
<div className="text-center py-4 text-slate-500 dark:text-slate-400 text-sm">
<p className="flex items-center justify-center gap-1">
Made with <Heart className="w-4 h-4 text-red-500" /> by Open Source Community
Made with <Heart className="w-3.5 h-3.5 text-red-500" /> by Open Source Community
</p>
<p className="text-sm mt-2">
{' '}
<p className="mt-1 text-xs">
<a
href="https://www.hsykj.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 dark:text-blue-400 hover:underline"
className="text-blue-500 dark:text-blue-400 hover:underline ml-1"
>
www.hsykj.com
</a>
</p>
</div>
{/* 图片预览弹窗 */}
{previewImage && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={() => setPreviewImage(null)}
>
<div className="relative max-w-[90vw] max-h-[90vh]">
<button
onClick={() => setPreviewImage(null)}
className="absolute -top-10 right-0 p-2 text-white hover:text-gray-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
<img
src={previewImage}
alt="预览"
className="max-w-full max-h-[85vh] rounded-lg shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
)}
</div>
)
}

View File

@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import type { FormEvent } from 'react'
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getAccountDetails, deleteAccount, updateAccount, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2, Copy } from 'lucide-react'
import { getAccountDetails, deleteAccount, updateAccountCookie, updateAccountStatus, updateAccountRemark, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { AccountDetail } from '@/types'
@ -10,6 +11,7 @@ type ModalType = 'qrcode' | 'password' | 'manual' | 'edit' | null
export function Accounts() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [accounts, setAccounts] = useState<AccountDetail[]>([])
const [activeModal, setActiveModal] = useState<ModalType>(null)
@ -34,12 +36,11 @@ export function Accounts() {
// 编辑账号状态
const [editingAccount, setEditingAccount] = useState<AccountDetail | null>(null)
const [editNote, setEditNote] = useState('')
const [editUseAI, setEditUseAI] = useState(false)
const [editUseDefault, setEditUseDefault] = useState(false)
const [editCookie, setEditCookie] = useState('')
const [editSaving, setEditSaving] = useState(false)
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const data = await getAccountDetails()
@ -52,8 +53,9 @@ export function Accounts() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
}, [])
}, [_hasHydrated, isAuthenticated, token])
// 清理扫码检查定时器
const clearQrCheck = useCallback(() => {
@ -111,14 +113,22 @@ export function Accounts() {
case 'scanned':
setQrStatus('scanned')
break
case 'processing':
// 正在处理中,显示已扫描状态
setQrStatus('scanned')
break
case 'success':
case 'already_processed':
// 登录成功或已处理完成
setQrStatus('success')
clearQrCheck()
addToast({
type: 'success',
message: result.account_info?.is_new_account
? `新账号 ${result.account_info.account_id} 添加成功`
: `账号 ${result.account_info?.account_id} 登录成功`,
: result.account_info?.account_id
? `账号 ${result.account_info.account_id} 登录成功`
: '账号登录成功',
})
setTimeout(() => {
closeModal()
@ -227,7 +237,7 @@ export function Accounts() {
const handleToggleEnabled = async (account: AccountDetail) => {
try {
await updateAccount(account.id, { enabled: !account.enabled })
await updateAccountStatus(account.id, !account.enabled)
addToast({ type: 'success', message: account.enabled ? '账号已禁用' : '账号已启用' })
loadAccounts()
} catch {
@ -250,8 +260,6 @@ export function Accounts() {
const openEditModal = (account: AccountDetail) => {
setEditingAccount(account)
setEditNote(account.note || '')
setEditUseAI(account.use_ai_reply || false)
setEditUseDefault(account.use_default_reply || false)
setEditCookie(account.cookie || '')
setActiveModal('edit')
}
@ -262,12 +270,20 @@ export function Accounts() {
setEditSaving(true)
try {
await updateAccount(editingAccount.id, {
note: editNote.trim() || undefined,
use_ai_reply: editUseAI,
use_default_reply: editUseDefault,
cookie: editCookie.trim() || undefined,
})
// 分别调用不同的 API 更新不同字段
const promises: Promise<unknown>[] = []
// 更新备注
if (editNote.trim() !== (editingAccount.note || '')) {
promises.push(updateAccountRemark(editingAccount.id, editNote.trim()))
}
// 更新 Cookie 值
if (editCookie.trim() && editCookie.trim() !== editingAccount.cookie) {
promises.push(updateAccountCookie(editingAccount.id, editCookie.trim()))
}
await Promise.all(promises)
addToast({ type: 'success', message: '账号信息已更新' })
closeModal()
loadAccounts()
@ -314,45 +330,45 @@ export function Accounts() {
{/* 扫码登录 */}
<button
onClick={startQRCodeLogin}
className="flex items-center gap-3 p-4 rounded-md border border-indigo-200
bg-blue-50 hover:bg-blue-100 transition-colors text-left"
className="flex items-center gap-3 p-4 rounded-md border border-blue-200 dark:border-blue-800
bg-blue-50 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors text-left"
>
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
<QrCode className="w-4 h-4 text-white" />
</div>
<div>
<p className="font-medium text-gray-900 text-sm"></p>
<p className="text-xs text-gray-500"></p>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm"></p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
</div>
</button>
{/* 账号密码登录 */}
<button
onClick={() => setActiveModal('password')}
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
className="flex items-center gap-3 p-4 rounded-md border border-slate-200 dark:border-slate-700
hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors text-left"
>
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4 text-gray-600" />
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
<Key className="w-4 h-4 text-slate-600 dark:text-slate-300" />
</div>
<div>
<p className="font-medium text-gray-900 text-sm"></p>
<p className="text-xs text-gray-500">使</p>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm"></p>
<p className="text-xs text-slate-500 dark:text-slate-400">使</p>
</div>
</button>
{/* 手动输入 */}
<button
onClick={() => setActiveModal('manual')}
className="flex items-center gap-3 p-4 rounded-md border border-gray-200
hover:border-indigo-200 hover:bg-blue-50 transition-colors text-left"
className="flex items-center gap-3 p-4 rounded-md border border-slate-200 dark:border-slate-700
hover:border-blue-300 dark:hover:border-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition-colors text-left"
>
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
<Edit2 className="w-4 h-4 text-gray-600" />
<div className="w-10 h-10 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center flex-shrink-0">
<Edit2 className="w-4 h-4 text-slate-600 dark:text-slate-300" />
</div>
<div>
<p className="font-medium text-gray-900 text-sm"></p>
<p className="text-xs text-gray-500">Cookie</p>
<p className="font-medium text-slate-900 dark:text-slate-100 text-sm"></p>
<p className="text-xs text-slate-500 dark:text-slate-400">Cookie</p>
</div>
</button>
</div>
@ -383,7 +399,7 @@ export function Accounts() {
<tr>
<td colSpan={7}>
<div className="empty-state py-8">
<p className="text-gray-500"></p>
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
</td>
</tr>
@ -392,9 +408,23 @@ export function Accounts() {
<tr key={account.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{account.id}</td>
<td>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded max-w-[120px] truncate block">
{account.cookie?.substring(0, 25)}...
</code>
<div className="flex items-center gap-2">
<code className="text-xs font-mono bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded text-slate-600 dark:text-slate-300 max-w-[180px] truncate block">
{account.cookie ? account.cookie.substring(0, 30) + '...' : '无'}
</code>
{account.cookie && (
<button
onClick={() => {
navigator.clipboard.writeText(account.cookie || '')
addToast({ type: 'success', message: 'Cookie已复制' })
}}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded transition-colors"
title="复制Cookie"
>
<Copy className="w-3.5 h-3.5 text-slate-400 hover:text-blue-500" />
</button>
)}
</div>
</td>
<td>
<span className={`inline-flex items-center gap-1.5 ${account.enabled !== false ? 'text-green-600' : 'text-gray-400'}`}>
@ -416,31 +446,33 @@ export function Accounts() {
{account.note || '-'}
</td>
<td>
<div className="table-actions">
<div className="flex items-center gap-1">
<button
onClick={() => handleToggleEnabled(account)}
className="table-action-btn"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
title={account.enabled !== false ? '禁用' : '启用'}
>
{account.enabled !== false ? (
<PowerOff className="w-4 h-4 text-amber-500" />
<><PowerOff className="w-3.5 h-3.5 text-amber-500" /><span className="text-amber-600 dark:text-amber-400"></span></>
) : (
<Power className="w-4 h-4 text-green-500" />
<><Power className="w-3.5 h-3.5 text-green-500" /><span className="text-green-600 dark:text-green-400"></span></>
)}
</button>
<button
onClick={() => openEditModal(account)}
className="table-action-btn"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500" />
<Edit2 className="w-3.5 h-3.5 text-blue-500" />
<span className="text-blue-600 dark:text-blue-400"></span>
</button>
<button
onClick={() => handleDelete(account.id)}
className="table-action-btn hover:!bg-red-50"
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
<Trash2 className="w-3.5 h-3.5 text-red-500" />
<span className="text-red-600 dark:text-red-400"></span>
</button>
</div>
</td>
@ -466,14 +498,14 @@ export function Accounts() {
{qrStatus === 'loading' && (
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-10 h-10 text-blue-600 dark:text-blue-400 animate-spin" />
<p className="text-sm text-gray-500">...</p>
<p className="text-sm text-slate-500 dark:text-slate-400">...</p>
</div>
)}
{qrStatus === 'ready' && (
<div className="flex flex-col items-center gap-3">
<img src={qrCodeUrl} alt="登录二维码" className="w-44 h-44 rounded-lg border" />
<p className="text-sm text-gray-600">使APP扫描二维码</p>
<p className="text-xs text-gray-400">5</p>
<p className="text-sm text-slate-600 dark:text-slate-300">使APP扫描二维码</p>
<p className="text-xs text-slate-400 dark:text-slate-500">5</p>
</div>
)}
{qrStatus === 'scanned' && (
@ -495,7 +527,7 @@ export function Accounts() {
)}
{qrStatus === 'expired' && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-gray-500"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<button onClick={refreshQRCode} className="btn-ios-primary btn-sm">
</button>
@ -547,12 +579,12 @@ export function Accounts() {
placeholder="请输入密码"
/>
</div>
<label className=" text-sm text-gray-600">
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<input
type="checkbox"
checked={pwdShowBrowser}
onChange={(e) => setPwdShowBrowser(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
className="h-4 w-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
/>
</label>
@ -654,7 +686,7 @@ export function Accounts() {
type="text"
value={editingAccount.id}
disabled
className="input-ios"
className="input-ios bg-slate-100 dark:bg-slate-700"
/>
</div>
<div className="input-group">
@ -672,30 +704,17 @@ export function Accounts() {
<textarea
value={editCookie}
onChange={(e) => setEditCookie(e.target.value)}
className="input-ios h-20 resize-none font-mono text-xs"
className="input-ios h-28 resize-none font-mono text-xs"
placeholder="更新Cookie值"
/>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
Cookie长度: {editCookie.length}
</p>
</div>
<div className="space-y-2">
<label className=" text-sm text-gray-700">
<input
type="checkbox"
checked={editUseAI}
onChange={(e) => setEditUseAI(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
/>
AI回复
</label>
<label className=" text-sm text-gray-700">
<input
type="checkbox"
checked={editUseDefault}
onChange={(e) => setEditUseDefault(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 dark:text-blue-400"
/>
</label>
</div>
{/* AI回复和默认回复设置请在"自动回复"页面配置 */}
<p className="text-xs text-slate-500 dark:text-slate-400 pt-2">
AI回复和默认回复设置请在"自动回复"
</p>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={editSaving}>

View File

@ -110,144 +110,133 @@ export function DataManagement() {
return (
<div className="space-y-4">
{/* Header */}
<div>
<div className="page-header">
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
{/* Export Section */}
<div
className="vben-card"
>
<div className="vben-card-header">
<h2 className="vben-card-title ">
<Download className="w-4 h-4" />
</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{dataTypes.map((type) => (
<div
key={type.id}
className="border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-primary-300
hover:bg-primary-50/30 transition-colors"
>
<div className="flex items-start justify-between">
{/* 双列布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 左列 - 数据导出 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Download className="w-4 h-4 text-blue-500" />
</h2>
</div>
<div className="vben-card-body">
<div className="space-y-3">
{dataTypes.map((type) => (
<div
key={type.id}
className="flex items-center justify-between p-3 rounded-lg border border-slate-200 dark:border-slate-700
hover:border-blue-300 dark:hover:border-blue-600 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-colors"
>
<div>
<h3 className="font-medium text-slate-900 dark:text-slate-100">{type.name}</h3>
<p className="text-sm page-description">{type.desc}</p>
<h3 className="font-medium text-slate-900 dark:text-slate-100 text-sm">{type.name}</h3>
<p className="text-xs text-slate-500 dark:text-slate-400">{type.desc}</p>
</div>
<button
onClick={() => handleExport(type.id)}
disabled={exporting !== null}
className="btn-ios-secondary py-2 px-3 text-sm"
className="btn-ios-secondary py-1.5 px-3 text-xs"
>
{exporting === type.id ? <ButtonLoading /> : <Download className="w-4 h-4" />}
{exporting === type.id ? <ButtonLoading /> : <Download className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
))}
</div>
</div>
</div>
</div>
{/* Import Section */}
<div
className="vben-card"
>
<div className="bg-emerald-500 px-6 py-4 text-white">
<h2 className="vben-card-title ">
<Upload className="w-4 h-4" />
</h2>
</div>
<div className="p-6">
<div
onClick={handleImportClick}
className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center
hover:border-primary-400 transition-colors cursor-pointer"
>
{importing ? (
<>
<Loader2 className="w-12 h-12 text-blue-500 dark:text-blue-400 mx-auto mb-4 animate-spin" />
<p className="text-slate-600 dark:text-slate-400 mb-2">...</p>
</>
) : (
<>
<Database className="w-12 h-12 text-slate-400 dark:text-slate-500 mx-auto mb-4" />
<p className="text-slate-600 dark:text-slate-400 mb-2"></p>
<p className="text-sm text-slate-400 dark:text-slate-500"> JSON </p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
</div>
{/* Cleanup Section */}
<div
className="vben-card"
>
<div className="bg-red-500 px-6 py-4 text-white">
<h2 className="vben-card-title ">
<Trash2 className="w-4 h-4" />
</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cleanupTypes.map((type) => (
{/* 右列 */}
<div className="space-y-4">
{/* 数据导入 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Upload className="w-4 h-4 text-emerald-500" />
</h2>
</div>
<div className="p-5">
<div
key={type.id}
className={`border rounded-xl p-4 ${
type.danger
? 'border-red-200 bg-red-50/50'
: 'border-slate-200 dark:border-slate-700 hover:border-amber-300 hover:bg-amber-50/30'
} transition-colors`}
onClick={handleImportClick}
className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg p-6 text-center
hover:border-emerald-400 dark:hover:border-emerald-500 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/20
transition-colors cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
{type.danger && (
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
)}
<div>
<h3 className={`font-medium ${type.danger ? 'text-red-700' : 'text-slate-900 dark:text-slate-100'}`}>
{type.name}
</h3>
<p className={`text-sm mt-1 ${type.danger ? 'text-red-600' : 'text-slate-500 dark:text-slate-400'}`}>
{type.desc}
</p>
</div>
</div>
<button
onClick={() => handleCleanup(type.id, type.danger)}
disabled={cleaning !== null}
className={`py-2 px-3 text-sm rounded-lg font-medium transition-colors ${
type.danger
? 'bg-red-500 text-white hover:bg-red-600'
: 'btn-ios-secondary'
{importing ? (
<>
<Loader2 className="w-10 h-10 text-emerald-500 mx-auto mb-3 animate-spin" />
<p className="text-slate-600 dark:text-slate-400 text-sm">...</p>
</>
) : (
<>
<Database className="w-10 h-10 text-slate-400 dark:text-slate-500 mx-auto mb-3" />
<p className="text-slate-600 dark:text-slate-300 text-sm mb-1"></p>
<p className="text-xs text-slate-400 dark:text-slate-500"> JSON </p>
</>
)}
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
</div>
{/* 数据清理 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Trash2 className="w-4 h-4 text-amber-500" />
</h2>
</div>
<div className="vben-card-body">
<div className="space-y-3">
{cleanupTypes.map((type) => (
<div
key={type.id}
className={`flex items-center justify-between p-3 rounded-lg border transition-colors ${
type.danger
? 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20'
: 'border-slate-200 dark:border-slate-700 hover:border-amber-300 dark:hover:border-amber-600 hover:bg-amber-50/50 dark:hover:bg-amber-900/20'
}`}
>
{cleaning === type.id ? <ButtonLoading /> : '执行'}
</button>
</div>
<div className="flex items-start gap-2">
{type.danger && (
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
)}
<div>
<h3 className={`font-medium text-sm ${type.danger ? 'text-red-700 dark:text-red-400' : 'text-slate-900 dark:text-slate-100'}`}>
{type.name}
</h3>
<p className={`text-xs mt-0.5 ${type.danger ? 'text-red-600 dark:text-red-400' : 'text-slate-500 dark:text-slate-400'}`}>
{type.desc}
</p>
</div>
</div>
<button
onClick={() => handleCleanup(type.id, type.danger)}
disabled={cleaning !== null}
className={`py-1.5 px-3 text-xs rounded-md font-medium transition-colors ${
type.danger
? 'bg-red-500 text-white hover:bg-red-600'
: 'btn-ios-secondary'
}`}
>
{cleaning === type.id ? <ButtonLoading /> : '执行'}
</button>
</div>
))}
</div>
))}
</div>
</div>
</div>
</div>

View File

@ -2,16 +2,19 @@ import { useState, useEffect } from 'react'
import { FileText, RefreshCw, Trash2, AlertCircle, AlertTriangle, Info } from 'lucide-react'
import { getSystemLogs, clearSystemLogs, type SystemLog } from '@/api/admin'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { cn } from '@/utils/cn'
export function Logs() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [logs, setLogs] = useState<SystemLog[]>([])
const [levelFilter, setLevelFilter] = useState('')
const loadLogs = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getSystemLogs({ level: levelFilter || undefined })
@ -26,8 +29,11 @@ export function Logs() {
}
useEffect(() => {
if (!_hasHydrated) return
if (!isAuthenticated || !token) return
loadLogs()
}, [levelFilter])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_hasHydrated, isAuthenticated, token, levelFilter])
const handleClear = async () => {
if (!confirm('确定要清空所有系统日志吗?此操作不可恢复!')) return

View File

@ -1,20 +1,23 @@
import { useState, useEffect } from 'react'
import { ShieldAlert, RefreshCw, Trash2 } from 'lucide-react'
import { getRiskLogs, clearRiskLogs, type RiskLog } from '@/api/admin'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { Account } from '@/types'
export function RiskLogs() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [logs, setLogs] = useState<RiskLog[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const loadLogs = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getRiskLogs({ cookie_id: selectedAccount || undefined })
@ -29,6 +32,7 @@ export function RiskLogs() {
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
@ -38,13 +42,15 @@ export function RiskLogs() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadLogs()
}, [])
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadLogs()
}, [selectedAccount])
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleClear = async () => {
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
@ -82,39 +88,33 @@ export function RiskLogs() {
</div>
{/* Filter */}
<div
className="vben-card"
>
<div className="max-w-md">
<label className="input-label"></label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
<div className="vben-card">
<div className="vben-card-body">
<div className="max-w-md">
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
</div>
</div>
</div>
{/* Logs List */}
<div
className="vben-card"
>
<div className="bg-red-500 px-6 py-4 text-white
flex items-center justify-between">
<h2 className="vben-card-title ">
<ShieldAlert className="w-4 h-4" />
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<ShieldAlert className="w-4 h-4 text-amber-500" />
</h2>
<span className="badge-primary">{logs.length} </span>
@ -146,7 +146,14 @@ export function RiskLogs() {
<td>
<span className="badge-danger">{log.risk_type}</span>
</td>
<td className="max-w-[300px] truncate text-slate-500 dark:text-slate-400">{log.message}</td>
<td className="max-w-[300px] text-slate-500 dark:text-slate-400">
<span
className="block truncate cursor-help"
title={log.message}
>
{log.message}
</span>
</td>
<td className="text-slate-500 dark:text-slate-400 text-sm">
{new Date(log.created_at).toLocaleString()}
</td>

View File

@ -1,25 +1,19 @@
import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { Users as UsersIcon, RefreshCw, Plus, Edit2, Trash2, Shield, ShieldOff, X, Loader2 } from 'lucide-react'
import { getUsers, deleteUser, updateUser, addUser } from '@/api/admin'
import { Users as UsersIcon, RefreshCw, Plus, Trash2 } from 'lucide-react'
import { getUsers, deleteUser } from '@/api/admin'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { User } from '@/types'
export function Users() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [users, setUsers] = useState<User[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [formUsername, setFormUsername] = useState('')
const [formPassword, setFormPassword] = useState('')
const [formEmail, setFormEmail] = useState('')
const [formIsAdmin, setFormIsAdmin] = useState(false)
const [saving, setSaving] = useState(false)
const loadUsers = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getUsers()
@ -34,17 +28,13 @@ export function Users() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadUsers()
}, [])
}, [_hasHydrated, isAuthenticated, token])
const handleToggleAdmin = async (user: User) => {
try {
await updateUser(user.user_id, { is_admin: !user.is_admin })
addToast({ type: 'success', message: user.is_admin ? '已取消管理员权限' : '已设为管理员' })
loadUsers()
} catch {
addToast({ type: 'error', message: '操作失败' })
}
// TODO: 后端暂未实现 PUT /admin/users/{user_id} 接口
const handleNotImplemented = (action: string) => {
addToast({ type: 'warning', message: `${action}功能后端暂未实现` })
}
const handleDelete = async (userId: number) => {
@ -58,70 +48,6 @@ export function Users() {
}
}
const openAddModal = () => {
setEditingUser(null)
setFormUsername('')
setFormPassword('')
setFormEmail('')
setFormIsAdmin(false)
setIsModalOpen(true)
}
const openEditModal = (user: User) => {
setEditingUser(user)
setFormUsername(user.username)
setFormPassword('')
setFormEmail(user.email || '')
setFormIsAdmin(user.is_admin)
setIsModalOpen(true)
}
const closeModal = () => {
setIsModalOpen(false)
setEditingUser(null)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formUsername.trim()) {
addToast({ type: 'warning', message: '请输入用户名' })
return
}
if (!editingUser && !formPassword) {
addToast({ type: 'warning', message: '请输入密码' })
return
}
setSaving(true)
try {
if (editingUser) {
const data: Partial<User> & { password?: string } = {
username: formUsername.trim(),
email: formEmail.trim() || undefined,
is_admin: formIsAdmin,
}
if (formPassword) data.password = formPassword
await updateUser(editingUser.user_id, data)
addToast({ type: 'success', message: '用户已更新' })
} else {
await addUser({
username: formUsername.trim(),
password: formPassword,
email: formEmail.trim() || undefined,
is_admin: formIsAdmin,
})
addToast({ type: 'success', message: '用户已添加' })
}
closeModal()
loadUsers()
} catch {
addToast({ type: 'error', message: '保存失败' })
} finally {
setSaving(false)
}
}
if (loading) {
return <PageLoading />
}
@ -135,11 +61,12 @@ export function Users() {
<p className="page-description"></p>
</div>
<div className="flex gap-3">
<button onClick={openAddModal} className="btn-ios-primary ">
{/* TODO: 后端暂未实现 POST /admin/users 接口 */}
<button onClick={() => handleNotImplemented('添加用户')} className="btn-ios-primary">
<Plus className="w-4 h-4" />
</button>
<button onClick={loadUsers} className="btn-ios-secondary ">
<button onClick={loadUsers} className="btn-ios-secondary">
<RefreshCw className="w-4 h-4" />
</button>
@ -147,14 +74,9 @@ export function Users() {
</div>
{/* Users List */}
<div
className="vben-card"
>
<div className="vben-card-header
flex items-center justify-between">
<h2 className="vben-card-title ">
<div className="vben-card">
<div className="vben-card-header flex items-center justify-between">
<h2 className="vben-card-title">
<UsersIcon className="w-4 h-4" />
</h2>
@ -195,28 +117,10 @@ export function Users() {
)}
</td>
<td>
<div className="">
<button
onClick={() => handleToggleAdmin(user)}
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
title={user.is_admin ? '取消管理员' : '设为管理员'}
>
{user.is_admin ? (
<ShieldOff className="w-4 h-4 text-amber-500" />
) : (
<Shield className="w-4 h-4 text-emerald-500" />
)}
</button>
<button
onClick={() => openEditModal(user)}
className="p-2 rounded-lg hover:bg-slate-100 dark:bg-slate-700 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<div className="flex gap-1">
<button
onClick={() => handleDelete(user.user_id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
@ -231,81 +135,14 @@ export function Users() {
</div>
</div>
{/* 添加/编辑用户弹窗 */}
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-md">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold">
{editingUser ? '编辑用户' : '添加用户'}
</h2>
<button onClick={closeModal} className="p-1 hover:bg-slate-100 dark:bg-slate-700 rounded-lg">
<X className="w-4 h-4 text-slate-500 dark:text-slate-400" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div>
<label className="input-label"></label>
<input
type="text"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
className="input-ios"
placeholder="请输入用户名"
/>
</div>
<div>
<label className="input-label">
{editingUser && '(留空则不修改)'}
</label>
<input
type="password"
value={formPassword}
onChange={(e) => setFormPassword(e.target.value)}
className="input-ios"
placeholder={editingUser ? '留空则不修改密码' : '请输入密码'}
/>
</div>
<div>
<label className="input-label"></label>
<input
type="email"
value={formEmail}
onChange={(e) => setFormEmail(e.target.value)}
className="input-ios"
placeholder="请输入邮箱"
/>
</div>
<label className=" text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={formIsAdmin}
onChange={(e) => setFormIsAdmin(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
/>
</label>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
</button>
<button type="submit" className="btn-ios-primary" disabled={saving}>
{saving ? (
<span className="">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>
) : (
'保存'
)}
</button>
</div>
</form>
</div>
{/* 提示信息 */}
<div className="vben-card">
<div className="vben-card-body">
<p className="text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
)}
</div>
</div>
)
}

View File

@ -141,13 +141,13 @@ export function Register() {
if (!registrationEnabled) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 text-center max-w-sm">
<div className="w-14 h-14 rounded-full bg-amber-100 mx-auto mb-4 flex items-center justify-center">
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-4">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-8 text-center max-w-sm">
<div className="w-14 h-14 rounded-full bg-amber-100 dark:bg-amber-900/30 mx-auto mb-4 flex items-center justify-center">
<span className="text-2xl">🚫</span>
</div>
<h1 className="text-lg vben-card-title text-gray-900 mb-2"></h1>
<p className="text-sm text-gray-500 mb-6"></p>
<h1 className="text-lg vben-card-title text-slate-900 dark:text-slate-100 mb-2"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-6"></p>
<Link to="/login" className="btn-ios-primary">
</Link>
@ -157,25 +157,25 @@ export function Register() {
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-6 transition-colors">
<div className="w-full max-w-md">
{/* Mobile header */}
<div className="text-center mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-600 text-white mx-auto mb-4 flex items-center justify-center">
<MessageSquare className="w-6 h-6" />
</div>
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="text-sm page-description">使</p>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400">使</p>
</div>
{/* Register Card */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-6">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Username */}
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={username}
@ -190,7 +190,7 @@ export function Register() {
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="email"
value={email}
@ -205,7 +205,7 @@ export function Register() {
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
@ -216,7 +216,7 @@ export function Register() {
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
@ -227,7 +227,7 @@ export function Register() {
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
@ -259,12 +259,12 @@ export function Register() {
src={captchaImage}
alt="验证码"
onClick={loadCaptcha}
className="h-[38px] rounded border border-gray-300 cursor-pointer hover:opacity-80 transition-opacity"
className="h-[38px] rounded border border-slate-300 dark:border-slate-600 cursor-pointer hover:opacity-80 transition-opacity"
/>
</div>
<p className={cn(
'text-xs',
captchaVerified ? 'text-green-600' : 'text-gray-400'
captchaVerified ? 'text-green-600 dark:text-green-400' : 'text-slate-400'
)}>
{captchaVerified ? '✓ 验证成功' : '点击图片更换验证码'}
</p>
@ -275,7 +275,7 @@ export function Register() {
<label className="input-label"></label>
<div className="flex gap-2">
<div className="relative flex-1">
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={verificationCode}
@ -307,7 +307,7 @@ export function Register() {
</form>
{/* Login link */}
<p className="text-center mt-6 text-gray-500 text-sm">
<p className="text-center mt-6 text-slate-500 dark:text-slate-400 text-sm">
{' '}
<Link to="/login" className="text-blue-600 dark:text-blue-400 font-medium hover:text-indigo-700">
@ -316,7 +316,7 @@ export function Register() {
</div>
{/* Footer */}
<p className="text-center mt-6 text-gray-400 text-xs">
<p className="text-center mt-6 text-slate-400 text-xs">
© {new Date().getFullYear()} ·
<a href="https://www.hsykj.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:text-blue-400 ml-1 transition-colors">
www.hsykj.com

View File

@ -5,12 +5,15 @@ import { getCards, deleteCard, addCard, importCards } from '@/api/cards'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
import { useAuthStore } from '@/store/authStore'
import { Select } from '@/components/common/Select'
import type { Card, Account } from '@/types'
type ModalType = 'add' | 'import' | null
export function Cards() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [cards, setCards] = useState<Card[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
@ -28,6 +31,9 @@ export function Cards() {
const [importLoading, setImportLoading] = useState(false)
const loadCards = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
setLoading(true)
const result = await getCards(selectedAccount || undefined)
@ -42,6 +48,9 @@ export function Cards() {
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
const data = await getAccounts()
setAccounts(data)
@ -51,13 +60,15 @@ export function Cards() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadCards()
}, [])
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadCards()
}, [selectedAccount])
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这张卡券吗?')) return
@ -182,18 +193,18 @@ export function Cards() {
<div className="max-w-md">
<div className="input-group">
<label className="input-label"></label>
<select
<Select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@ import { getAccountDetails } from '@/api/accounts'
import { getKeywords } from '@/api/keywords'
import { getOrders } from '@/api/orders'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { AccountDetail } from '@/types'
@ -17,6 +18,7 @@ interface DashboardStats {
export function Dashboard() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<DashboardStats>({
totalAccounts: 0,
@ -27,6 +29,7 @@ export function Dashboard() {
const [accounts, setAccounts] = useState<AccountDetail[]>([])
const loadDashboard = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
@ -87,8 +90,9 @@ export function Dashboard() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadDashboard()
}, [])
}, [_hasHydrated, isAuthenticated, token])
if (loading) {
return <PageLoading />

View File

@ -3,31 +3,35 @@ import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { Truck, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getDeliveryRules, deleteDeliveryRule, updateDeliveryRule, addDeliveryRule } from '@/api/delivery'
import { getAccounts } from '@/api/accounts'
import { getCards } from '@/api/cards'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { DeliveryRule, Account } from '@/types'
import { Select } from '@/components/common/Select'
import type { DeliveryRule, Card } from '@/types'
export function Delivery() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [rules, setRules] = useState<DeliveryRule[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [cards, setCards] = useState<Card[]>([])
// 弹窗状态
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
const [formItemId, setFormItemId] = useState('')
const [formDeliveryType, setFormDeliveryType] = useState<'card' | 'text' | 'api'>('card')
const [formContent, setFormContent] = useState('')
const [formKeyword, setFormKeyword] = useState('')
const [formCardId, setFormCardId] = useState('')
const [formDeliveryCount, setFormDeliveryCount] = useState(1)
const [formDescription, setFormDescription] = useState('')
const [formEnabled, setFormEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const loadRules = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getDeliveryRules(selectedAccount || undefined)
const result = await getDeliveryRules()
if (result.success) {
setRules(result.data || [])
}
@ -38,27 +42,27 @@ export function Delivery() {
}
}
const loadAccounts = async () => {
const loadCards = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
const result = await getCards()
if (result.success) {
setCards(result.data || [])
}
} catch {
// ignore
}
}
useEffect(() => {
loadAccounts()
if (!_hasHydrated || !isAuthenticated || !token) return
loadCards()
loadRules()
}, [])
useEffect(() => {
loadRules()
}, [selectedAccount])
}, [_hasHydrated, isAuthenticated, token])
const handleToggleEnabled = async (rule: DeliveryRule) => {
try {
await updateDeliveryRule(rule.id, { enabled: !rule.enabled })
await updateDeliveryRule(String(rule.id), { enabled: !rule.enabled })
addToast({ type: 'success', message: rule.enabled ? '规则已禁用' : '规则已启用' })
loadRules()
} catch {
@ -66,10 +70,10 @@ export function Delivery() {
}
}
const handleDelete = async (id: string) => {
const handleDelete = async (id: number) => {
if (!confirm('确定要删除这条规则吗?')) return
try {
await deleteDeliveryRule(id)
await deleteDeliveryRule(String(id))
addToast({ type: 'success', message: '删除成功' })
loadRules()
} catch {
@ -78,23 +82,21 @@ export function Delivery() {
}
const openAddModal = () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
return
}
setEditingRule(null)
setFormItemId('')
setFormDeliveryType('card')
setFormContent('')
setFormKeyword('')
setFormCardId('')
setFormDeliveryCount(1)
setFormDescription('')
setFormEnabled(true)
setIsModalOpen(true)
}
const openEditModal = (rule: DeliveryRule) => {
setEditingRule(rule)
setFormItemId(rule.item_id || '')
setFormDeliveryType((rule.delivery_type as 'card' | 'text' | 'api') || 'card')
setFormContent(rule.delivery_content || '')
setFormKeyword(rule.keyword)
setFormCardId(String(rule.card_id))
setFormDeliveryCount(rule.delivery_count)
setFormDescription(rule.description || '')
setFormEnabled(rule.enabled)
setIsModalOpen(true)
}
@ -106,23 +108,27 @@ export function Delivery() {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!selectedAccount && !editingRule) {
addToast({ type: 'warning', message: '请先选择账号' })
if (!formKeyword.trim()) {
addToast({ type: 'warning', message: '请输入触发关键词' })
return
}
if (!formCardId) {
addToast({ type: 'warning', message: '请选择卡券' })
return
}
setSaving(true)
try {
const data = {
cookie_id: editingRule?.cookie_id || selectedAccount,
item_id: formItemId || undefined,
delivery_type: formDeliveryType,
delivery_content: formContent,
keyword: formKeyword.trim(),
card_id: Number(formCardId),
delivery_count: formDeliveryCount,
description: formDescription || undefined,
enabled: formEnabled,
}
if (editingRule) {
await updateDeliveryRule(editingRule.id, data)
await updateDeliveryRule(String(editingRule.id), data)
addToast({ type: 'success', message: '规则已更新' })
} else {
await addDeliveryRule(data)
@ -162,29 +168,6 @@ export function Delivery() {
</div>
</div>
{/* Filter */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="max-w-md">
<label className="input-label"></label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
</div>
</motion.div>
{/* Rules List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -204,10 +187,10 @@ export function Delivery() {
<table className="table-ios">
<thead>
<tr>
<th>ID</th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@ -225,20 +208,10 @@ export function Delivery() {
) : (
rules.map((rule) => (
<tr key={rule.id}>
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.cookie_id}</td>
<td className="text-sm">{rule.item_id || '所有商品'}</td>
<td>
{rule.delivery_type === 'card' ? (
<span className="badge-info"></span>
) : rule.delivery_type === 'text' ? (
<span className="badge-warning"></span>
) : (
<span className="badge-gray"></span>
)}
</td>
<td className="max-w-[200px] truncate text-gray-500">
{rule.delivery_content || '-'}
</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{rule.keyword}</td>
<td className="text-sm">{rule.card_name || `卡券ID: ${rule.card_id}`}</td>
<td className="text-center">{rule.delivery_count}</td>
<td className="text-center text-slate-500">{rule.delivery_times || 0}</td>
<td>
{rule.enabled ? (
<span className="badge-success"></span>
@ -298,62 +271,67 @@ export function Delivery() {
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div>
<label className="input-label"></label>
<label className="input-label"> *</label>
<input
type="text"
value={editingRule?.cookie_id || selectedAccount || '请先选择账号'}
disabled
className="input-ios bg-gray-100 cursor-not-allowed"
value={formKeyword}
onChange={(e) => setFormKeyword(e.target.value)}
className="input-ios"
placeholder="输入触发自动发货的关键词"
required
/>
</div>
<div className="input-group">
<label className="input-label"> *</label>
<Select
value={formCardId}
onChange={setFormCardId}
options={[
{ value: '', label: '请选择卡券' },
...cards.map((card) => ({
value: String(card.id),
label: card.keyword || card.card_content?.substring(0, 20) || `卡券 ${card.id}`,
})),
]}
placeholder="请选择卡券"
/>
</div>
<div>
<label className="input-label">ID</label>
<label className="input-label"></label>
<input
type="text"
value={formItemId}
onChange={(e) => setFormItemId(e.target.value)}
type="number"
value={formDeliveryCount}
onChange={(e) => setFormDeliveryCount(Number(e.target.value) || 1)}
className="input-ios"
placeholder="留空表示适用于所有商品"
min={1}
placeholder="每次发货的卡密数量"
/>
</div>
<div>
<label className="input-label"></label>
<select
value={formDeliveryType}
onChange={(e) => setFormDeliveryType(e.target.value as 'card' | 'text' | 'api')}
className="input-ios"
>
<option value="card"></option>
<option value="text"></option>
<option value="api">API接口</option>
</select>
</div>
<div>
<label className="input-label">
{formDeliveryType === 'card' ? '卡密说明' : formDeliveryType === 'api' ? 'API地址' : '发货内容'}
</label>
<label className="input-label"></label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
className="input-ios h-24 resize-none"
placeholder={
formDeliveryType === 'card'
? '卡密将从卡券库中自动获取'
: formDeliveryType === 'api'
? '请输入API接口地址'
: '请输入固定发货文本'
}
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
className="input-ios h-20 resize-none"
placeholder="规则描述,方便识别"
/>
</div>
<label className=" text-sm text-gray-700">
<input
type="checkbox"
checked={formEnabled}
onChange={(e) => setFormEnabled(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
/>
</label>
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200"></span>
<button
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>

View File

@ -5,11 +5,14 @@ import { MessageCircle, RefreshCw, Plus, Edit2, Trash2, X, Loader2 } from 'lucid
import { getItemReplies, deleteItemReply, addItemReply, updateItemReply } from '@/api/items'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { ItemReply, Account } from '@/types'
export function ItemReplies() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [replies, setReplies] = useState<ItemReply[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
@ -22,6 +25,7 @@ export function ItemReplies() {
const [saving, setSaving] = useState(false)
const loadReplies = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getItemReplies(selectedAccount || undefined)
@ -36,6 +40,7 @@ export function ItemReplies() {
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
@ -45,18 +50,20 @@ export function ItemReplies() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadReplies()
}, [])
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadReplies()
}, [selectedAccount])
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleDelete = async (id: string) => {
const handleDelete = async (reply: ItemReply) => {
if (!confirm('确定要删除这条商品回复吗?')) return
try {
await deleteItemReply(id)
await deleteItemReply(reply.cookie_id, reply.item_id)
addToast({ type: 'success', message: '删除成功' })
loadReplies()
} catch {
@ -110,10 +117,10 @@ export function ItemReplies() {
}
if (editingReply) {
await updateItemReply(editingReply.id, data)
await updateItemReply(editingReply.cookie_id, editingReply.item_id, data)
addToast({ type: 'success', message: '回复已更新' })
} else {
await addItemReply(data)
await addItemReply(selectedAccount, formItemId.trim(), data)
addToast({ type: 'success', message: '回复已添加' })
}
@ -156,20 +163,22 @@ export function ItemReplies() {
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="max-w-md">
<label className="input-label"></label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
<div className="vben-card-body">
<div className="max-w-xs">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="选择账号"
/>
</div>
</div>
</motion.div>
@ -230,7 +239,7 @@ export function ItemReplies() {
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<button
onClick={() => handleDelete(reply.id)}
onClick={() => handleDelete(reply)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
title="删除"
>

View File

@ -1,23 +1,29 @@
import { useState, useEffect } from 'react'
import { Package, RefreshCw, Search, Trash2, Download, CheckSquare, Square, Loader2 } from 'lucide-react'
import { Package, RefreshCw, Search, Trash2, Download, CheckSquare, Square, Loader2, ExternalLink } from 'lucide-react'
import { getItems, deleteItem, fetchItemsFromAccount, batchDeleteItems } from '@/api/items'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
import { useAuthStore } from '@/store/authStore'
import { Select } from '@/components/common/Select'
import type { Item, Account } from '@/types'
export function Items() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [items, setItems] = useState<Item[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set())
const [fetching, setFetching] = useState(false)
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0 })
const loadItems = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
setLoading(true)
const result = await getItems(selectedAccount || undefined)
@ -73,6 +79,9 @@ export function Items() {
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
const data = await getAccounts()
setAccounts(data)
@ -82,18 +91,20 @@ export function Items() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadItems()
}, [])
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadItems()
}, [selectedAccount])
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const handleDelete = async (id: string) => {
const handleDelete = async (item: Item) => {
if (!confirm('确定要删除这个商品吗?')) return
try {
await deleteItem(id)
await deleteItem(item.cookie_id, item.item_id)
addToast({ type: 'success', message: '删除成功' })
loadItems()
} catch {
@ -102,7 +113,7 @@ export function Items() {
}
// 批量选择相关
const toggleSelect = (id: string) => {
const toggleSelect = (id: string | number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
@ -129,7 +140,11 @@ export function Items() {
}
if (!confirm(`确定要删除选中的 ${selectedIds.size} 个商品吗?`)) return
try {
await batchDeleteItems(Array.from(selectedIds))
// 将选中的 ID 转换为 { cookie_id, item_id } 格式
const itemsToDelete = items
.filter((item) => selectedIds.has(item.id))
.map((item) => ({ cookie_id: item.cookie_id, item_id: item.item_id }))
await batchDeleteItems(itemsToDelete)
addToast({ type: 'success', message: `成功删除 ${selectedIds.size} 个商品` })
setSelectedIds(new Set())
loadItems()
@ -141,9 +156,11 @@ export function Items() {
const filteredItems = items.filter((item) => {
if (!searchKeyword) return true
const keyword = searchKeyword.toLowerCase()
const title = item.item_title || item.title || ''
const desc = item.item_detail || item.desc || ''
return (
item.title?.toLowerCase().includes(keyword) ||
item.desc?.toLowerCase().includes(keyword) ||
title.toLowerCase().includes(keyword) ||
desc.toLowerCase().includes(keyword) ||
item.item_id?.includes(keyword)
)
})
@ -170,7 +187,7 @@ export function Items() {
<button
onClick={handleFetchItems}
disabled={fetching}
className="btn-ios-success"
className="btn-ios-primary"
>
{fetching ? (
<>
@ -197,18 +214,18 @@ export function Items() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label"></label>
<select
<Select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
@ -288,22 +305,47 @@ export function Items() {
</button>
</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{item.cookie_id}</td>
<td className="text-xs text-gray-500">{item.item_id}</td>
<td className="max-w-[180px] truncate" title={item.title}>
{item.title}
<td className="text-xs text-gray-500">
<a
href={`https://www.goofish.com/item?id=${item.item_id}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-500 flex items-center gap-1"
>
{item.item_id}
<ExternalLink className="w-3 h-3" />
</a>
</td>
<td className="max-w-[280px]">
<div
className="font-medium line-clamp-2 cursor-help"
title={item.item_title || item.title || '-'}
>
{item.item_title || item.title || '-'}
</div>
{(item.item_detail || item.desc) && (
<div
className="text-xs text-gray-400 line-clamp-1 mt-0.5 cursor-help"
title={item.item_detail || item.desc}
>
{item.item_detail || item.desc}
</div>
)}
</td>
<td className="text-amber-600 font-medium">
{item.item_price || (item.price ? `¥${item.price}` : '-')}
</td>
<td className="text-amber-600 font-medium">¥{item.price}</td>
<td>
<span className={item.has_sku ? 'badge-success' : 'badge-gray'}>
{item.has_sku ? '是' : '否'}
<span className={(item.is_multi_spec || item.has_sku) ? 'badge-success' : 'badge-gray'}>
{(item.is_multi_spec || item.has_sku) ? '是' : '否'}
</span>
</td>
<td className="text-gray-500">
<td className="text-gray-500 text-xs">
{item.updated_at ? new Date(item.updated_at).toLocaleString() : '-'}
</td>
<td>
<button
onClick={() => handleDelete(item.id)}
onClick={() => handleDelete(item)}
className="table-action-btn hover:!bg-red-50"
title="删除"
>

View File

@ -6,10 +6,13 @@ import { getKeywords, deleteKeyword, addKeyword, updateKeyword, exportKeywords,
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
import { useAuthStore } from '@/store/authStore'
import { Select } from '@/components/common/Select'
import type { Keyword, Account } from '@/types'
export function Keywords() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [keywords, setKeywords] = useState<Keyword[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
@ -25,6 +28,9 @@ export function Keywords() {
const importInputRef = useRef<HTMLInputElement | null>(null)
const loadKeywords = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
if (!selectedAccount) {
setKeywords([])
setLoading(false)
@ -42,6 +48,9 @@ export function Keywords() {
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
return
}
try {
const data = await getAccounts()
setAccounts(data)
@ -54,14 +63,16 @@ export function Keywords() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
}, [])
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
if (selectedAccount) {
loadKeywords()
}
}, [selectedAccount])
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const openAddModal = () => {
if (!selectedAccount) {
@ -258,23 +269,23 @@ export function Keywords() {
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="max-w-md">
<label className="input-label"></label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
{accounts.length === 0 ? (
<option value=""></option>
) : (
accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))
)}
</select>
<div className="vben-card-body">
<div className="max-w-md">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={
accounts.length === 0
? [{ value: '', label: '暂无账号' }]
: accounts.map((account) => ({
value: account.id,
label: account.id,
}))
}
placeholder="选择账号"
/>
</div>
</div>
</motion.div>
@ -328,12 +339,12 @@ export function Keywords() {
keywords.map((keyword) => (
<tr key={keyword.id}>
<td className="font-medium">
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded">
<code className="bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-2 py-1 rounded">
{keyword.keyword}
</code>
</td>
<td className="max-w-[300px]">
<p className="truncate text-gray-600" title={keyword.reply}>
<p className="truncate text-slate-600 dark:text-slate-300" title={keyword.reply}>
{keyword.reply}
</p>
</td>
@ -348,14 +359,14 @@ export function Keywords() {
<div className="">
<button
onClick={() => openEditModal(keyword)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<button
onClick={() => handleDelete(keyword.id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
@ -386,7 +397,7 @@ export function Keywords() {
type="text"
value={selectedAccount}
disabled
className="input-ios bg-gray-100 cursor-not-allowed"
className="input-ios bg-slate-100 dark:bg-slate-700 cursor-not-allowed"
/>
</div>
<div>
@ -408,17 +419,24 @@ export function Keywords() {
placeholder="请输入自动回复内容"
/>
</div>
<div className="flex items-center justify-between">
<label className=" text-sm text-gray-700">
<input
type="checkbox"
checked={fuzzyMatch}
onChange={(e) => setFuzzyMatch(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400 focus:ring-primary-500"
<div className="flex items-center justify-between pt-2">
<div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">使</span>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5"></p>
</div>
<button
type="button"
onClick={() => setFuzzyMatch(!fuzzyMatch)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
fuzzyMatch ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
fuzzyMatch ? 'translate-x-6' : 'translate-x-1'
}`}
/>
使
</label>
<p className="text-xs text-gray-400"></p>
</button>
</div>
</div>
<div className="modal-footer">

View File

@ -1,25 +1,30 @@
import { useState, useEffect } from 'react'
import type { FormEvent } from 'react'
import { motion } from 'framer-motion'
import { Mail, RefreshCw, Plus, Edit2, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getMessageNotifications, deleteMessageNotification, updateMessageNotification, addMessageNotification } from '@/api/notifications'
import { Mail, RefreshCw, Plus, Trash2, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getMessageNotifications, setMessageNotification, getNotificationChannels } from '@/api/notifications'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import type { MessageNotification } from '@/types'
import { Select } from '@/components/common/Select'
import type { MessageNotification, NotificationChannel, Account } from '@/types'
export function MessageNotifications() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [notifications, setNotifications] = useState<MessageNotification[]>([])
const [channels, setChannels] = useState<NotificationChannel[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingNotification, setEditingNotification] = useState<MessageNotification | null>(null)
const [formName, setFormName] = useState('')
const [formKeyword, setFormKeyword] = useState('')
const [formAccountId, setFormAccountId] = useState('')
const [formChannelId, setFormChannelId] = useState('')
const [formEnabled, setFormEnabled] = useState(true)
const [saving, setSaving] = useState(false)
const loadNotifications = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getMessageNotifications()
@ -33,13 +38,38 @@ export function MessageNotifications() {
}
}
const loadChannels = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const result = await getNotificationChannels()
if (result.success) {
setChannels(result.data || [])
}
} catch {
// ignore
}
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
} catch {
// ignore
}
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadChannels()
loadAccounts()
loadNotifications()
}, [])
}, [_hasHydrated, isAuthenticated, token])
const handleToggleEnabled = async (notification: MessageNotification) => {
try {
await updateMessageNotification(notification.id, { enabled: !notification.enabled })
await setMessageNotification(notification.cookie_id, notification.channel_id, !notification.enabled)
addToast({ type: 'success', message: notification.enabled ? '通知已禁用' : '通知已启用' })
loadNotifications()
} catch {
@ -47,64 +77,45 @@ export function MessageNotifications() {
}
}
const handleDelete = async (id: string) => {
const handleDelete = async (notification: MessageNotification) => {
if (!confirm('确定要删除这个消息通知吗?')) return
try {
await deleteMessageNotification(id)
addToast({ type: 'success', message: '删除成功' })
// 后端删除接口需要 notification_id但我们没有这个字段
// 改为禁用该通知
await setMessageNotification(notification.cookie_id, notification.channel_id, false)
addToast({ type: 'success', message: '通知已禁用' })
loadNotifications()
} catch {
addToast({ type: 'error', message: '删除失败' })
addToast({ type: 'error', message: '操作失败' })
}
}
const openAddModal = () => {
setEditingNotification(null)
setFormName('')
setFormKeyword('')
setFormAccountId('')
setFormChannelId('')
setFormEnabled(true)
setIsModalOpen(true)
}
const openEditModal = (notification: MessageNotification) => {
setEditingNotification(notification)
setFormName(notification.name)
setFormKeyword(notification.trigger_keyword || '')
setFormChannelId(notification.channel_id || '')
setFormEnabled(notification.enabled)
setIsModalOpen(true)
}
const closeModal = () => {
setIsModalOpen(false)
setEditingNotification(null)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!formName.trim()) {
addToast({ type: 'warning', message: '请输入通知名称' })
if (!formAccountId) {
addToast({ type: 'warning', message: '请选择账号' })
return
}
if (!formChannelId) {
addToast({ type: 'warning', message: '请选择通知渠道' })
return
}
setSaving(true)
try {
const data = {
name: formName.trim(),
trigger_keyword: formKeyword.trim() || undefined,
channel_id: formChannelId.trim() || undefined,
enabled: formEnabled,
}
if (editingNotification) {
await updateMessageNotification(editingNotification.id, data)
addToast({ type: 'success', message: '通知已更新' })
} else {
await addMessageNotification(data)
addToast({ type: 'success', message: '通知已添加' })
}
await setMessageNotification(formAccountId, Number(formChannelId), formEnabled)
addToast({ type: 'success', message: '通知已添加' })
closeModal()
loadNotifications()
} catch {
@ -156,8 +167,7 @@ export function MessageNotifications() {
<table className="table-ios">
<thead>
<tr>
<th></th>
<th></th>
<th>ID</th>
<th></th>
<th></th>
<th></th>
@ -166,24 +176,19 @@ export function MessageNotifications() {
<tbody>
{notifications.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-gray-500">
<td colSpan={4} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<Mail className="w-12 h-12 text-gray-300" />
<p></p>
<p></p>
</div>
</td>
</tr>
) : (
notifications.map((notification) => (
<tr key={notification.id}>
<td className="font-medium">{notification.name}</td>
<td>
<code className="bg-primary-50 text-blue-600 dark:text-blue-400 px-2 py-1 rounded text-sm">
{notification.trigger_keyword || '全部消息'}
</code>
</td>
<td className="text-sm text-gray-500">
{notification.channel_id || '-'}
<tr key={`${notification.cookie_id}-${notification.channel_id}`}>
<td className="font-medium text-blue-600 dark:text-blue-400">{notification.cookie_id}</td>
<td className="text-sm">
{notification.channel_name || `渠道 ${notification.channel_id}`}
</td>
<td>
{notification.enabled ? (
@ -193,10 +198,10 @@ export function MessageNotifications() {
)}
</td>
<td>
<div className="">
<div className="flex gap-1">
<button
onClick={() => handleToggleEnabled(notification)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
title={notification.enabled ? '禁用' : '启用'}
>
{notification.enabled ? (
@ -206,15 +211,8 @@ export function MessageNotifications() {
)}
</button>
<button
onClick={() => openEditModal(notification)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title="编辑"
>
<Edit2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</button>
<button
onClick={() => handleDelete(notification.id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
onClick={() => handleDelete(notification)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
@ -229,59 +227,64 @@ export function MessageNotifications() {
</div>
</motion.div>
{/* 添加/编辑通知弹窗 */}
{/* 添加通知弹窗 */}
{isModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-md">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold">
{editingNotification ? '编辑消息通知' : '添加消息通知'}
</h2>
<button onClick={closeModal} className="p-1 hover:bg-gray-100 rounded-lg">
<h2 className="text-lg font-semibold"></h2>
<button onClick={closeModal} className="p-1 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body space-y-4">
<div>
<label className="input-label"></label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
className="input-ios"
placeholder="如:订单通知"
<div className="input-group">
<label className="input-label"> *</label>
<Select
value={formAccountId}
onChange={setFormAccountId}
options={[
{ value: '', label: '请选择账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="请选择账号"
/>
</div>
<div>
<label className="input-label"></label>
<input
type="text"
value={formKeyword}
onChange={(e) => setFormKeyword(e.target.value)}
className="input-ios"
placeholder="留空表示所有消息"
/>
</div>
<div>
<label className="input-label">ID</label>
<input
type="text"
<div className="input-group">
<label className="input-label"> *</label>
<Select
value={formChannelId}
onChange={(e) => setFormChannelId(e.target.value)}
className="input-ios"
placeholder="输入渠道ID"
onChange={setFormChannelId}
options={[
{ value: '', label: '请选择通知渠道' },
...channels.map((channel) => ({
value: String(channel.id),
label: channel.name || channel.channel_name || `渠道 ${channel.id}`,
})),
]}
placeholder="请选择通知渠道"
/>
</div>
<label className=" text-sm text-gray-700">
<input
type="checkbox"
checked={formEnabled}
onChange={(e) => setFormEnabled(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
/>
</label>
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200"></span>
<button
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>
@ -289,7 +292,7 @@ export function MessageNotifications() {
</button>
<button type="submit" className="btn-ios-primary" disabled={saving}>
{saving ? (
<span className="">
<span className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
...
</span>

View File

@ -4,7 +4,9 @@ import { motion } from 'framer-motion'
import { Bell, RefreshCw, Plus, Edit2, Trash2, Send, Power, PowerOff, X, Loader2 } from 'lucide-react'
import { getNotificationChannels, deleteNotificationChannel, updateNotificationChannel, testNotificationChannel, addNotificationChannel } from '@/api/notifications'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { NotificationChannel } from '@/types'
const channelTypeLabels: Record<string, string> = {
@ -18,6 +20,7 @@ const channelTypeLabels: Record<string, string> = {
export function NotificationChannels() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [channels, setChannels] = useState<NotificationChannel[]>([])
const [isModalOpen, setIsModalOpen] = useState(false)
@ -29,6 +32,7 @@ export function NotificationChannels() {
const [saving, setSaving] = useState(false)
const loadChannels = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getNotificationChannels()
@ -43,8 +47,9 @@ export function NotificationChannels() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadChannels()
}, [])
}, [_hasHydrated, isAuthenticated, token])
const handleToggleEnabled = async (channel: NotificationChannel) => {
try {
@ -280,20 +285,20 @@ export function NotificationChannels() {
placeholder="如:我的邮箱通知"
/>
</div>
<div>
<div className="input-group">
<label className="input-label"></label>
<select
<Select
value={formType}
onChange={(e) => setFormType(e.target.value as typeof formType)}
className="input-ios"
>
<option value="email"></option>
<option value="wechat"></option>
<option value="dingtalk"></option>
<option value="feishu"></option>
<option value="webhook">Webhook</option>
<option value="telegram">Telegram</option>
</select>
onChange={(value) => setFormType(value as typeof formType)}
options={[
{ value: 'email', label: '邮件' },
{ value: 'wechat', label: '微信' },
{ value: 'dingtalk', label: '钉钉' },
{ value: 'feishu', label: '飞书' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'telegram', label: 'Telegram' },
]}
/>
</div>
<div>
<label className="input-label"> (JSON)</label>
@ -307,15 +312,22 @@ export function NotificationChannels() {
webhook_urltoken等
</p>
</div>
<label className=" text-sm text-gray-700">
<input
type="checkbox"
checked={formEnabled}
onChange={(e) => setFormEnabled(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-500 dark:text-blue-400"
/>
</label>
<div className="flex items-center justify-between pt-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200"></span>
<button
type="button"
onClick={() => setFormEnabled(!formEnabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formEnabled ? 'bg-blue-600' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formEnabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button type="button" onClick={closeModal} className="btn-ios-secondary" disabled={saving}>

View File

@ -4,7 +4,9 @@ import { ShoppingCart, RefreshCw, Search, Trash2 } from 'lucide-react'
import { getOrders, deleteOrder } from '@/api/orders'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading } from '@/components/common/Loading'
import { Select } from '@/components/common/Select'
import type { Order, Account } from '@/types'
const statusMap: Record<string, { label: string; class: string }> = {
@ -18,6 +20,7 @@ const statusMap: Record<string, { label: string; class: string }> = {
export function Orders() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [orders, setOrders] = useState<Order[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
@ -26,6 +29,7 @@ export function Orders() {
const [searchKeyword, setSearchKeyword] = useState('')
const loadOrders = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined)
@ -40,6 +44,7 @@ export function Orders() {
}
const loadAccounts = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
const data = await getAccounts()
setAccounts(data)
@ -49,13 +54,15 @@ export function Orders() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadOrders()
}, [])
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadOrders()
}, [selectedAccount, selectedStatus])
}, [_hasHydrated, isAuthenticated, token, selectedAccount, selectedStatus])
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个订单吗?')) return
@ -102,49 +109,52 @@ export function Orders() {
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="input-label"></label>
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
</div>
<div>
<label className="input-label"></label>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="input-ios"
>
<option value=""></option>
<option value="processing"></option>
<option value="processed"></option>
<option value="shipped"></option>
<option value="completed"></option>
<option value="cancelled"></option>
</select>
</div>
<div>
<label className="input-label"></label>
<div className="relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索订单ID或商品ID..."
className="input-ios pl-12"
<div className="vben-card-body">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedAccount}
onChange={setSelectedAccount}
options={[
{ value: '', label: '所有账号' },
...accounts.map((account) => ({
value: account.id,
label: account.id,
})),
]}
placeholder="所有账号"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<Select
value={selectedStatus}
onChange={setSelectedStatus}
options={[
{ value: '', label: '所有状态' },
{ value: 'processing', label: '处理中' },
{ value: 'processed', label: '已处理' },
{ value: 'shipped', label: '已发货' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已关闭' },
]}
placeholder="所有状态"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索订单ID或商品ID..."
className="input-ios pl-9"
/>
</div>
</div>
</div>
</div>
</motion.div>

View File

@ -1,23 +1,16 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Search, ShoppingBag } from 'lucide-react'
import { searchItems } from '@/api/search'
import { getAccounts } from '@/api/accounts'
import { Search, ShoppingBag, ExternalLink, MapPin, Heart } from 'lucide-react'
import { searchItems, SearchResultItem } from '@/api/search'
import { useUIStore } from '@/store/uiStore'
import { ButtonLoading } from '@/components/common/Loading'
import type { Item, Account } from '@/types'
export function ItemSearch() {
const { addToast } = useUIStore()
const [loading, setLoading] = useState(false)
const [keyword, setKeyword] = useState('')
const [selectedAccount, setSelectedAccount] = useState('')
const [results, setResults] = useState<Item[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
useEffect(() => {
getAccounts().then(setAccounts).catch(() => {})
}, [])
const [results, setResults] = useState<SearchResultItem[]>([])
const [total, setTotal] = useState(0)
const handleSearch = async (e?: React.FormEvent) => {
e?.preventDefault()
@ -26,17 +19,29 @@ export function ItemSearch() {
return
}
addToast({ type: 'info', message: '正在搜索中,请稍候...' })
try {
setLoading(true)
const result = await searchItems(keyword, selectedAccount || undefined)
setResults([])
const result = await searchItems(keyword.trim())
if (result.success) {
setResults(result.data || [])
setTotal(result.total || result.data.length)
if ((result.data || []).length === 0) {
addToast({ type: 'info', message: '未找到相关商品' })
} else {
addToast({ type: 'success', message: `搜索完成,找到 ${result.data.length} 件商品` })
}
if (result.error) {
addToast({ type: 'warning', message: result.error })
}
}
} catch {
addToast({ type: 'error', message: '搜索失败' })
addToast({ type: 'error', message: '搜索失败,请稍后重试' })
} finally {
setLoading(false)
}
@ -45,9 +50,14 @@ export function ItemSearch() {
return (
<div className="space-y-4">
{/* Header */}
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
<div className="flex items-center justify-between">
<div>
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
{total > 0 && (
<span className="badge-primary"> {total} </span>
)}
</div>
{/* Search Bar */}
@ -56,87 +66,128 @@ export function ItemSearch() {
animate={{ opacity: 1, y: 0 }}
className="vben-card"
>
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="输入关键词搜索商品..."
className="input-ios pl-12"
/>
</div>
<div className="w-full md:w-64">
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="input-ios"
<div className="vben-card-body">
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 dark:text-slate-500 z-10" />
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="输入关键词搜索商品..."
className="input-ios pl-12"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-ios-primary w-full md:w-32 flex items-center justify-center"
>
<option value="">使</option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.id}
</option>
))}
</select>
</div>
<button
type="submit"
disabled={loading}
className="btn-ios-primary w-full md:w-32 flex items-center justify-center"
>
{loading ? <ButtonLoading /> : '搜索'}
</button>
</form>
{loading ? <ButtonLoading /> : '搜索'}
</button>
</form>
</div>
</motion.div>
{/* Results */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{results.map((item, index) => (
<motion.div
key={item.id || index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="vben-card group hover:shadow-ios-lg transition-all duration-300"
>
<div className="aspect-square bg-gray-100 relative overflow-hidden">
{/* Placeholder for item image - in real app would use item.image_url */}
<div className="absolute inset-0 flex items-center justify-center text-gray-300">
<ShoppingBag className="w-12 h-12" />
{results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
>
{results.map((item, index) => (
<motion.a
key={item.item_id || index}
href={item.item_url || `https://www.goofish.com/item?id=${item.item_id}`}
target="_blank"
rel="noopener noreferrer"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
className="vben-card group hover:shadow-lg transition-all duration-300 overflow-hidden"
>
{/* 商品图片 */}
<div className="aspect-square bg-slate-100 dark:bg-slate-800 relative overflow-hidden">
{item.main_image ? (
<img
src={item.main_image}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none'
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-slate-300 dark:text-slate-600">
<ShoppingBag className="w-12 h-12" />
</div>
)}
{/* 外链图标 */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="bg-black/50 rounded-full p-1.5">
<ExternalLink className="w-3.5 h-3.5 text-white" />
</div>
</div>
</div>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/5 transition-colors" />
</div>
<div className="p-4">
<h3 className="font-medium text-gray-900 line-clamp-2 mb-2 h-12">
{item.title}
</h3>
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-red-500">¥{item.price}</span>
<span className="text-sm text-gray-500">{item.cookie_id}</span>
{/* 商品信息 */}
<div className="p-3">
<h3 className="font-medium text-slate-900 dark:text-slate-100 line-clamp-2 text-sm mb-2 min-h-[2.5rem]">
{item.title}
</h3>
<div className="flex items-center justify-between mb-2">
<span className="text-lg font-bold text-red-500">{item.price}</span>
{item.want_count && item.want_count > 0 && (
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Heart className="w-3 h-3" />
{item.want_count}
</span>
)}
</div>
<div className="flex items-center justify-between text-xs text-slate-500 dark:text-slate-400">
<span className="truncate max-w-[60%]">{item.seller_name || '-'}</span>
{item.area && (
<span className="flex items-center gap-0.5">
<MapPin className="w-3 h-3" />
{item.area}
</span>
)}
</div>
{/* 标签 */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{item.tags.slice(0, 3).map((tag, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 bg-slate-100 dark:bg-slate-700 rounded text-slate-600 dark:text-slate-300">
{tag}
</span>
))}
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex justify-between items-center text-sm text-gray-500">
<span>ID: {item.item_id}</span>
{item.has_sku && <span className="badge-info"></span>}
</div>
</div>
</motion.div>
))}
</motion.div>
</motion.a>
))}
</motion.div>
)}
{/* Empty State */}
{!loading && results.length === 0 && (
<div className="text-center py-12 text-gray-500">
<ShoppingBag className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<ShoppingBag className="w-16 h-16 text-slate-300 dark:text-slate-600 mx-auto mb-4" />
<p></p>
</div>
)}
{/* Loading State */}
{loading && (
<div className="text-center py-12">
<div className="inline-flex items-center gap-2 text-blue-500">
<ButtonLoading />
<span>...</span>
</div>
</div>
)}
</div>
)
}

View File

@ -2,16 +2,19 @@ import { useState, useEffect } from 'react'
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw } from 'lucide-react'
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend } from '@/api/settings'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
import { PageLoading, ButtonLoading } from '@/components/common/Loading'
import type { SystemSettings } from '@/types'
export function Settings() {
const { addToast } = useUIStore()
const { isAuthenticated, token, _hasHydrated } = useAuthStore()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [settings, setSettings] = useState<SystemSettings | null>(null)
const loadSettings = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getSystemSettings()
@ -26,8 +29,9 @@ export function Settings() {
}
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadSettings()
}, [])
}, [_hasHydrated, isAuthenticated, token])
const handleSave = async () => {
if (!settings) return
@ -79,7 +83,7 @@ export function Settings() {
}
return (
<div className="space-y-4 max-w-4xl">
<div className="space-y-4">
{/* Header */}
<div className="page-header flex-between flex-wrap gap-4">
<div>
@ -98,192 +102,183 @@ export function Settings() {
</div>
</div>
{/* General Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
{/* 双列布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* 左列 */}
<div className="space-y-4">
{/* General Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<SettingsIcon className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="flex items-center justify-between py-3 border-b border-slate-100 dark:border-slate-700">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={settings?.registration_enabled ?? true}
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={settings?.show_login_info ?? true}
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings?.registration_enabled ?? true}
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-slate-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500" />
</label>
</div>
<div className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
{/* AI Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Bot className="w-4 h-4 text-green-500" />
AI
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="input-group">
<label className="input-label">API </label>
<input
type="text"
value={settings?.ai_api_url || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_url: e.target.value } : null)}
placeholder="https://api.openai.com/v1"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label">API Key</label>
<input
type="password"
value={settings?.ai_api_key || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
placeholder="sk-..."
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={settings?.ai_model || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_model: e.target.value } : null)}
placeholder="gpt-3.5-turbo"
className="input-ios"
/>
</div>
<button onClick={handleTestAI} className="btn-ios-secondary">
AI
</button>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings?.show_login_info ?? true}
onChange={(e) => setSettings(s => s ? { ...s, show_login_info: e.target.checked } : null)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-slate-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500" />
</label>
</div>
</div>
</div>
{/* AI Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<Bot className="w-4 h-4 text-green-500" />
AI
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="input-group">
<label className="input-label">API </label>
<input
type="text"
value={settings?.ai_api_url || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_url: e.target.value } : null)}
placeholder="https://api.openai.com/v1"
className="input-ios"
/>
{/* 右列 */}
<div className="space-y-4">
{/* Email Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Mail className="w-4 h-4 text-amber-500" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label">SMTP </label>
<input
type="text"
value={settings?.smtp_host || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_host: e.target.value } : null)}
placeholder="smtp.example.com"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="number"
value={settings?.smtp_port || 465}
onChange={(e) => setSettings(s => s ? { ...s, smtp_port: parseInt(e.target.value) } : null)}
placeholder="465"
className="input-ios"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label"></label>
<input
type="email"
value={settings?.smtp_user || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_user: e.target.value } : null)}
placeholder="noreply@example.com"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label">/</label>
<input
type="password"
value={settings?.smtp_password || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
placeholder="••••••••"
className="input-ios"
/>
</div>
</div>
<button onClick={handleTestEmail} className="btn-ios-secondary">
</button>
</div>
</div>
<div className="input-group">
<label className="input-label">API Key</label>
<input
type="password"
value={settings?.ai_api_key || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
placeholder="sk-..."
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={settings?.ai_model || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_model: e.target.value } : null)}
placeholder="gpt-3.5-turbo"
className="input-ios"
/>
</div>
<button onClick={handleTestAI} className="btn-ios-secondary">
AI
</button>
</div>
</div>
{/* Email Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<Mail className="w-4 h-4 text-amber-500" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label">SMTP </label>
<input
type="text"
value={settings?.smtp_host || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_host: e.target.value } : null)}
placeholder="smtp.example.com"
className="input-ios"
/>
{/* Security Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Shield className="w-4 h-4 text-red-500" />
</h2>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="number"
value={settings?.smtp_port || 465}
onChange={(e) => setSettings(s => s ? { ...s, smtp_port: parseInt(e.target.value) } : null)}
placeholder="465"
className="input-ios"
/>
<div className="vben-card-body">
<div className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={settings?.login_captcha_enabled ?? false}
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="input-group">
<label className="input-label"></label>
<input
type="email"
value={settings?.smtp_user || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_user: e.target.value } : null)}
placeholder="noreply@example.com"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label">/</label>
<input
type="password"
value={settings?.smtp_password || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
placeholder="••••••••"
className="input-ios"
/>
</div>
</div>
<button onClick={handleTestEmail} className="btn-ios-secondary">
</button>
</div>
</div>
{/* Security Settings */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title flex items-center gap-2">
<Shield className="w-4 h-4 text-red-500" />
</h2>
</div>
<div className="vben-card-body">
<div className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings?.login_captcha_enabled ?? false}
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-200 dark:bg-slate-600 peer-focus:outline-none peer-focus:ring-2
peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-slate-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-500" />
</label>
</div>
</div>
</div>
</div>

View File

@ -6,9 +6,11 @@ interface AuthState {
token: string | null
user: User | null
isAuthenticated: boolean
_hasHydrated: boolean
setAuth: (token: string, user: User) => void
clearAuth: () => void
updateUser: (user: Partial<User>) => void
setHasHydrated: (state: boolean) => void
}
export const useAuthStore = create<AuthState>()(
@ -17,6 +19,7 @@ export const useAuthStore = create<AuthState>()(
token: null,
user: null,
isAuthenticated: false,
_hasHydrated: false,
setAuth: (token, user) => {
localStorage.setItem('auth_token', token)
@ -35,6 +38,10 @@ export const useAuthStore = create<AuthState>()(
user: state.user ? { ...state.user, ...userData } : null,
}))
},
setHasHydrated: (state) => {
set({ _hasHydrated: state })
},
}),
{
name: 'auth-storage',
@ -43,6 +50,9 @@ export const useAuthStore = create<AuthState>()(
user: state.user,
isAuthenticated: state.isAuthenticated
}),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true)
},
}
)
)

View File

@ -108,13 +108,60 @@
}
.vben-card-title {
@apply text-base font-semibold text-slate-800 dark:text-slate-100;
@apply text-base font-semibold text-slate-800 dark:text-slate-100 flex items-center gap-2;
}
.vben-card-body {
@apply p-5;
}
/* ==================== 开关组件 ==================== */
.switch-ios {
@apply relative inline-flex items-center cursor-pointer;
}
.switch-ios input {
@apply sr-only;
}
.switch-ios .switch-slider {
@apply w-11 h-6 bg-slate-200 dark:bg-slate-600 rounded-full
transition-colors duration-200 ease-in-out
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:border-slate-300 after:border after:rounded-full
after:h-5 after:w-5 after:transition-all after:duration-200;
}
.switch-ios input:checked + .switch-slider {
@apply bg-blue-500;
}
.switch-ios input:checked + .switch-slider::after {
transform: translateX(100%);
border-color: white;
}
.switch-ios input:focus + .switch-slider {
@apply ring-2 ring-blue-300 dark:ring-blue-800 ring-offset-2 dark:ring-offset-slate-800;
}
/* ==================== 勾选框组件 ==================== */
.checkbox-ios {
@apply w-4 h-4 rounded border-slate-300 dark:border-slate-600
text-blue-500 bg-white dark:bg-slate-700
focus:ring-2 focus:ring-blue-500 focus:ring-offset-0
transition-colors cursor-pointer;
}
.checkbox-ios:checked {
@apply bg-blue-500 border-blue-500;
}
/* 勾选框标签组合 */
.checkbox-label {
@apply flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300 cursor-pointer;
}
/* 兼容旧类名 */
.glass-card {
@apply vben-card;
@ -198,6 +245,20 @@
@apply input-ios border-red-500 focus:border-red-500 focus:ring-red-500;
}
/* 自定义下拉框样式 */
select.input-ios {
@apply appearance-none cursor-pointer
bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2364748b%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')]
bg-[length:1.25rem_1.25rem]
bg-[right_0.5rem_center]
bg-no-repeat
pr-10;
}
.dark select.input-ios {
background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2394a3b8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E');
}
/* 输入框组 */
.input-group {
@apply space-y-1.5;

View File

@ -54,14 +54,21 @@ export interface Keyword {
// 商品相关类型
export interface Item {
id: string
id: string | number
cookie_id: string
item_id: string
title: string
title?: string
item_title?: string
desc?: string
price: string
has_sku: boolean
multi_delivery: boolean
item_description?: string
item_detail?: string
item_category?: string
price?: string
item_price?: string
has_sku?: boolean
is_multi_spec?: number | boolean
multi_delivery?: boolean
multi_quantity_delivery?: number | boolean
created_at?: string
updated_at?: string
}
@ -114,18 +121,20 @@ export interface Card {
updated_at?: string
}
// 发货规则相关类型
// 发货规则相关类型 - 匹配后端接口
export interface DeliveryRule {
id: string
cookie_id: string
item_id?: string
keyword?: string
delivery_type: 'card' | 'text' | 'api'
delivery_content?: string
api_url?: string
api_method?: string
api_params?: string
id: number
keyword: string
card_id: number
delivery_count: number
enabled: boolean
description?: string
delivery_times?: number
card_name?: string
card_type?: string
is_multi_spec?: boolean
spec_name?: string
spec_value?: string
created_at?: string
updated_at?: string
}
@ -145,18 +154,13 @@ export interface NotificationChannel {
updated_at?: string
}
// 消息通知相关类型
// 消息通知相关类型 - 匹配后端接口
// 后端返回格式: { cookie_id: { channel_id: { enabled: boolean, channel_name: string } } }
export interface MessageNotification {
id: string
cookie_id?: string
name: string
notification_type?: string
trigger_keyword?: string
channel_id?: string
channel_ids?: string[]
cookie_id: string
channel_id: number
channel_name?: string
enabled: boolean
created_at?: string
updated_at?: string
}
// 系统设置相关类型

View File

@ -61,7 +61,27 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin': {
'/admin/users': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/logs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/risk-control-logs': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/backup': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/data': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/admin/cookies': {
target: 'http://localhost:8080',
changeOrigin: true,
},