修复已知bug,完善系统功能

This commit is contained in:
zhinianboke 2025-12-23 23:02:24 +08:00
parent 347ee75985
commit 446320b62c
45 changed files with 3375 additions and 1249 deletions

View File

@ -440,7 +440,6 @@ from config import AUTO_REPLY, COOKIES_LIST
import cookie_manager as cm
from db_manager import db_manager
from file_log_collector import setup_file_logging
from usage_statistics import report_user_count
def _start_api_server():
@ -575,12 +574,6 @@ async def main():
threading.Thread(target=_start_api_server, daemon=True).start()
print("API 服务线程已启动")
# 上报用户统计
try:
await report_user_count()
except Exception as e:
logger.debug(f"上报用户统计失败: {e}")
# 阻塞保持运行
print("主程序启动完成,保持运行...")
await asyncio.Event().wait()

View File

@ -1734,7 +1734,7 @@ class XianyuLive:
# user_id=f"{self.cookie_id}_{int(time.time() * 1000)}", # 使用唯一ID避免冲突
user_id=f"{self.cookie_id}", # 使用唯一ID避免冲突
enable_learning=True, # 启用学习功能
headless=False # 使用有头模式(可视化浏览器)
headless=True # 使用无头模式
)
# 在线程池中执行滑块验证
@ -3500,9 +3500,6 @@ class XianyuLive:
logger.info(f"📱 解析后的配置数据: {config_data}")
match channel_type:
case 'qq':
logger.info(f"📱 开始发送QQ通知...")
await self._send_qq_notification(config_data, notification_msg)
case 'ding_talk' | 'dingtalk':
logger.info(f"📱 开始发送钉钉通知...")
await self._send_dingtalk_notification(config_data, notification_msg)
@ -3547,54 +3544,6 @@ class XianyuLive:
# 兼容旧格式(直接字符串)
return {"config": config}
async def _send_qq_notification(self, config_data: dict, message: str):
"""发送QQ通知"""
try:
import aiohttp
logger.info(f"📱 QQ通知 - 开始处理配置数据: {config_data}")
# 解析配置QQ号码
qq_number = config_data.get('qq_number') or config_data.get('config', '')
qq_number = qq_number.strip() if qq_number else ''
logger.info(f"📱 QQ通知 - 解析到QQ号码: {qq_number}")
if not qq_number:
logger.warning("📱 QQ通知 - QQ号码配置为空无法发送通知")
return
# 构建请求URL
api_url = "http://notice.zhinianblog.cn/sendPrivateMsg"
params = {
'qq': qq_number,
'msg': message
}
logger.info(f"📱 QQ通知 - 请求URL: {api_url}")
logger.info(f"📱 QQ通知 - 请求参数: qq={qq_number}, msg长度={len(message)}")
# 发送GET请求
async with aiohttp.ClientSession() as session:
async with session.get(api_url, params=params, timeout=10) as response:
response_text = await response.text()
logger.info(f"📱 QQ通知 - 响应状态: {response.status}")
# 需求502 视为成功,且不打印返回内容
if response.status == 502:
logger.info(f"📱 QQ通知发送成功: {qq_number} (状态码: {response.status})")
elif response.status == 200:
logger.info(f"📱 QQ通知发送成功: {qq_number} (状态码: {response.status})")
logger.warning(f"📱 QQ通知 - 响应内容: {response_text}")
else:
logger.warning(f"📱 QQ通知发送失败: HTTP {response.status}")
logger.warning(f"📱 QQ通知 - 响应内容: {response_text}")
except Exception as e:
logger.error(f"📱 发送QQ通知异常: {self._safe_str(e)}")
import traceback
logger.error(f"📱 QQ通知异常详情: {traceback.format_exc()}")
async def _send_dingtalk_notification(self, config_data: dict, message: str):
"""发送钉钉通知"""
try:
@ -4129,9 +4078,6 @@ class XianyuLive:
config_data = self._parse_notification_config(channel_config)
match channel_type:
case 'qq':
await self._send_qq_notification(config_data, notification_msg)
notification_sent = True
case 'ding_talk' | 'dingtalk':
await self._send_dingtalk_notification(config_data, notification_msg)
notification_sent = True
@ -4283,9 +4229,6 @@ class XianyuLive:
config_data = self._parse_notification_config(channel_config)
match channel_type:
case 'qq':
await self._send_qq_notification(config_data, notification_message)
logger.info(f"已发送自动发货通知到QQ")
case 'ding_talk' | 'dingtalk':
await self._send_dingtalk_notification(config_data, notification_message)
logger.info(f"已发送自动发货通知到钉钉")
@ -4569,41 +4512,60 @@ class XianyuLive:
if spec_name and spec_value:
logger.info(f"获取到规格信息: {spec_name} = {spec_value}")
else:
logger.warning(f"未能获取到规格信息,将使用兜底匹配")
logger.warning(f"未能获取到规格信息,将跳过自动发货")
return None
else:
logger.warning(f"获取订单详情失败(返回类型: {type(order_detail).__name__}),将使用兜底匹配")
logger.warning(f"获取订单详情失败(返回类型: {type(order_detail).__name__}),将跳过自动发货")
return None
except Exception as e:
logger.error(f"获取订单规格信息失败: {self._safe_str(e)},将使用兜底匹配")
logger.error(f"获取订单规格信息失败: {self._safe_str(e)},将跳过自动发货")
return None
# 智能匹配发货规则:优先精确匹配,然后兜底匹配
# 智能匹配发货规则:多规格商品只匹配多规格卡券,非多规格商品只匹配非多规格卡券
delivery_rules = []
# 第一步:如果有规格信息,尝试精确匹配多规格发货规则
if spec_name and spec_value:
logger.info(f"尝试精确匹配多规格发货规则: {search_text[:50]}... [{spec_name}:{spec_value}]")
delivery_rules = db_manager.get_delivery_rules_by_keyword_and_spec(search_text, spec_name, spec_value)
if delivery_rules:
logger.info(f"✅ 找到精确匹配的多规格发货规则: {len(delivery_rules)}")
if is_multi_spec:
# 多规格商品:只匹配多规格发货规则
if spec_name and spec_value:
logger.info(f"多规格商品,尝试匹配多规格发货规则: {search_text[:50]}... [{spec_name}:{spec_value}]")
delivery_rules = db_manager.get_delivery_rules_by_keyword_and_spec(search_text, spec_name, spec_value)
# 过滤只保留多规格卡券
delivery_rules = [r for r in delivery_rules if r.get('is_multi_spec')]
if delivery_rules:
logger.info(f"✅ 找到匹配的多规格发货规则: {len(delivery_rules)}")
else:
logger.warning(f"❌ 多规格商品未找到匹配的多规格发货规则,跳过自动发货")
return None
else:
logger.info(f"❌ 未找到精确匹配的多规格发货规则")
# 第二步:如果精确匹配失败,尝试兜底匹配(普通发货规则)
if not delivery_rules:
logger.info(f"尝试兜底匹配普通发货规则: {search_text[:50]}...")
logger.warning(f"❌ 多规格商品但无规格信息,跳过自动发货")
return None
else:
# 非多规格商品:只匹配非多规格发货规则
logger.info(f"非多规格商品,尝试匹配普通发货规则: {search_text[:50]}...")
delivery_rules = db_manager.get_delivery_rules_by_keyword(search_text)
# 过滤只保留非多规格卡券
delivery_rules = [r for r in delivery_rules if not r.get('is_multi_spec')]
if delivery_rules:
logger.info(f"✅ 找到兜底匹配的普通发货规则: {len(delivery_rules)}")
logger.info(f"✅ 找到匹配的普通发货规则: {len(delivery_rules)}")
else:
logger.info(f"❌ 未找到任何匹配的发货规则")
logger.warning(f"❌ 非多规格商品未找到匹配的普通发货规则,跳过自动发货")
return None
# 检查匹配到的卡券数量,只有唯一匹配时才自动发货
if len(delivery_rules) > 1:
rule_names = [f"{r['card_name']}({r.get('spec_name', '')}:{r.get('spec_value', '')})" if r.get('is_multi_spec') else r['card_name'] for r in delivery_rules]
logger.warning(f"❌ 匹配到多个发货规则({len(delivery_rules)}个),无法确定使用哪个,跳过自动发货: {', '.join(rule_names)}")
return None
if not delivery_rules:
logger.warning(f"未找到匹配的发货规则: {search_text[:50]}...")
return None
# 使用第一个匹配的规则(按关键字长度降序排列,优先匹配更精确的规则)
# 使用唯一匹配的规则
rule = delivery_rules[0]
logger.info(f"✅ 唯一匹配发货规则: {rule['keyword']} -> {rule['card_name']} ({rule['card_type']})")
# 保存商品信息到数据库(需要有商品标题才保存)
# 尝试获取商品标题
@ -4761,6 +4723,10 @@ class XianyuLive:
def _process_delivery_content_with_description(self, delivery_content: str, card_description: str) -> str:
"""处理发货内容和备注信息,实现变量替换"""
try:
# 如果是图片发送标记,不进行备注处理,直接返回
if delivery_content.startswith("__IMAGE_SEND__"):
return delivery_content
# 如果没有备注信息,直接返回发货内容
if not card_description or not card_description.strip():
return delivery_content
@ -7201,6 +7167,8 @@ class XianyuLive:
# 如果不是同步包消息,直接返回
if not self.is_sync_package(message_data):
# 添加调试日志,记录非同步包消息
logger.debug(f"{self.cookie_id}】非同步包消息,跳过处理")
return
# 获取并解密数据
@ -7583,6 +7551,20 @@ class XianyuLive:
logger.warning(f'[{msg_time}] 【{self.cookie_id}】❌ 未能提取到订单ID无法执行免拼发货')
return
# 更新订单的is_bargain字段为True标记为小刀订单
try:
from db_manager import db_manager
db_manager.insert_or_update_order(
order_id=order_id,
item_id=item_id,
buyer_id=send_user_id,
cookie_id=self.cookie_id,
is_bargain=True
)
logger.info(f'[{msg_time}] 【{self.cookie_id}】✅ 订单 {order_id} 已标记为小刀订单')
except Exception as e:
logger.error(f'[{msg_time}] 【{self.cookie_id}】标记小刀订单失败: {self._safe_str(e)}')
# 延迟2秒后执行免拼发货
logger.info(f'[{msg_time}] 【{self.cookie_id}】延迟2秒后执行免拼发货...')
await asyncio.sleep(2)

View File

@ -228,12 +228,22 @@ class DBManager:
amount TEXT,
order_status TEXT DEFAULT 'unknown',
cookie_id TEXT,
is_bargain INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cookie_id) REFERENCES cookies(id) ON DELETE CASCADE
)
''')
# 检查并添加 is_bargain 列(用于标记小刀订单)
try:
self._execute_sql(cursor, "SELECT is_bargain FROM orders LIMIT 1")
except sqlite3.OperationalError:
# is_bargain 列不存在,需要添加
logger.info("正在为 orders 表添加 is_bargain 列...")
self._execute_sql(cursor, "ALTER TABLE orders ADD COLUMN is_bargain INTEGER DEFAULT 0")
logger.info("orders 表 is_bargain 列添加完成")
# 检查并添加 user_id 列(用于数据库迁移)
try:
self._execute_sql(cursor, "SELECT user_id FROM cards LIMIT 1")
@ -427,6 +437,7 @@ class DBManager:
('theme_color', 'blue', '主题颜色'),
('registration_enabled', 'true', '是否开启用户注册'),
('show_default_login_info', 'true', '是否显示默认登录信息'),
('login_captcha_enabled', 'true', '登录滑动验证码开关'),
('smtp_server', '', 'SMTP服务器地址'),
('smtp_port', '587', 'SMTP端口'),
('smtp_user', '', 'SMTP登录用户名发件邮箱'),
@ -714,6 +725,14 @@ class DBManager:
self._execute_sql(cursor, "ALTER TABLE item_info ADD COLUMN multi_quantity_delivery BOOLEAN DEFAULT FALSE")
logger.info("为item_info表添加多数量发货字段")
# 检查orders表是否有is_bargain字段
try:
self._execute_sql(cursor, "SELECT is_bargain FROM orders LIMIT 1")
except sqlite3.OperationalError:
# is_bargain字段不存在需要添加
self._execute_sql(cursor, "ALTER TABLE orders ADD COLUMN is_bargain INTEGER DEFAULT 0")
logger.info("为orders表添加is_bargain字段")
# 处理keywords表的唯一约束问题
# 由于SQLite不支持直接修改约束我们需要重建表
self._migrate_keywords_table_constraints(cursor)
@ -3207,7 +3226,8 @@ class DBManager:
dr.description, dr.delivery_times,
c.name as card_name, c.type as card_type, c.api_config,
c.text_content, c.data_content, c.image_url, c.enabled as card_enabled, c.description as card_description,
c.delay_seconds as card_delay_seconds
c.delay_seconds as card_delay_seconds,
c.is_multi_spec, c.spec_name, c.spec_value
FROM delivery_rules dr
LEFT JOIN cards c ON dr.card_id = c.id
WHERE dr.enabled = 1 AND c.enabled = 1
@ -3248,7 +3268,10 @@ class DBManager:
'image_url': row[12],
'card_enabled': bool(row[13]),
'card_description': row[14], # 卡券备注信息
'card_delay_seconds': row[15] or 0 # 延时秒数
'card_delay_seconds': row[15] or 0, # 延时秒数
'is_multi_spec': bool(row[16]) if row[16] is not None else False,
'spec_name': row[17],
'spec_value': row[18]
})
return rules
@ -4410,7 +4433,8 @@ class DBManager:
def insert_or_update_order(self, order_id: str, item_id: str = None, buyer_id: str = None,
spec_name: str = None, spec_value: str = None, quantity: str = None,
amount: str = None, order_status: str = None, cookie_id: str = None):
amount: str = None, order_status: str = None, cookie_id: str = None,
is_bargain: bool = None):
"""插入或更新订单信息"""
with self.lock:
try:
@ -4457,6 +4481,9 @@ class DBManager:
if cookie_id is not None:
update_fields.append("cookie_id = ?")
update_values.append(cookie_id)
if is_bargain is not None:
update_fields.append("is_bargain = ?")
update_values.append(1 if is_bargain else 0)
if update_fields:
update_fields.append("updated_at = CURRENT_TIMESTAMP")
@ -4469,10 +4496,11 @@ class DBManager:
# 插入新订单
cursor.execute('''
INSERT INTO orders (order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, cookie_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
quantity, amount, order_status, cookie_id, is_bargain)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status or 'unknown', cookie_id))
quantity, amount, order_status or 'unknown', cookie_id,
1 if is_bargain else 0))
logger.info(f"插入新订单: {order_id}")
self.conn.commit()
@ -4490,13 +4518,14 @@ class DBManager:
cursor = self.conn.cursor()
cursor.execute('''
SELECT order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, cookie_id, created_at, updated_at
quantity, amount, order_status, cookie_id, is_bargain, created_at, updated_at
FROM orders WHERE order_id = ?
''', (order_id,))
row = cursor.fetchone()
if row:
return {
'id': row[0], # 使用 order_id 作为 id
'order_id': row[0],
'item_id': row[1],
'buyer_id': row[2],
@ -4504,10 +4533,11 @@ class DBManager:
'spec_value': row[4],
'quantity': row[5],
'amount': row[6],
'order_status': row[7],
'status': row[7],
'cookie_id': row[8],
'created_at': row[9],
'updated_at': row[10]
'is_bargain': bool(row[9]) if row[9] is not None else False,
'created_at': row[10],
'updated_at': row[11]
}
return None
@ -4515,6 +4545,22 @@ class DBManager:
logger.error(f"获取订单信息失败: {order_id} - {e}")
return None
def delete_order(self, order_id: str):
"""删除订单"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('DELETE FROM orders WHERE order_id = ?', (order_id,))
if cursor.rowcount > 0:
self.conn.commit()
logger.info(f"删除订单成功: {order_id}")
return True
return False
except Exception as e:
logger.error(f"删除订单失败: {order_id} - {e}")
self.conn.rollback()
return False
def get_orders_by_cookie(self, cookie_id: str, limit: int = 100):
"""根据Cookie ID获取订单列表"""
with self.lock:
@ -4522,7 +4568,7 @@ class DBManager:
cursor = self.conn.cursor()
cursor.execute('''
SELECT order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, created_at, updated_at
quantity, amount, order_status, is_bargain, created_at, updated_at
FROM orders WHERE cookie_id = ?
ORDER BY created_at DESC LIMIT ?
''', (cookie_id, limit))
@ -4530,6 +4576,7 @@ class DBManager:
orders = []
for row in cursor.fetchall():
orders.append({
'id': row[0], # 使用 order_id 作为 id
'order_id': row[0],
'item_id': row[1],
'buyer_id': row[2],
@ -4537,9 +4584,10 @@ class DBManager:
'spec_value': row[4],
'quantity': row[5],
'amount': row[6],
'order_status': row[7],
'created_at': row[8],
'updated_at': row[9]
'status': row[7],
'is_bargain': bool(row[8]) if row[8] is not None else False,
'created_at': row[9],
'updated_at': row[10]
})
return orders
@ -4548,6 +4596,42 @@ class DBManager:
logger.error(f"获取Cookie订单列表失败: {cookie_id} - {e}")
return []
def get_all_orders(self, limit: int = 1000):
"""获取所有订单列表"""
with self.lock:
try:
cursor = self.conn.cursor()
cursor.execute('''
SELECT order_id, item_id, buyer_id, spec_name, spec_value,
quantity, amount, order_status, cookie_id, is_bargain, created_at, updated_at
FROM orders
ORDER BY created_at DESC LIMIT ?
''', (limit,))
orders = []
for row in cursor.fetchall():
orders.append({
'id': row[0],
'order_id': row[0],
'item_id': row[1],
'buyer_id': row[2],
'spec_name': row[3],
'spec_value': row[4],
'quantity': row[5],
'amount': row[6],
'status': row[7],
'cookie_id': row[8],
'is_bargain': bool(row[9]) if row[9] is not None else False,
'created_at': row[10],
'updated_at': row[11]
})
return orders
except Exception as e:
logger.error(f"获取所有订单列表失败: {e}")
return []
def delete_table_record(self, table_name: str, record_id: str):
"""删除指定表的指定记录"""
with self.lock:

View File

@ -10,6 +10,7 @@ import { Items } from '@/pages/items/Items'
import { Orders } from '@/pages/orders/Orders'
import { Keywords } from '@/pages/keywords/Keywords'
import { About } from '@/pages/about/About'
import { Disclaimer } from '@/pages/disclaimer/Disclaimer'
import { Cards } from '@/pages/cards/Cards'
import { Delivery } from '@/pages/delivery/Delivery'
import { NotificationChannels } from '@/pages/notifications/NotificationChannels'
@ -21,6 +22,7 @@ import { Users } from '@/pages/admin/Users'
import { Logs } from '@/pages/admin/Logs'
import { RiskLogs } from '@/pages/admin/RiskLogs'
import { DataManagement } from '@/pages/admin/DataManagement'
import { DisclaimerModal } from '@/components/common/DisclaimerModal'
import { verifyToken } from '@/api/auth'
import { Toast } from '@/components/common/Toast'
@ -28,6 +30,7 @@ import { Toast } from '@/components/common/Toast'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, setAuth, clearAuth, token: storeToken, _hasHydrated } = useAuthStore()
const [authState, setAuthState] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const [showDisclaimer, setShowDisclaimer] = useState(false)
const checkingRef = useRef(false)
useEffect(() => {
@ -63,6 +66,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
is_admin: result.is_admin || false,
})
setAuthState('authenticated')
// 检查是否已同意免责声明
const disclaimerAccepted = localStorage.getItem('disclaimer_accepted')
if (!disclaimerAccepted) {
setShowDisclaimer(true)
}
} else {
clearAuth()
setAuthState('unauthenticated')
@ -78,6 +87,17 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
checkAuth()
}, [_hasHydrated, isAuthenticated, storeToken, setAuth, clearAuth])
const handleDisclaimerAgree = () => {
localStorage.setItem('disclaimer_accepted', 'true')
setShowDisclaimer(false)
}
const handleDisclaimerDisagree = () => {
clearAuth()
setShowDisclaimer(false)
setAuthState('unauthenticated')
}
// 等待 hydration 或检查完成
if (!_hasHydrated || authState === 'checking') {
return (
@ -91,7 +111,16 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <Navigate to="/login" replace />
}
return <>{children}</>
return (
<>
{children}
<DisclaimerModal
isOpen={showDisclaimer}
onAgree={handleDisclaimerAgree}
onDisagree={handleDisclaimerDisagree}
/>
</>
)
}
function App() {
@ -126,6 +155,7 @@ function App() {
<Route path="message-notifications" element={<MessageNotifications />} />
<Route path="item-search" element={<ItemSearch />} />
<Route path="settings" element={<Settings />} />
<Route path="disclaimer" element={<Disclaimer />} />
<Route path="about" element={<About />} />
{/* Admin routes */}

View File

@ -24,6 +24,9 @@ export const getAccountDetails = async (): Promise<AccountDetail[]> => {
auto_confirm: boolean
remark?: string
pause_duration?: number
username?: string
login_password?: string
show_browser?: boolean
}
const data = await get<BackendAccountDetail[]>('/cookies/details')
// 后端返回 value 字段,前端使用 cookie 字段
@ -34,6 +37,9 @@ export const getAccountDetails = async (): Promise<AccountDetail[]> => {
auto_confirm: item.auto_confirm,
note: item.remark,
pause_duration: item.pause_duration,
username: item.username,
login_password: item.login_password,
show_browser: item.show_browser,
use_ai_reply: false,
use_default_reply: false,
}))
@ -70,6 +76,15 @@ export const updateAccountPauseDuration = (id: string, pauseDuration: number): P
return put(`/cookies/${id}/pause-duration`, { pause_duration: pauseDuration })
}
// 更新账号登录信息(用户名、密码、是否显示浏览器)
export const updateAccountLoginInfo = (id: string, data: {
username?: string
login_password?: string
show_browser?: boolean
}): Promise<ApiResponse> => {
return put(`/cookies/${id}/login-info`, data)
}
// 删除账号
export const deleteAccount = (id: string): Promise<ApiResponse> => {
return del(`/cookies/${id}`)

View File

@ -64,7 +64,7 @@ export const getSystemLogs = async (params?: { page?: number; limit?: number; le
// 清空系统日志
export const clearSystemLogs = (): Promise<ApiResponse> => {
return post('/logs/clear')
return post('/admin/logs/clear')
}
// ========== 风控日志 ==========
@ -74,7 +74,11 @@ export interface RiskLog {
cookie_id: string
risk_type: string
message: string
processing_result: string
processing_status: string
error_message: string | null
created_at: string
updated_at: string
}
// 获取风控日志
@ -100,17 +104,20 @@ export const getRiskLogs = async (params?: { page?: number; limit?: number; cook
id: String(item.id),
cookie_id: item.cookie_id || item.cookie_name,
risk_type: item.event_type,
message: item.event_description || item.processing_result,
message: item.event_description || '',
processing_result: item.processing_result || '',
processing_status: item.processing_status || '',
error_message: item.error_message,
created_at: item.created_at,
updated_at: item.updated_at || '',
}))
return { success: true, data: logs, total: result.total }
}
// 清空风控日志 - 后端暂未实现批量删除接口
export const clearRiskLogs = async (): Promise<ApiResponse> => {
// 后端只有单条删除接口 DELETE /risk-control-logs/{log_id}
// 暂时返回提示信息
return { success: false, message: '后端暂未实现批量清空风控日志接口' }
// 清空风控日志
export const clearRiskLogs = async (cookieId?: string): Promise<ApiResponse> => {
const query = cookieId ? `?cookie_id=${cookieId}` : ''
return del(`/admin/risk-control-logs${query}`)
}
// ========== 数据管理 ==========

View File

@ -17,13 +17,25 @@ export const logout = (): Promise<ApiResponse> => {
}
// 获取注册状态
export const getRegistrationStatus = (): Promise<{ enabled: boolean }> => {
return get('/registration-status')
export const getRegistrationStatus = async (): Promise<{ enabled: boolean }> => {
try {
const settings = await get<Record<string, any>>('/system-settings/public')
const value = settings.registration_enabled
return { enabled: value === true || value === 'true' || value === 1 || value === '1' }
} catch {
return { enabled: true }
}
}
// 获取登录信息显示状态
export const getLoginInfoStatus = (): Promise<{ enabled: boolean }> => {
return get('/login-info-status')
export const getLoginInfoStatus = async (): Promise<{ enabled: boolean }> => {
try {
const settings = await get<Record<string, any>>('/system-settings/public')
const value = settings.show_default_login_info
return { enabled: value === true || value === 'true' || value === 1 || value === '1' }
} catch {
return { enabled: true }
}
}
// 生成图形验证码
@ -51,3 +63,54 @@ export const register = (data: {
}): Promise<ApiResponse> => {
return post('/register', data)
}
// ==================== 极验滑动验证码 ====================
// 极验验证码初始化响应类型
export interface GeetestRegisterResponse {
success: boolean
code: number
message: string
data?: {
success: number
gt: string
challenge: string
new_captcha: boolean
}
}
// 极验二次验证响应类型
export interface GeetestValidateResponse {
success: boolean
code: number
message: string
}
// 获取极验验证码初始化参数
export const getGeetestRegister = (): Promise<GeetestRegisterResponse> => {
return get('/geetest/register')
}
// 极验二次验证
export const geetestValidate = (data: {
challenge: string
validate: string
seccode: string
}): Promise<GeetestValidateResponse> => {
return post('/geetest/validate', data)
}
// 获取登录验证码开关状态
export const getLoginCaptchaStatus = async (): Promise<{ enabled: boolean }> => {
try {
const settings = await get<Record<string, any>>('/system-settings/public')
const value = settings.login_captcha_enabled
// 如果没有设置,默认开启
if (value === undefined || value === null) {
return { enabled: true }
}
return { enabled: value === true || value === 'true' || value === 1 || value === '1' }
} catch {
return { enabled: true }
}
}

View File

@ -98,22 +98,35 @@ export const batchDeleteKeywords = (cookieId: string, keywordIds: string[]): Pro
}
// 获取默认回复
export const getDefaultReply = (cookieId: string): Promise<{ default_reply: string }> => {
export const getDefaultReply = (cookieId: string): Promise<{ enabled?: boolean; reply_content?: string; reply_once?: boolean }> => {
return get(`/default-reply/${cookieId}`)
}
// 更新默认回复
export const updateDefaultReply = (cookieId: string, defaultReply: string): Promise<ApiResponse> => {
return put(`/default-reply/${cookieId}`, { default_reply: defaultReply })
export const updateDefaultReply = (cookieId: string, replyContent: string, enabled: boolean = true, replyOnce: boolean = false): Promise<ApiResponse> => {
return put(`/default-reply/${cookieId}`, {
enabled,
reply_content: replyContent,
reply_once: replyOnce
})
}
// 导出关键词Excel/模板),返回 Blob 供前端触发下载
export const exportKeywords = (cookieId: string): Promise<Blob> => {
return get<Blob>(`/keywords-export/${cookieId}`, { responseType: 'blob' })
export const exportKeywords = async (cookieId: string): Promise<Blob> => {
const token = localStorage.getItem('auth_token')
const response = await fetch(`/keywords-export/${cookieId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (!response.ok) {
throw new Error('导出失败')
}
return response.blob()
}
// 导入关键词Excel上传文件并返回导入结果
export const importKeywords = (
export const importKeywords = async (
cookieId: string,
file: File
): Promise<ApiResponse<{ added: number; updated: number }>> => {
@ -125,7 +138,7 @@ export const importKeywords = (
}
// 添加图片关键词
export const addImageKeyword = (
export const addImageKeyword = async (
cookieId: string,
keyword: string,
image: File,

View File

@ -1,29 +1,69 @@
import { get } from '@/utils/request'
import { get, del } from '@/utils/request'
import type { Order, ApiResponse } from '@/types'
// 获取订单列表
export const getOrders = (cookieId?: string, status?: string): Promise<{ success: boolean; data: Order[] }> => {
// 订单详情类型
export interface OrderDetail extends Order {
spec_name?: string
spec_value?: string
}
// 获取订单列表(支持分页)
export const getOrders = async (
cookieId?: string,
status?: string,
page: number = 1,
pageSize: number = 20
): Promise<{ success: boolean; data: Order[]; total?: number; total_pages?: number }> => {
const params = new URLSearchParams()
if (cookieId) params.append('cookie_id', cookieId)
if (status) params.append('status', status)
params.append('page', String(page))
params.append('page_size', String(pageSize))
const queryString = params.toString()
return get(`/api/orders${queryString ? `?${queryString}` : ''}`)
try {
const result = await get<{ orders?: Order[]; data?: Order[]; total?: number; total_pages?: number }>(`/api/orders?${queryString}`)
const orders = result.orders || result.data || []
return {
success: true,
data: orders,
total: result.total || orders.length,
total_pages: result.total_pages || Math.ceil((result.total || orders.length) / pageSize)
}
} catch {
return { success: false, data: [], total: 0, total_pages: 0 }
}
}
// 删除订单 - 后端暂未实现
export const deleteOrder = async (_id: string): Promise<ApiResponse> => {
// 后端暂未实现 DELETE /api/orders/{id} 接口
return { success: false, message: '后端暂未实现订单删除接口' }
// 获取订单详情
export const getOrderDetail = async (orderId: string): Promise<{ success: boolean; data?: OrderDetail }> => {
try {
const result = await get<{ order?: OrderDetail; data?: OrderDetail }>(`/api/orders/${orderId}`)
return {
success: true,
data: result.order || result.data
}
} catch {
return { success: false }
}
}
// 批量删除订单 - 后端暂未实现
// 删除订单
export const deleteOrder = async (id: string): Promise<ApiResponse> => {
try {
await del(`/api/orders/${id}`)
return { success: true, message: '删除成功' }
} catch {
return { success: false, message: '删除失败' }
}
}
// 批量删除订单
export const batchDeleteOrders = async (_ids: string[]): Promise<ApiResponse> => {
// 后端暂未实现批量删除接口
return { success: false, message: '后端暂未实现批量删除订单接口' }
}
// 更新订单状态 - 后端暂未实现
// 更新订单状态
export const updateOrderStatus = async (_id: string, _status: string): Promise<ApiResponse> => {
// 后端暂未实现订单状态更新接口
return { success: false, message: '后端暂未实现订单状态更新接口' }
}

View File

@ -88,10 +88,16 @@ export const updateEmailSettings = (data: Record<string, unknown>): Promise<ApiR
return Promise.all(promises).then(() => ({ success: true, message: '设置已保存' }))
}
// TODO: 测试邮件发送功能需要后端支持 type: 'test' 参数
// 当前后端的 /send-verification-code 接口只支持 'register' 和 'login' 类型
export const testEmailSend = async (_email: string): Promise<ApiResponse> => {
return { success: false, message: '邮件测试功能暂未实现,请检查 SMTP 配置后直接保存' }
// 测试邮件发送功能
export const testEmailSend = async (email: string): Promise<ApiResponse> => {
try {
const result = await post<ApiResponse>(`/system-settings/test-email?email=${encodeURIComponent(email)}`)
return result
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string; message?: string } } }
const detail = axiosError.response?.data?.detail || axiosError.response?.data?.message
return { success: false, message: detail || '发送测试邮件失败' }
}
}
// 修改密码(管理员)

View File

@ -0,0 +1,48 @@
/**
*
*
*
*/
export function DisclaimerContent() {
return (
<div className="space-y-6 text-slate-700 dark:text-slate-300">
{/* 数据存储说明 */}
<section>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-3">
</h3>
<p className="text-sm mb-3">
</p>
</section>
{/* 用户须知 */}
<section>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-3">
</h3>
<div className="space-y-2 text-sm">
<p><strong>1. </strong>访</p>
<p><strong>2. 使</strong>使使</p>
<p><strong>3. </strong></p>
<p><strong>4. </strong></p>
<p><strong>5. </strong></p>
<p><strong>6. </strong></p>
<p><strong>7. </strong></p>
</div>
</section>
{/* 隐私保护承诺 */}
<section>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-3">
</h3>
<ul className="space-y-1 text-sm">
<li> </li>
<li> </li>
</ul>
</section>
</div>
)
}

View File

@ -0,0 +1,92 @@
/**
*
*
* 使使
*/
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/utils/cn'
import { DisclaimerContent } from './DisclaimerContent'
interface DisclaimerModalProps {
isOpen: boolean
onAgree: () => void
onDisagree: () => void
}
export function DisclaimerModal({ isOpen, onAgree, onDisagree }: DisclaimerModalProps) {
const [checked, setChecked] = useState(false)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center">
{/* 遮罩层 */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* 弹窗内容 */}
<div
className={cn(
'relative w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col',
'bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
'border border-slate-200 dark:border-slate-700'
)}
>
{/* 标题栏 */}
<div className="flex items-center gap-3 px-6 py-4 border-b border-slate-200 dark:border-slate-700">
<AlertTriangle className="w-6 h-6 text-amber-500" />
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
</h2>
</div>
{/* 内容区域 */}
<div className="flex-1 overflow-y-auto px-6 py-4">
<DisclaimerContent />
</div>
{/* 底部操作区 */}
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700">
{/* 勾选确认 */}
<label className="flex items-center gap-2 mb-4 cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
className="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">
</span>
</label>
{/* 按钮 */}
<div className="flex gap-3">
<button
onClick={onDisagree}
className={cn(
'flex-1 px-4 py-2.5 rounded-lg font-medium transition-colors',
'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-600'
)}
>
</button>
<button
onClick={onAgree}
disabled={!checked}
className={cn(
'flex-1 px-4 py-2.5 rounded-lg font-medium transition-colors',
checked
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 dark:bg-slate-600 text-slate-400 dark:text-slate-500 cursor-not-allowed'
)}
>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,247 @@
/**
*
*
*
* 1.
* 2.
* 3.
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import { getGeetestRegister, geetestValidate } from '@/api/auth'
// 极验验证结果类型
export interface GeetestResult {
challenge: string
validate: string
seccode: string
}
interface GeetestCaptchaProps {
onSuccess: (result: GeetestResult) => void
onError?: (error: string) => void
disabled?: boolean
buttonText?: string
className?: string
}
declare global {
interface Window {
initGeetest?: (config: any, callback: (captchaObj: any) => void) => void
}
}
export function GeetestCaptcha({
onSuccess,
onError,
disabled = false,
buttonText = '点击进行验证',
className = ''
}: GeetestCaptchaProps) {
const [status, setStatus] = useState<'loading' | 'ready' | 'verified' | 'error'>('loading')
const [errorMsg, setErrorMsg] = useState('')
const captchaObjRef = useRef<any>(null)
const initedRef = useRef(false)
const onSuccessRef = useRef(onSuccess)
const onErrorRef = useRef(onError)
useEffect(() => {
onSuccessRef.current = onSuccess
onErrorRef.current = onError
}, [onSuccess, onError])
// 加载极验JS SDK
const loadScript = useCallback((): Promise<void> => {
return new Promise((resolve, reject) => {
if (window.initGeetest) {
resolve()
return
}
const existing = document.querySelector('script[src*="geetest.com"]')
if (existing) {
const check = setInterval(() => {
if (window.initGeetest) {
clearInterval(check)
resolve()
}
}, 100)
setTimeout(() => {
clearInterval(check)
window.initGeetest ? resolve() : reject(new Error('加载超时'))
}, 10000)
return
}
const script = document.createElement('script')
script.src = 'https://static.geetest.com/static/tools/gt.js'
script.async = true
script.onload = () => {
const check = setInterval(() => {
if (window.initGeetest) {
clearInterval(check)
resolve()
}
}, 50)
setTimeout(() => {
clearInterval(check)
window.initGeetest ? resolve() : reject(new Error('SDK初始化失败'))
}, 5000)
}
script.onerror = () => reject(new Error('脚本加载失败'))
document.head.appendChild(script)
})
}, [])
// 初始化
const init = useCallback(async () => {
if (initedRef.current) return
initedRef.current = true
try {
setStatus('loading')
setErrorMsg('')
await loadScript()
const res = await getGeetestRegister()
if (!res.success || !res.data) {
throw new Error(res.message || '获取参数失败')
}
const { gt, challenge, success, new_captcha } = res.data
if (!gt || !challenge) {
throw new Error('参数不完整')
}
window.initGeetest?.(
{
gt,
challenge,
offline: success === 0,
new_captcha,
product: 'bind',
width: '100%',
lang: 'zh-cn'
},
(obj: any) => {
captchaObjRef.current = obj
obj.onReady(() => {
setStatus('ready')
})
obj.onSuccess(async () => {
const result = obj.getValidate()
if (!result) return
try {
const validateRes = await geetestValidate({
challenge: result.geetest_challenge,
validate: result.geetest_validate,
seccode: result.geetest_seccode
})
if (validateRes.success) {
setStatus('verified')
onSuccessRef.current({
challenge: result.geetest_challenge,
validate: result.geetest_validate,
seccode: result.geetest_seccode
})
} else {
setErrorMsg(validateRes.message || '验证失败')
setStatus('error')
onErrorRef.current?.(validateRes.message || '验证失败')
obj.reset()
}
} catch {
setErrorMsg('验证异常')
setStatus('error')
onErrorRef.current?.('验证异常')
obj.reset()
}
})
obj.onError(() => {
setErrorMsg('加载失败')
setStatus('error')
onErrorRef.current?.('加载失败')
})
obj.onClose(() => {
// 用户关闭,不处理
})
}
)
} catch (err: any) {
initedRef.current = false
setErrorMsg(err.message || '初始化失败')
setStatus('error')
onErrorRef.current?.(err.message || '初始化失败')
}
}, [loadScript])
useEffect(() => {
init()
return () => {
captchaObjRef.current = null
initedRef.current = false
}
}, [init])
const handleClick = () => {
if (disabled) return
if (status === 'error') {
initedRef.current = false
init()
return
}
if (status === 'ready' && captchaObjRef.current) {
captchaObjRef.current.verify()
}
}
const btnClass = `w-full h-10 px-4 rounded-lg border transition-all duration-200 text-sm font-medium
${status === 'verified'
? 'bg-green-50 border-green-300 text-green-700 dark:bg-green-900/20 dark:border-green-700 dark:text-green-400'
: status === 'error'
? 'bg-red-50 border-red-300 text-red-700 dark:bg-red-900/20 dark:border-red-700 dark:text-red-400 cursor-pointer'
: 'bg-slate-50 border-slate-200 text-slate-700 hover:bg-slate-100 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-700'
}
${(disabled || status === 'loading') ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`
return (
<div className={className}>
<button
type="button"
onClick={handleClick}
disabled={disabled || status === 'loading'}
className={btnClass}
>
{status === 'loading' && (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
...
</span>
)}
{status === 'ready' && buttonText}
{status === 'verified' && (
<span className="flex items-center justify-center gap-2">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</span>
)}
{status === 'error' && `${errorMsg},点击重试`}
</button>
</div>
)
}
export default GeetestCaptcha

View File

@ -11,7 +11,6 @@ import {
Truck,
Bell,
MessageCircle,
Search,
Settings,
UserCog,
FileText,
@ -22,6 +21,7 @@ import {
X,
PanelLeftClose,
PanelLeft,
AlertTriangle,
} from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { useUIStore } from '@/store/uiStore'
@ -40,12 +40,12 @@ const mainNavItems: NavItem[] = [
{ icon: Package, label: '商品管理', path: '/items' },
{ icon: ShoppingCart, label: '订单管理', path: '/orders' },
{ icon: MessageSquare, label: '自动回复', path: '/keywords' },
{ icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
// { icon: MessageCircle, label: '指定商品回复', path: '/item-replies' },
{ icon: CreditCard, label: '卡券管理', path: '/cards' },
{ icon: Truck, label: '自动发货', path: '/delivery' },
{ icon: Bell, label: '通知渠道', path: '/notification-channels' },
{ icon: MessageCircle, label: '消息通知', path: '/message-notifications' },
{ icon: Search, label: '商品搜索', path: '/item-search' },
// { icon: Search, label: '商品搜索', path: '/item-search' },
{ icon: Settings, label: '系统设置', path: '/settings' },
]
@ -57,6 +57,7 @@ const adminNavItems: NavItem[] = [
]
const bottomNavItems: NavItem[] = [
{ icon: AlertTriangle, label: '免责声明', path: '/disclaimer' },
{ icon: Info, label: '关于', path: '/about' },
]

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState, useRef } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { X, Home } from 'lucide-react'
import { create } from 'zustand'
@ -16,6 +16,9 @@ interface TabsStore {
activeTab: string
addTab: (tab: Tab) => void
removeTab: (path: string) => void
removeTabsToRight: (path: string) => void
removeTabsToLeft: (path: string) => void
removeAllTabs: () => void
setActiveTab: (path: string) => void
}
@ -37,6 +40,7 @@ const routeTitles: Record<string, string> = {
'/admin/logs': '系统日志',
'/admin/risk-logs': '风控日志',
'/admin/data': '数据管理',
'/disclaimer': '免责声明',
'/about': '关于',
}
@ -60,7 +64,6 @@ export const useTabsStore = create<TabsStore>()(
const { tabs, activeTab } = get()
const newTabs = tabs.filter(t => t.path !== path)
// 如果关闭的是当前标签,切换到最后一个标签
if (activeTab === path && newTabs.length > 0) {
set({ tabs: newTabs, activeTab: newTabs[newTabs.length - 1].path })
} else {
@ -68,6 +71,44 @@ export const useTabsStore = create<TabsStore>()(
}
},
removeTabsToRight: (path) => {
const { tabs, activeTab } = get()
const index = tabs.findIndex(t => t.path === path)
if (index === -1) return
const newTabs = tabs.slice(0, index + 1)
const activeIndex = tabs.findIndex(t => t.path === activeTab)
if (activeIndex > index) {
set({ tabs: newTabs, activeTab: path })
} else {
set({ tabs: newTabs })
}
},
removeTabsToLeft: (path) => {
const { tabs, activeTab } = get()
const index = tabs.findIndex(t => t.path === path)
if (index === -1) return
// 保留仪表盘和当前标签及右侧的标签
const newTabs = [tabs[0], ...tabs.slice(index).filter(t => t.path !== '/dashboard')]
const activeIndex = tabs.findIndex(t => t.path === activeTab)
if (activeIndex < index && activeTab !== '/dashboard') {
set({ tabs: newTabs, activeTab: path })
} else {
set({ tabs: newTabs })
}
},
removeAllTabs: () => {
set({
tabs: [{ path: '/dashboard', title: '仪表盘', closable: false }],
activeTab: '/dashboard'
})
},
setActiveTab: (path) => set({ activeTab: path }),
}),
{
@ -77,10 +118,24 @@ export const useTabsStore = create<TabsStore>()(
)
)
interface ContextMenuState {
visible: boolean
x: number
y: number
targetPath: string
}
export function TabsBar() {
const location = useLocation()
const navigate = useNavigate()
const { tabs, activeTab, addTab, removeTab, setActiveTab } = useTabsStore()
const { tabs, activeTab, addTab, removeTab, removeTabsToRight, removeTabsToLeft, removeAllTabs, setActiveTab } = useTabsStore()
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
visible: false,
x: 0,
y: 0,
targetPath: ''
})
const menuRef = useRef<HTMLDivElement>(null)
// 监听路由变化,自动添加标签
useEffect(() => {
@ -96,6 +151,18 @@ export function TabsBar() {
}
}, [location.pathname])
// 点击其他地方关闭右键菜单
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setContextMenu(prev => ({ ...prev, visible: false }))
}
}
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}, [])
const handleTabClick = (path: string) => {
setActiveTab(path)
navigate(path)
@ -105,7 +172,6 @@ export function TabsBar() {
e.stopPropagation()
removeTab(path)
// 如果关闭的是当前标签,导航到新的活动标签
if (activeTab === path) {
const remainingTabs = tabs.filter(t => t.path !== path)
if (remainingTabs.length > 0) {
@ -114,31 +180,113 @@ export function TabsBar() {
}
}
const handleContextMenu = (e: React.MouseEvent, path: string) => {
e.preventDefault()
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
targetPath: path
})
}
const handleCloseCurrentTab = () => {
const { targetPath } = contextMenu
if (targetPath !== '/dashboard') {
removeTab(targetPath)
if (activeTab === targetPath) {
navigate('/dashboard')
}
}
setContextMenu(prev => ({ ...prev, visible: false }))
}
const handleCloseRightTabs = () => {
removeTabsToRight(contextMenu.targetPath)
setContextMenu(prev => ({ ...prev, visible: false }))
}
const handleCloseLeftTabs = () => {
removeTabsToLeft(contextMenu.targetPath)
setContextMenu(prev => ({ ...prev, visible: false }))
}
const handleCloseAllTabs = () => {
removeAllTabs()
navigate('/dashboard')
setContextMenu(prev => ({ ...prev, visible: false }))
}
const targetIndex = tabs.findIndex(t => t.path === contextMenu.targetPath)
const hasRightTabs = targetIndex < tabs.length - 1
const hasLeftTabs = targetIndex > 1 || (targetIndex === 1 && tabs[0].path === '/dashboard')
return (
<div className="tabs-bar overflow-x-auto scrollbar-hide">
<div className="flex min-w-max">
{tabs.map((tab) => (
<div
key={tab.path}
onClick={() => handleTabClick(tab.path)}
className={cn(
activeTab === tab.path ? 'tab-item-active' : 'tab-item',
'whitespace-nowrap flex-shrink-0'
)}
>
{tab.path === '/dashboard' && <Home className="w-3.5 h-3.5" />}
<span className="text-xs sm:text-sm">{tab.title}</span>
{tab.closable && (
<button
onClick={(e) => handleTabClose(e, tab.path)}
className="tab-close"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
<>
<div className="tabs-bar overflow-x-auto scrollbar-hide">
<div className="flex min-w-max">
{tabs.map((tab) => (
<div
key={tab.path}
onClick={() => handleTabClick(tab.path)}
onContextMenu={(e) => handleContextMenu(e, tab.path)}
className={cn(
activeTab === tab.path ? 'tab-item-active' : 'tab-item',
'whitespace-nowrap flex-shrink-0'
)}
>
{tab.path === '/dashboard' && <Home className="w-3.5 h-3.5" />}
<span className="text-xs sm:text-sm">{tab.title}</span>
{tab.closable && (
<button
onClick={(e) => handleTabClose(e, tab.path)}
className="tab-close"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
</div>
</div>
{/* 右键菜单 */}
{contextMenu.visible && (
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-md py-0.5 text-xs"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={handleCloseCurrentTab}
disabled={contextMenu.targetPath === '/dashboard'}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCloseRightTabs}
disabled={!hasRightTabs}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={handleCloseLeftTabs}
disabled={!hasLeftTabs}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<div className="border-t border-gray-200 dark:border-gray-700 my-0.5" />
<button
onClick={handleCloseAllTabs}
className="w-full px-3 py-1 text-left hover:bg-gray-100 dark:hover:bg-gray-700 text-red-500"
>
</button>
</div>
)}
</>
)
}

View File

@ -24,7 +24,7 @@ function compareVersions(v1: string, v2: string): number {
export function About() {
const [previewImage, setPreviewImage] = useState<string | null>(null)
const [version, setVersion] = useState('v1.0.4')
const [version, setVersion] = useState('加载中...')
const [totalUsers, setTotalUsers] = useState(0)
// 更新检查相关状态
@ -35,13 +35,14 @@ export function About() {
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [showChangelogModal, setShowChangelogModal] = useState(false)
const [changelog, setChangelog] = useState<UpdateInfo[]>([])
const [changelogHtml, setChangelogHtml] = useState<string | null>(null)
const [loadingChangelog, setLoadingChangelog] = useState(false)
// 检查更新
const checkForUpdate = useCallback(async (showToast = false) => {
setCheckingUpdate(true)
try {
const response = await fetch('https://xianyu.zhinianblog.cn/index.php?action=getVersion')
const response = await fetch('/api/version/check')
const result = await response.json()
if (result.error) {
@ -51,7 +52,8 @@ export function About() {
return
}
const remoteVersion = result.version || result.latest_version
// 支持 {data: "v1.0.5"} 格式
const remoteVersion = result.data || result.version || result.latest_version
if (remoteVersion) {
setLatestVersion(remoteVersion)
setUpdateInfo({
@ -81,13 +83,27 @@ export function About() {
// 获取更新日志
const loadChangelog = useCallback(async () => {
setLoadingChangelog(true)
setChangelogHtml(null)
setChangelog([])
try {
const response = await fetch('https://xianyu.zhinianblog.cn/index.php?action=getChangelog')
const response = await fetch('/api/version/changelog')
const result = await response.json()
if (!result.error && result.changelog) {
if (result.error) {
console.error('获取更新日志失败:', result.message)
return
}
// 支持 {data: {updates: [...]}} 格式
if (result.data && result.data.updates && Array.isArray(result.data.updates)) {
// 将 updates 数组合并成 HTML 字符串
const htmlContent = result.data.updates.join('<br/>')
setChangelogHtml(htmlContent)
} else if (result.html) {
setChangelogHtml(result.html)
} else if (result.changelog) {
setChangelog(result.changelog)
} else if (!result.error && Array.isArray(result)) {
} else if (Array.isArray(result)) {
setChangelog(result)
}
} catch (error) {
@ -459,6 +475,11 @@ export function About() {
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
) : changelogHtml ? (
<div
className="changelog-html prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: changelogHtml }}
/>
) : changelog.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<FileText className="w-12 h-12 mx-auto mb-3 text-slate-300 dark:text-slate-600" />

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { FormEvent } from 'react'
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2, Clock, CheckCircle, MessageSquare, Bot } from 'lucide-react'
import { getAccountDetails, deleteAccount, updateAccountCookie, updateAccountStatus, updateAccountRemark, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin, updateAccountAutoConfirm, updateAccountPauseDuration, getAllAIReplySettings, getAIReplySettings, updateAIReplySettings, type AIReplySettings } from '@/api/accounts'
import { Plus, RefreshCw, QrCode, Key, Edit2, Trash2, Power, PowerOff, X, Loader2, Clock, CheckCircle, MessageSquare, Bot, Eye, EyeOff } from 'lucide-react'
import { getAccountDetails, deleteAccount, updateAccountCookie, updateAccountStatus, updateAccountRemark, addAccount, generateQRLogin, checkQRLoginStatus, passwordLogin, updateAccountAutoConfirm, updateAccountPauseDuration, getAllAIReplySettings, getAIReplySettings, updateAIReplySettings, updateAccountLoginInfo, type AIReplySettings } from '@/api/accounts'
import { getKeywords, getDefaultReply, updateDefaultReply } from '@/api/keywords'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
@ -51,6 +51,11 @@ export function Accounts() {
const [editAutoConfirm, setEditAutoConfirm] = useState(false)
const [editPauseDuration, setEditPauseDuration] = useState(0)
const [editSaving, setEditSaving] = useState(false)
// 登录信息
const [editUsername, setEditUsername] = useState('')
const [editLoginPassword, setEditLoginPassword] = useState('')
const [editShowBrowser, setEditShowBrowser] = useState(false)
const [showLoginPassword, setShowLoginPassword] = useState(false)
// AI设置状态
const [aiSettingsAccount, setAiSettingsAccount] = useState<AccountWithKeywordCount | null>(null)
@ -316,6 +321,10 @@ export function Accounts() {
setEditCookie(account.cookie || '')
setEditAutoConfirm(account.auto_confirm || false)
setEditPauseDuration(account.pause_duration || 0)
setEditUsername(account.username || '')
setEditLoginPassword(account.login_password || '')
setEditShowBrowser(account.show_browser || false)
setShowLoginPassword(false)
setActiveModal('edit')
}
@ -348,6 +357,20 @@ export function Accounts() {
promises.push(updateAccountPauseDuration(editingAccount.id, editPauseDuration))
}
// 更新登录信息
const loginInfoChanged =
editUsername !== (editingAccount.username || '') ||
editLoginPassword !== (editingAccount.login_password || '') ||
editShowBrowser !== (editingAccount.show_browser || false)
if (loginInfoChanged) {
promises.push(updateAccountLoginInfo(editingAccount.id, {
username: editUsername,
login_password: editLoginPassword,
show_browser: editShowBrowser,
}))
}
await Promise.all(promises)
addToast({ type: 'success', message: '账号信息已更新' })
closeModal()
@ -368,7 +391,7 @@ export function Accounts() {
// 加载当前默认回复
try {
const result = await getDefaultReply(account.id)
setDefaultReplyContent(result.default_reply || '')
setDefaultReplyContent(result.reply_content || '')
} catch {
// ignore
}
@ -379,7 +402,7 @@ export function Accounts() {
try {
setDefaultReplySaving(true)
await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent)
await updateDefaultReply(defaultReplyAccount.id, defaultReplyContent, true)
addToast({ type: 'success', message: '默认回复已保存' })
closeModal()
} catch {
@ -924,6 +947,67 @@ export function Accounts() {
</p>
</div>
{/* 登录信息管理 */}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4 mt-2">
<h3 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-3 flex items-center gap-2">
<Key className="w-4 h-4 text-blue-500" />
</h3>
<div className="space-y-3">
<div className="input-group">
<label className="input-label text-xs"></label>
<input
type="text"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
className="input-ios"
placeholder="手机号或用户名"
/>
</div>
<div className="input-group">
<label className="input-label text-xs"></label>
<div className="relative">
<input
type={showLoginPassword ? 'text' : 'password'}
value={editLoginPassword}
onChange={(e) => setEditLoginPassword(e.target.value)}
className="input-ios pr-10"
placeholder="登录密码"
/>
<button
type="button"
onClick={() => setShowLoginPassword(!showLoginPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{showLoginPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-700 dark:text-slate-300"></p>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
</div>
<button
type="button"
onClick={() => setEditShowBrowser(!editShowBrowser)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
editShowBrowser ? '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 ${
editShowBrowser ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
Cookie过期时系统可自动重新登录
</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 pt-2">
AI回复和默认回复设置请在"自动回复"
</p>

View File

@ -55,9 +55,13 @@ export function RiskLogs() {
const handleClear = async () => {
if (!confirm('确定要清空所有风控日志吗?此操作不可恢复!')) return
try {
await clearRiskLogs()
addToast({ type: 'success', message: '日志已清空' })
loadLogs()
const result = await clearRiskLogs()
if (result.success) {
addToast({ type: 'success', message: '日志已清空' })
loadLogs()
} else {
addToast({ type: 'error', message: result.message || '清空失败' })
}
} catch {
addToast({ type: 'error', message: '清空失败' })
}
@ -125,14 +129,18 @@ export function RiskLogs() {
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-8 text-slate-500 dark:text-slate-400">
<td colSpan={8} className="text-center py-8 text-slate-500 dark:text-slate-400">
<div className="flex flex-col items-center gap-2">
<ShieldAlert className="w-12 h-12 text-slate-300 dark:text-slate-600" />
<p></p>
@ -146,17 +154,49 @@ export function RiskLogs() {
<td>
<span className="badge-danger">{log.risk_type}</span>
</td>
<td className="max-w-[300px] text-slate-500 dark:text-slate-400">
<td className="max-w-[200px] text-slate-500 dark:text-slate-400">
<span
className="block truncate cursor-help"
title={log.message}
>
{log.message}
{log.message || '-'}
</span>
</td>
<td className="text-slate-500 dark:text-slate-400 text-sm">
<td className="max-w-[200px] text-slate-500 dark:text-slate-400">
<span
className="block truncate cursor-help"
title={log.processing_result}
>
{log.processing_result || '-'}
</span>
</td>
<td>
<span className={`text-xs px-2 py-1 rounded ${
log.processing_status === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
log.processing_status === 'failed' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
log.processing_status === 'processing' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}>
{log.processing_status === 'success' ? '成功' :
log.processing_status === 'failed' ? '失败' :
log.processing_status === 'processing' ? '处理中' :
log.processing_status || '-'}
</span>
</td>
<td className="max-w-[150px] text-red-500 dark:text-red-400">
<span
className="block truncate cursor-help"
title={log.error_message || ''}
>
{log.error_message || '-'}
</span>
</td>
<td className="text-slate-500 dark:text-slate-400 text-sm whitespace-nowrap">
{new Date(log.created_at).toLocaleString()}
</td>
<td className="text-slate-500 dark:text-slate-400 text-sm whitespace-nowrap">
{log.updated_at ? new Date(log.updated_at).toLocaleString() : '-'}
</td>
</tr>
))
)}

View File

@ -2,11 +2,12 @@ import { useState, useEffect } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { MessageSquare, User, Lock, Mail, KeyRound, Eye, EyeOff, Sun, Moon } from 'lucide-react'
import { login, verifyToken, getRegistrationStatus, getLoginInfoStatus, generateCaptcha, verifyCaptcha, sendVerificationCode } from '@/api/auth'
import { login, verifyToken, getRegistrationStatus, getLoginInfoStatus, generateCaptcha, verifyCaptcha, sendVerificationCode, getLoginCaptchaStatus } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { useUIStore } from '@/store/uiStore'
import { cn } from '@/utils/cn'
import { ButtonLoading } from '@/components/common/Loading'
import { GeetestCaptcha, type GeetestResult } from '@/components/common/GeetestCaptcha'
type LoginType = 'username' | 'email-password' | 'email-code'
@ -21,6 +22,7 @@ export function Login() {
const [registrationEnabled, setRegistrationEnabled] = useState(true)
const [showDefaultLogin, setShowDefaultLogin] = useState(true)
const [isDark, setIsDark] = useState(false)
const [loginCaptchaEnabled, setLoginCaptchaEnabled] = useState(true)
// Form states
const [username, setUsername] = useState('')
@ -36,12 +38,21 @@ export function Login() {
const [sessionId] = useState(() => `session_${Math.random().toString(36).substr(2, 9)}_${Date.now()}`)
const [captchaVerified, setCaptchaVerified] = useState(false)
const [countdown, setCountdown] = useState(0)
const [emailError, setEmailError] = useState('')
const [verifying, setVerifying] = useState(false)
// 极验滑动验证码状态
const [geetestResult, setGeetestResult] = useState<GeetestResult | null>(null)
const [geetestKey, setGeetestKey] = useState(0)
// 重置滑动验证码
const resetGeetest = () => {
setGeetestResult(null)
setGeetestKey((k) => k + 1)
}
// 初始化主题
useEffect(() => {
const savedTheme = localStorage.getItem('theme')
// 默认使用白天模式
const shouldBeDark = savedTheme === 'dark'
setIsDark(shouldBeDark)
document.documentElement.classList.toggle('dark', shouldBeDark)
@ -54,46 +65,36 @@ export function Login() {
localStorage.setItem('theme', newIsDark ? 'dark' : 'light')
}
// Check if already logged in
useEffect(() => {
if (isAuthenticated) {
navigate('/dashboard')
return
}
const token = localStorage.getItem('auth_token')
if (token) {
verifyToken()
.then((result) => {
if (result.authenticated) {
navigate('/dashboard')
}
})
.catch(() => {
localStorage.removeItem('auth_token')
if (result.authenticated) navigate('/dashboard')
})
.catch(() => localStorage.removeItem('auth_token'))
}
}, [isAuthenticated, navigate])
// Load initial states
useEffect(() => {
getRegistrationStatus()
.then((result) => setRegistrationEnabled(result.enabled))
.catch(() => {})
getLoginInfoStatus()
.then((result) => setShowDefaultLogin(result.enabled))
.catch(() => {})
getRegistrationStatus().then((result) => setRegistrationEnabled(result.enabled)).catch(() => {})
getLoginInfoStatus().then((result) => setShowDefaultLogin(result.enabled)).catch(() => {})
getLoginCaptchaStatus().then((result) => {
console.log('getLoginCaptchaStatus result:', result)
setLoginCaptchaEnabled(result.enabled)
}).catch((err) => {
console.error('getLoginCaptchaStatus error:', err)
})
}, [])
// Load captcha when switching to email-code
useEffect(() => {
if (loginType === 'email-code') {
loadCaptcha()
}
if (loginType === 'email-code') loadCaptcha()
}, [loginType])
// Countdown timer
useEffect(() => {
if (countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
@ -101,6 +102,32 @@ export function Login() {
}
}, [countdown])
useEffect(() => {
if (captchaCode.length === 4 && !captchaVerified && !verifying && loginType === 'email-code') {
handleVerifyCaptchaAuto()
}
}, [captchaCode])
const handleVerifyCaptchaAuto = async () => {
if (captchaCode.length !== 4 || verifying) return
setVerifying(true)
try {
const result = await verifyCaptcha(sessionId, captchaCode)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
} else {
setCaptchaVerified(false)
loadCaptcha()
addToast({ type: 'error', message: '验证码错误' })
}
} catch {
addToast({ type: 'error', message: '验证失败' })
} finally {
setVerifying(false)
}
}
const loadCaptcha = async () => {
try {
const result = await generateCaptcha(sessionId)
@ -114,53 +141,8 @@ export function Login() {
}
}
const handleVerifyCaptcha = async (code?: string) => {
const codeToVerify = code || captchaCode
if (codeToVerify.length !== 4) return
try {
const result = await verifyCaptcha(sessionId, codeToVerify)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
} else {
setCaptchaVerified(false)
loadCaptcha()
addToast({ type: 'error', message: '验证码错误' })
}
} catch {
addToast({ type: 'error', message: '验证失败' })
}
}
// 邮箱格式校验
const isValidEmail = (emailStr: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailStr)
// 邮箱输入处理
const handleEmailChange = (value: string) => {
setEmailForCode(value)
if (value && !isValidEmail(value)) {
setEmailError('请输入正确的邮箱格式')
} else {
setEmailError('')
}
}
const handleSendCode = async () => {
if (!emailForCode) {
setEmailError('请输入邮箱地址')
return
}
if (!isValidEmail(emailForCode)) {
setEmailError('请输入正确的邮箱格式')
return
}
if (!captchaVerified) {
addToast({ type: 'warning', message: '请先完成图形验证码验证' })
return
}
if (countdown > 0) return
if (!captchaVerified || !emailForCode || countdown > 0) return
try {
const result = await sendVerificationCode(emailForCode, 'login', sessionId)
if (result.success) {
@ -174,375 +156,102 @@ export function Login() {
}
}
useEffect(() => { resetGeetest() }, [loginType])
const handleGeetestSuccess = (result: GeetestResult) => { setGeetestResult(result) }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
let loginData = {}
let loginData: any = {}
if (loginType === 'username') {
if (!username || !password) {
addToast({ type: 'error', message: '请输入用户名和密码' })
return
}
loginData = { username, password }
if (!username || !password) { addToast({ type: 'error', message: '请输入用户名和密码' }); setLoading(false); return }
if (loginCaptchaEnabled && !geetestResult) { addToast({ type: 'error', message: '请完成滑动验证' }); setLoading(false); return }
loginData = { username, password, geetest_challenge: geetestResult?.challenge, geetest_validate: geetestResult?.validate, geetest_seccode: geetestResult?.seccode }
} else if (loginType === 'email-password') {
if (!email || !emailPassword) {
addToast({ type: 'error', message: '请输入邮箱和密码' })
return
}
loginData = { email, password: emailPassword }
if (!email || !emailPassword) { addToast({ type: 'error', message: '请输入邮箱和密码' }); setLoading(false); return }
if (loginCaptchaEnabled && !geetestResult) { addToast({ type: 'error', message: '请完成滑动验证' }); setLoading(false); return }
loginData = { email, password: emailPassword, geetest_challenge: geetestResult?.challenge, geetest_validate: geetestResult?.validate, geetest_seccode: geetestResult?.seccode }
} else {
if (!emailForCode || !verificationCode) {
addToast({ type: 'error', message: '请输入邮箱和验证码' })
return
}
if (!emailForCode || !verificationCode) { addToast({ type: 'error', message: '请输入邮箱和验证码' }); setLoading(false); return }
loginData = { email: emailForCode, verification_code: verificationCode }
}
const result = await login(loginData)
if (result.success && result.token) {
setAuth(result.token, {
user_id: result.user_id!,
username: result.username!,
is_admin: result.is_admin!,
})
setAuth(result.token, { user_id: result.user_id!, username: result.username!, is_admin: result.is_admin! })
addToast({ type: 'success', message: '登录成功' })
navigate('/dashboard')
} else {
addToast({ type: 'error', message: result.message || '登录失败' })
resetGeetest()
}
} catch {
addToast({ type: 'error', message: '登录失败,请检查网络连接' })
resetGeetest()
} finally {
setLoading(false)
}
}
const fillDefaultCredentials = () => {
setLoginType('username')
setUsername('admin')
setPassword('admin123')
}
const fillDefaultCredentials = () => { setLoginType('username'); setUsername('admin'); setPassword('admin123') }
return (
<div className="min-h-screen flex bg-slate-50 dark:bg-slate-900 transition-colors duration-200">
{/* 右上角主题切换 */}
<button
onClick={toggleTheme}
className="fixed top-4 right-4 z-50 p-2.5 rounded-lg
bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700
text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white
transition-colors duration-150"
title={isDark ? '切换到亮色模式' : '切换到暗色模式'}
>
<button onClick={toggleTheme} className="fixed top-4 right-4 z-50 p-2.5 rounded-lg bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white transition-colors duration-150" title={isDark ? '切换到亮色模式' : '切换到暗色模式'}>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* Left side - Branding */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
className="hidden lg:flex lg:w-1/2 bg-slate-900 dark:bg-slate-950 relative overflow-hidden"
>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.5 }} className="hidden lg:flex lg:w-1/2 bg-slate-900 dark:bg-slate-950 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-blue-600/20 to-transparent" />
<div className="relative z-10 flex flex-col justify-center px-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
className="flex items-center gap-3 mb-8"
>
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center">
<MessageSquare className="w-6 h-6 text-white" />
</div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2, duration: 0.5 }} className="flex items-center gap-3 mb-8">
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center"><MessageSquare className="w-6 h-6 text-white" /></div>
<span className="text-2xl font-bold text-white"></span>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
className="text-4xl font-bold text-white mb-4 leading-tight"
>
<br />
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="text-slate-400 text-lg max-w-md"
>
</motion.p>
<motion.h1 initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3, duration: 0.5 }} className="text-4xl font-bold text-white mb-4 leading-tight"><br /></motion.h1>
<motion.p initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4, duration: 0.5 }} className="text-slate-400 text-lg max-w-md"></motion.p>
</div>
{/* Decorative circles */}
<div className="absolute -bottom-32 -left-32 w-96 h-96 rounded-full bg-blue-600/10" />
<div className="absolute -top-32 -right-32 w-96 h-96 rounded-full bg-blue-600/5" />
</motion.div>
{/* Right side - Login form */}
<div className="flex-1 flex items-center justify-center p-4 sm:p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="w-full max-w-md"
>
{/* Mobile header */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.4 }}
className="lg:hidden text-center mb-8"
>
<div className="w-12 h-12 rounded-xl bg-blue-500 text-white mx-auto mb-4 flex items-center justify-center">
<MessageSquare className="w-6 h-6" />
</div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }} className="w-full max-w-md">
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1, duration: 0.4 }} className="lg:hidden text-center mb-8">
<div className="w-12 h-12 rounded-xl bg-blue-500 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-slate-900 dark:text-white"></h1>
</motion.div>
{/* Login Card */}
<div className="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-200 dark:border-slate-700 p-5 sm:p-8">
<div className="mb-6">
<h2 className="text-xl vben-card-title text-slate-900 dark:text-white"></h2>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1"></p>
</div>
{/* Login type tabs */}
<div className="flex border-b border-slate-200 dark:border-slate-700 mb-4 sm:mb-6 overflow-x-auto scrollbar-hide">
{[
{ type: 'username' as const, label: '账号登录' },
{ type: 'email-password' as const, label: '邮箱密码' },
{ type: 'email-code' as const, label: '验证码' },
].map((tab) => (
<button
key={tab.type}
onClick={() => setLoginType(tab.type)}
className={cn(
'px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap flex-shrink-0',
loginType === tab.type
? 'text-blue-600 dark:text-blue-400 border-blue-600 dark:border-blue-400'
: 'text-slate-500 dark:text-slate-400 border-transparent hover:text-slate-700 dark:hover:text-slate-300'
)}
>
{tab.label}
</button>
{[{ type: 'username' as const, label: '账号登录' }, { type: 'email-password' as const, label: '邮箱密码' }, { type: 'email-code' as const, label: '验证码' }].map((tab) => (
<button key={tab.type} onClick={() => setLoginType(tab.type)} className={cn('px-3 sm:px-4 py-2 sm:py-2.5 text-xs sm:text-sm font-medium border-b-2 -mb-px transition-colors whitespace-nowrap flex-shrink-0', loginType === tab.type ? 'text-blue-600 dark:text-blue-400 border-blue-600 dark:border-blue-400' : 'text-slate-500 dark:text-slate-400 border-transparent hover:text-slate-700 dark:hover:text-slate-300')}>{tab.label}</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{/* Username login */}
{loginType === '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" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
className="input-ios pl-9"
/>
</div>
</div>
<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" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
className="input-ios pl-9 pr-9"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</>
)}
{/* Email password login */}
{loginType === 'email-password' && (
<>
<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" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className="input-ios pl-9"
/>
</div>
</div>
<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" />
<input
type={showPassword ? 'text' : 'password'}
value={emailPassword}
onChange={(e) => setEmailPassword(e.target.value)}
placeholder="请输入密码"
className="input-ios pl-9 pr-9"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</>
)}
{/* Email code login */}
{loginType === 'email-code' && (
<>
<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" />
<input
type="email"
value={emailForCode}
onChange={(e) => handleEmailChange(e.target.value)}
placeholder="name@example.com"
className={cn('input-ios pl-9', emailError && 'border-red-500 focus:border-red-500')}
/>
</div>
{emailError && <p className="text-xs text-red-500 mt-1">{emailError}</p>}
</div>
{/* Captcha */}
<div className="input-group">
<label className="input-label"></label>
<div className="flex gap-2">
<input
type="text"
value={captchaCode}
onChange={(e) => {
const val = e.target.value.toUpperCase()
setCaptchaCode(val)
// 输入4位后自动验证
if (val.length === 4 && !captchaVerified) {
handleVerifyCaptcha(val)
}
}}
placeholder="输入验证码"
maxLength={4}
className={cn(
'input-ios flex-1',
captchaVerified && 'border-green-500 bg-green-50 dark:bg-green-900/20'
)}
disabled={captchaVerified}
/>
<img
src={captchaImage}
alt="验证码"
onClick={loadCaptcha}
className="h-[38px] rounded border border-gray-300 cursor-pointer hover:opacity-80 transition-opacity"
/>
</div>
<p className={cn(
'text-xs',
captchaVerified ? 'text-green-600' : 'text-gray-400'
)}>
{captchaVerified ? '✓ 验证成功,可以发送邮箱验证码' : '输入4位验证码后自动验证'}
</p>
</div>
{/* Email code */}
<div className="input-group">
<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" />
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="6位数字验证码"
maxLength={6}
className="input-ios pl-9"
/>
</div>
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0}
className="btn-ios-secondary whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '发送'}
</button>
</div>
</div>
</>
)}
{/* Submit button */}
<button
type="submit"
disabled={loading}
className="w-full btn-ios-primary"
>
{loading ? <ButtonLoading /> : '登 录'}
</button>
{loginType === '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" /><input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="请输入用户名" className="input-ios pl-9" /></div></div>
<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" /><input type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="请输入密码" className="input-ios pl-9 pr-9" /><button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</button></div></div>
{loginCaptchaEnabled && (<div className="input-group"><label className="input-label"></label><GeetestCaptcha key={`username-${geetestKey}`} onSuccess={handleGeetestSuccess} onError={(err) => addToast({ type: 'error', message: err })} disabled={loading} /></div>)}
</>)}
{loginType === 'email-password' && (<>
<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" /><input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="name@example.com" className="input-ios pl-9" /></div></div>
<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" /><input type={showPassword ? 'text' : 'password'} value={emailPassword} onChange={(e) => setEmailPassword(e.target.value)} placeholder="请输入密码" className="input-ios pl-9 pr-9" /><button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}</button></div></div>
{loginCaptchaEnabled && (<div className="input-group"><label className="input-label"></label><GeetestCaptcha key={`email-${geetestKey}`} onSuccess={handleGeetestSuccess} onError={(err) => addToast({ type: 'error', message: err })} disabled={loading} /></div>)}
</>)}
{loginType === 'email-code' && (<>
<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" /><input type="email" value={emailForCode} onChange={(e) => setEmailForCode(e.target.value)} placeholder="name@example.com" className="input-ios pl-9" /></div></div>
<div className="input-group"><label className="input-label"></label><div className="flex gap-2"><input type="text" value={captchaCode} onChange={(e) => setCaptchaCode(e.target.value)} placeholder="输入验证码" maxLength={4} className="input-ios flex-1" disabled={captchaVerified} /><img src={captchaImage} alt="验证码" onClick={loadCaptcha} className="h-[38px] rounded border border-gray-300 cursor-pointer hover:opacity-80 transition-opacity" /></div><p className={cn('text-xs', captchaVerified ? 'text-green-600' : verifying ? 'text-blue-500' : 'text-gray-400')}>{captchaVerified ? '✓ 验证成功' : verifying ? '验证中...' : '点击图片更换验证码'}</p></div>
<div className="input-group"><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" /><input type="text" value={verificationCode} onChange={(e) => setVerificationCode(e.target.value)} placeholder="6位数字验证码" maxLength={6} className="input-ios pl-9" /></div><button type="button" onClick={handleSendCode} disabled={!captchaVerified || !emailForCode || countdown > 0} className="btn-ios-secondary whitespace-nowrap">{countdown > 0 ? `${countdown}s` : '发送'}</button></div></div>
</>)}
<button type="submit" disabled={loading} className="w-full btn-ios-primary">{loading ? <ButtonLoading /> : '登 录'}</button>
</form>
{/* Register link */}
{registrationEnabled && (
<p className="text-center mt-6 text-slate-500 dark:text-slate-400 text-sm">
{' '}
<Link to="/register" className="text-blue-600 dark:text-blue-400 font-medium hover:text-blue-700 dark:hover:text-blue-300">
</Link>
</p>
)}
{/* Default credentials */}
{showDefaultLogin && (
<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-700">
<button
type="button"
onClick={fillDefaultCredentials}
className="w-full flex items-center justify-between p-3 rounded-md
bg-slate-50 dark:bg-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-700
transition-colors text-sm"
>
<div className="text-left">
<p className="text-slate-500 dark:text-slate-400"></p>
<p className="text-slate-900 dark:text-white font-medium">admin / admin123</p>
</div>
<span className="text-blue-600 dark:text-blue-400"> </span>
</button>
</div>
)}
{registrationEnabled && (<p className="text-center mt-6 text-slate-500 dark:text-slate-400 text-sm">{' '}<Link to="/register" className="text-blue-600 dark:text-blue-400 font-medium hover:text-blue-700 dark:hover:text-blue-300"></Link></p>)}
{showDefaultLogin && (<div className="mt-6 pt-6 border-t border-slate-100 dark:border-slate-700"><button type="button" onClick={fillDefaultCredentials} className="w-full flex items-center justify-between p-3 rounded-md bg-slate-50 dark:bg-slate-700/50 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors text-sm"><div className="text-left"><p className="text-slate-500 dark:text-slate-400"></p><p className="text-slate-900 dark:text-white font-medium">admin / admin123</p></div><span className="text-blue-600 dark:text-blue-400"> </span></button></div>)}
</div>
{/* Footer */}
<p className="text-center mt-6 text-slate-400 dark:text-slate-500 text-xs">
© {new Date().getFullYear()} ·
<a href="https://www.hsykj.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400 ml-1 transition-colors">
www.hsykj.com
</a>
</p>
<p className="text-center mt-6 text-slate-400 dark:text-slate-500 text-xs">© {new Date().getFullYear()} · <a href="https://www.hsykj.com" target="_blank" rel="noopener noreferrer" className="hover:text-blue-600 dark:hover:text-blue-400 ml-1 transition-colors">www.hsykj.com</a></p>
</motion.div>
</div>
</div>

View File

@ -27,7 +27,7 @@ export function Register() {
const [sessionId] = useState(() => `session_${Math.random().toString(36).substr(2, 9)}_${Date.now()}`)
const [captchaVerified, setCaptchaVerified] = useState(false)
const [countdown, setCountdown] = useState(0)
const [emailError, setEmailError] = useState('')
const [verifying, setVerifying] = useState(false)
useEffect(() => {
getRegistrationStatus()
@ -35,10 +35,11 @@ export function Register() {
setRegistrationEnabled(result.enabled)
if (!result.enabled) {
addToast({ type: 'warning', message: '注册功能已关闭' })
setTimeout(() => navigate('/login'), 1500)
}
})
.catch(() => {})
}, [])
}, [navigate, addToast])
useEffect(() => {
loadCaptcha()
@ -51,6 +52,33 @@ export function Register() {
}
}, [countdown])
// 自动验证图形验证码
useEffect(() => {
if (captchaCode.length === 4 && !captchaVerified && !verifying) {
handleVerifyCaptchaAuto()
}
}, [captchaCode])
const handleVerifyCaptchaAuto = async () => {
if (captchaCode.length !== 4 || verifying) return
setVerifying(true)
try {
const result = await verifyCaptcha(sessionId, captchaCode)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
} else {
setCaptchaVerified(false)
loadCaptcha()
addToast({ type: 'error', message: '验证码错误' })
}
} catch {
addToast({ type: 'error', message: '验证失败' })
} finally {
setVerifying(false)
}
}
const loadCaptcha = async () => {
try {
const result = await generateCaptcha(sessionId)
@ -64,45 +92,36 @@ export function Register() {
}
}
const handleVerifyCaptcha = async (code?: string) => {
const codeToVerify = code || captchaCode
if (codeToVerify.length !== 4) return
try {
const result = await verifyCaptcha(sessionId, codeToVerify)
if (result.success) {
setCaptchaVerified(true)
addToast({ type: 'success', message: '验证码验证成功' })
} else {
setCaptchaVerified(false)
loadCaptcha()
addToast({ type: 'error', message: '验证码错误' })
}
} catch {
addToast({ type: 'error', message: '验证失败' })
}
}
// 邮箱格式校验
const isValidEmail = (emailStr: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailStr)
// 邮箱输入处理
const handleEmailChange = (value: string) => {
setEmail(value)
if (value && !isValidEmail(value)) {
setEmailError('请输入正确的邮箱格式')
} else {
setEmailError('')
}
}
const handleSendCode = async () => {
if (!email) {
setEmailError('请输入邮箱地址')
// 验证表单数据
if (!username.trim()) {
addToast({ type: 'warning', message: '请先输入用户名' })
return
}
if (!isValidEmail(email)) {
setEmailError('请输入正确的邮箱格式')
if (!email.trim()) {
addToast({ type: 'warning', message: '请先输入邮箱地址' })
return
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
addToast({ type: 'warning', message: '请输入正确的邮箱格式' })
return
}
if (!password) {
addToast({ type: 'warning', message: '请先输入密码' })
return
}
if (password.length < 6) {
addToast({ type: 'warning', message: '密码长度至少6位' })
return
}
if (!confirmPassword) {
addToast({ type: 'warning', message: '请先确认密码' })
return
}
if (password !== confirmPassword) {
addToast({ type: 'warning', message: '两次输入的密码不一致' })
return
}
if (!captchaVerified) {
@ -159,8 +178,10 @@ export function Register() {
} else {
addToast({ type: 'error', message: result.message || '注册失败' })
}
} catch {
addToast({ type: 'error', message: '注册失败,请检查网络连接' })
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMsg = err?.response?.data?.detail || err?.response?.data?.message || '注册失败,请检查网络连接'
addToast({ type: 'error', message: errorMsg })
} finally {
setLoading(false)
}
@ -221,12 +242,11 @@ export function Register() {
<input
type="email"
value={email}
onChange={(e) => handleEmailChange(e.target.value)}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
className={cn('input-ios pl-9', emailError && 'border-red-500 focus:border-red-500')}
className="input-ios pl-9"
/>
</div>
{emailError && <p className="text-xs text-red-500 mt-1">{emailError}</p>}
</div>
{/* Password */}
@ -273,20 +293,11 @@ export function Register() {
<input
type="text"
value={captchaCode}
onChange={(e) => {
const val = e.target.value.toUpperCase()
setCaptchaCode(val)
if (val.length === 4 && !captchaVerified) {
handleVerifyCaptcha(val)
}
}}
disabled={captchaVerified}
onChange={(e) => setCaptchaCode(e.target.value)}
placeholder="输入验证码"
maxLength={4}
className={cn(
'input-ios flex-1',
captchaVerified && 'border-green-500 bg-green-50 dark:bg-green-900/20',
)}
className="input-ios flex-1"
disabled={captchaVerified}
/>
<img
src={captchaImage}
@ -297,9 +308,9 @@ export function Register() {
</div>
<p className={cn(
'text-xs',
captchaVerified ? 'text-green-600 dark:text-green-400' : 'text-slate-400'
captchaVerified ? 'text-green-600 dark:text-green-400' : verifying ? 'text-blue-500' : 'text-slate-400'
)}>
{captchaVerified ? '✓ 验证成功,可以发送邮箱验证码' : '输入4位验证码后自动验证'}
{captchaVerified ? '✓ 验证成功' : verifying ? '验证中...' : '点击图片更换验证码'}
</p>
</div>
@ -321,7 +332,7 @@ export function Register() {
<button
type="button"
onClick={handleSendCode}
disabled={countdown > 0}
disabled={!captchaVerified || !email || countdown > 0}
className="btn-ios-secondary whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '发送'}

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import type { FormEvent, ChangeEvent } from 'react'
import { Ticket, RefreshCw, Plus, Trash2, X, Loader2, Power, PowerOff, Edit2 } from 'lucide-react'
import { Ticket, RefreshCw, Plus, Trash2, X, Loader2, Power, PowerOff, Edit2, Image } from 'lucide-react'
import { getCards, deleteCard, createCard, updateCard, type CardData } from '@/api/cards'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
@ -109,6 +109,10 @@ export function Cards() {
const [submitting, setSubmitting] = useState(false)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// 图片预览弹窗状态
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false)
const [previewImageUrl, setPreviewImageUrl] = useState('')
const loadCards = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
@ -418,9 +422,29 @@ export function Cards() {
</span>
</td>
<td>
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded max-w-[200px] truncate block">
{card.text_content || card.data_content?.split('\n')[0] || card.api_config?.url || card.image_url || '-'}
</code>
{card.type === 'image' ? (
card.image_url ? (
<button
onClick={() => {
setPreviewImageUrl(card.image_url || '')
setIsImagePreviewOpen(true)
}}
className="px-2 py-1 text-xs font-medium bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400 rounded transition-colors flex items-center gap-1"
>
<Image className="w-3 h-3" />
</button>
) : (
<span className="text-gray-400 text-sm"></span>
)
) : (
<code className="text-xs bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded max-w-[200px] truncate block">
{card.type === 'text' && (card.text_content || '-')}
{card.type === 'data' && (card.data_content ? `剩余 ${card.data_content.split('\n').filter((line: string) => line.trim()).length}` : '-')}
{card.type === 'api' && (card.api_config?.url || '-')}
{!['text', 'data', 'api', 'image'].includes(card.type) && '-'}
</code>
)}
</td>
<td>{card.delay_seconds || 0}</td>
<td>
@ -748,6 +772,30 @@ export function Cards() {
</div>
</div>
)}
{/* 图片预览弹窗 */}
{isImagePreviewOpen && (
<div className="modal-overlay" style={{ zIndex: 70 }} onClick={() => setIsImagePreviewOpen(false)}>
<div className="modal-content max-w-4xl p-4" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={() => setIsImagePreviewOpen(false)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="flex justify-center">
<img
src={previewImageUrl}
alt="预览"
className="max-w-full max-h-[70vh] object-contain rounded-lg"
/>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -22,7 +22,6 @@ export function Delivery() {
const [editingRule, setEditingRule] = useState<DeliveryRule | null>(null)
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)
@ -85,7 +84,6 @@ export function Delivery() {
setEditingRule(null)
setFormKeyword('')
setFormCardId('')
setFormDeliveryCount(1)
setFormDescription('')
setFormEnabled(true)
setIsModalOpen(true)
@ -95,7 +93,6 @@ export function Delivery() {
setEditingRule(rule)
setFormKeyword(rule.keyword)
setFormCardId(String(rule.card_id))
setFormDeliveryCount(rule.delivery_count)
setFormDescription(rule.description || '')
setFormEnabled(rule.enabled)
setIsModalOpen(true)
@ -122,7 +119,7 @@ export function Delivery() {
const data = {
keyword: formKeyword.trim(),
card_id: Number(formCardId),
delivery_count: formDeliveryCount,
delivery_count: 1, // 固定为1
description: formDescription || undefined,
enabled: formEnabled,
}
@ -189,7 +186,7 @@ export function Delivery() {
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
@ -206,50 +203,62 @@ export function Delivery() {
</td>
</tr>
) : (
rules.map((rule) => (
<tr key={rule.id}>
<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>
) : (
<span className="badge-danger"></span>
)}
</td>
<td>
<div className="">
<button
onClick={() => handleToggleEnabled(rule)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={rule.enabled ? '禁用' : '启用'}
>
{rule.enabled ? (
<PowerOff className="w-4 h-4 text-amber-500" />
) : (
<Power className="w-4 h-4 text-emerald-500" />
)}
</button>
<button
onClick={() => openEditModal(rule)}
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(rule.id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
))
rules.map((rule) => {
// 查找关联的卡券以获取规格信息
const relatedCard = cards.find(c => c.id === rule.card_id)
return (
<tr key={rule.id}>
<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>
{relatedCard?.is_multi_spec ? (
<span className="text-xs text-blue-600 dark:text-blue-400">
{relatedCard.spec_name}: {relatedCard.spec_value}
</span>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td className="text-center text-slate-500">{rule.delivery_times || 0}</td>
<td>
{rule.enabled ? (
<span className="badge-success"></span>
) : (
<span className="badge-danger"></span>
)}
</td>
<td>
<div className="">
<button
onClick={() => handleToggleEnabled(rule)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
title={rule.enabled ? '禁用' : '启用'}
>
{rule.enabled ? (
<PowerOff className="w-4 h-4 text-amber-500" />
) : (
<Power className="w-4 h-4 text-emerald-500" />
)}
</button>
<button
onClick={() => openEditModal(rule)}
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(rule.id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
@ -290,23 +299,14 @@ export function Delivery() {
{ value: '', label: '请选择卡券' },
...cards.map((card) => ({
value: String(card.id),
label: card.name || card.text_content?.substring(0, 20) || `卡券 ${card.id}`,
label: card.is_multi_spec
? `${card.name} [${card.spec_name}: ${card.spec_value}]`
: card.name || card.text_content?.substring(0, 20) || `卡券 ${card.id}`,
})),
]}
placeholder="请选择卡券"
/>
</div>
<div>
<label className="input-label"></label>
<input
type="number"
value={formDeliveryCount}
onChange={(e) => setFormDeliveryCount(Number(e.target.value) || 1)}
className="input-ios"
min={1}
placeholder="每次发货的卡密数量"
/>
</div>
<div>
<label className="input-label"></label>
<textarea

View File

@ -0,0 +1,19 @@
/**
*
*/
import { DisclaimerContent } from '@/components/common/DisclaimerContent'
export function Disclaimer() {
return (
<div className="max-w-4xl mx-auto">
<div className="vben-card">
<div className="vben-card-header">
<h1 className="vben-card-title"></h1>
</div>
<div className="vben-card-body">
<DisclaimerContent />
</div>
</div>
</div>
)
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { CheckSquare, Download, Edit2, ExternalLink, Loader2, Package, RefreshCw, Search, Square, Trash2, X } from 'lucide-react'
import { batchDeleteItems, deleteItem, fetchItemsFromAccount, getItems, updateItem, updateItemMultiQuantityDelivery, updateItemMultiSpec } from '@/api/items'
import { batchDeleteItems, deleteItem, fetchAllItemsFromAccount, getItems, updateItem, updateItemMultiQuantityDelivery, updateItemMultiSpec } from '@/api/items'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { PageLoading } from '@/components/common/Loading'
@ -18,7 +18,6 @@ export function Items() {
const [searchKeyword, setSearchKeyword] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<string | number>>(new Set())
const [fetching, setFetching] = useState(false)
const [fetchProgress, setFetchProgress] = useState({ current: 0, total: 0 })
// 编辑弹窗状态
const [editingItem, setEditingItem] = useState<Item | null>(null)
@ -49,37 +48,23 @@ export function Items() {
}
setFetching(true)
setFetchProgress({ current: 0, total: 0 })
try {
let page = 1
let hasMore = true
let totalFetched = 0
// 使用获取所有页的接口,后端会自动遍历所有页
const result = await fetchAllItemsFromAccount(selectedAccount)
while (hasMore) {
setFetchProgress({ current: page, total: page })
const result = await fetchItemsFromAccount(selectedAccount, page)
if (result.success) {
const fetchedCount = (result as { count?: number }).count || 0
totalFetched += fetchedCount
hasMore = (result as { has_more?: boolean }).has_more === true
page++
} else {
hasMore = false
}
// 防止无限循环最多抓取20页
if (page > 20) hasMore = false
if (result.success) {
const totalCount = (result as { total_count?: number }).total_count || 0
const savedCount = (result as { saved_count?: number }).saved_count || 0
addToast({ type: 'success', message: `成功获取商品,共 ${totalCount} 件,保存 ${savedCount}` })
await loadItems()
} else {
addToast({ type: 'error', message: (result as { message?: string }).message || '获取商品失败' })
}
addToast({ type: 'success', message: `成功获取商品,共 ${totalFetched}` })
await loadItems()
} catch {
addToast({ type: 'error', message: '获取商品失败' })
} finally {
setFetching(false)
setFetchProgress({ current: 0, total: 0 })
}
}
@ -245,7 +230,7 @@ export function Items() {
{fetching ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
({fetchProgress.current})
...
</>
) : (
<>

View File

@ -4,11 +4,12 @@ import { motion } from 'framer-motion'
import { MessageSquare, RefreshCw, Plus, Edit2, Trash2, Upload, Download, Info, Image } from 'lucide-react'
import { getKeywords, deleteKeyword, addKeyword, updateKeyword, exportKeywords, importKeywords as importKeywordsApi, addImageKeyword } from '@/api/keywords'
import { getAccounts } from '@/api/accounts'
import { getItems } from '@/api/items'
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'
import type { Keyword, Account, Item } from '@/types'
export function Keywords() {
const { addToast } = useUIStore()
@ -16,13 +17,13 @@ export function Keywords() {
const [loading, setLoading] = useState(true)
const [keywords, setKeywords] = useState<Keyword[]>([])
const [accounts, setAccounts] = useState<Account[]>([])
const [items, setItems] = useState<Item[]>([])
const [selectedAccount, setSelectedAccount] = useState('')
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingKeyword, setEditingKeyword] = useState<Keyword | null>(null)
const [keywordText, setKeywordText] = useState('')
const [replyText, setReplyText] = useState('')
const [itemIdText, setItemIdText] = useState('') // 绑定的商品ID
const [fuzzyMatch, setFuzzyMatch] = useState(false)
const [saving, setSaving] = useState(false)
const [importing, setImporting] = useState(false)
const [exporting, setExporting] = useState(false)
@ -36,6 +37,10 @@ export function Keywords() {
const [imagePreview, setImagePreview] = useState<string>('')
const [savingImage, setSavingImage] = useState(false)
const imageInputRef = useRef<HTMLInputElement | null>(null)
// 图片预览弹窗状态
const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false)
const [previewImageUrl, setPreviewImageUrl] = useState('')
const loadKeywords = async () => {
if (!_hasHydrated || !isAuthenticated || !token) {
@ -90,9 +95,23 @@ export function Keywords() {
if (!_hasHydrated || !isAuthenticated || !token) return
if (selectedAccount) {
loadKeywords()
loadItems()
}
}, [_hasHydrated, isAuthenticated, token, selectedAccount])
const loadItems = async () => {
if (!selectedAccount) {
setItems([])
return
}
try {
const result = await getItems(selectedAccount)
setItems(result.data || [])
} catch {
setItems([])
}
}
const openAddModal = () => {
if (!selectedAccount) {
addToast({ type: 'warning', message: '请先选择账号' })
@ -102,16 +121,20 @@ export function Keywords() {
setKeywordText('')
setReplyText('')
setItemIdText('')
setFuzzyMatch(false)
setIsModalOpen(true)
}
const openEditModal = (keyword: Keyword) => {
// 图片关键词不支持编辑
if (keyword.type === 'image') {
addToast({ type: 'warning', message: '图片关键词不支持编辑,请删除后重新添加' })
return
}
setEditingKeyword(keyword)
setKeywordText(keyword.keyword)
setReplyText(keyword.reply)
setItemIdText(keyword.item_id || '')
setFuzzyMatch(!!keyword.fuzzy_match)
setIsModalOpen(true)
}
@ -215,15 +238,18 @@ export function Keywords() {
try {
setImporting(true)
const result = await importKeywordsApi(selectedAccount, file)
if (result.success) {
const info = (result.data as { added?: number; updated?: number } | undefined) || {}
// 后端返回 { msg, total, added, updated } 格式
const resultData = result as unknown as { msg?: string; added?: number; updated?: number; success?: boolean; message?: string }
if (resultData.msg || resultData.added !== undefined) {
addToast({
type: 'success',
message: `导入成功:新增 ${info.added ?? 0} 条,更新 ${info.updated ?? 0}`,
message: `导入成功:新增 ${resultData.added ?? 0} 条,更新 ${resultData.updated ?? 0}`,
})
await loadKeywords()
} else if (resultData.success === false) {
addToast({ type: 'error', message: resultData.message || '导入失败' })
} else {
addToast({ type: 'error', message: result.message || '导入失败' })
addToast({ type: 'error', message: '导入失败' })
}
} catch {
addToast({ type: 'error', message: '导入关键词失败' })
@ -489,9 +515,21 @@ export function Keywords() {
)}
</td>
<td className="max-w-[300px]">
<p className="truncate text-slate-600 dark:text-slate-300" title={keyword.reply}>
{keyword.reply || <span className="text-gray-400"></span>}
</p>
{keyword.type === 'image' ? (
<button
onClick={() => {
setPreviewImageUrl(keyword.image_url || '')
setIsImagePreviewOpen(true)
}}
className="text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
</button>
) : (
<p className="truncate text-slate-600 dark:text-slate-300" title={keyword.reply}>
{keyword.reply || <span className="text-gray-400"></span>}
</p>
)}
</td>
<td>
{keyword.type === 'image' ? (
@ -566,13 +604,18 @@ export function Keywords() {
</div>
<div>
<label className="input-label">ID</label>
<input
type="text"
<select
value={itemIdText}
onChange={(e) => setItemIdText(e.target.value)}
className="input-ios"
placeholder="留空表示通用关键词,填写则仅对该商品生效"
/>
>
<option value=""></option>
{items.map((item) => (
<option key={item.item_id} value={item.item_id}>
{item.item_id} - {item.title || item.item_title || '未命名商品'}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
ID后
</p>
@ -589,25 +632,6 @@ export function Keywords() {
</p>
</div>
<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'
}`}
/>
</button>
</div>
</div>
<div className="modal-footer">
<button
@ -698,13 +722,18 @@ export function Keywords() {
{/* 关联商品 */}
<div>
<label className="input-label"></label>
<input
type="text"
<select
value={imageItemId}
onChange={(e) => setImageItemId(e.target.value)}
className="input-ios"
placeholder="留空表示通用关键词"
/>
>
<option value=""></option>
{items.map((item) => (
<option key={item.item_id} value={item.item_id}>
{item.item_id} - {item.title || item.item_title || '未命名商品'}
</option>
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">ID后</p>
</div>
@ -744,6 +773,28 @@ export function Keywords() {
</div>
</div>
)}
{/* 图片预览弹窗 */}
{isImagePreviewOpen && (
<div
className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4"
onClick={() => setIsImagePreviewOpen(false)}
>
<div className="relative max-w-4xl max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setIsImagePreviewOpen(false)}
className="absolute -top-10 right-0 text-white hover:text-gray-300 text-sm"
>
</button>
<img
src={previewImageUrl}
alt="关键词图片"
className="max-w-full max-h-[90vh] object-contain rounded-lg"
/>
</div>
</div>
)}
</div>
)
}

View File

@ -10,7 +10,6 @@ import type { NotificationChannel } from '@/types'
// 所有支持的渠道类型配置
const channelTypes = [
{ type: 'qq', label: 'QQ通知', desc: 'QQ机器人消息', icon: MessageCircle, placeholder: '{"qq_number": "123456789"}' },
{ type: 'dingtalk', label: '钉钉通知', desc: '钉钉机器人消息', icon: Bell, placeholder: '{"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=..."}' },
{ type: 'feishu', label: '飞书通知', desc: '飞书机器人消息', icon: Send, placeholder: '{"webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/..."}' },
{ type: 'bark', label: 'Bark通知', desc: 'iOS推送通知', icon: Smartphone, placeholder: '{"device_key": "xxx", "server_url": "https://api.day.app"}' },
@ -191,7 +190,6 @@ export function NotificationChannels() {
// 获取当前类型的配置提示
const getConfigHint = (type: ChannelType) => {
switch (type) {
case 'qq': return '需要添加QQ号 3668943488 为好友才能正常接收消息通知'
case 'bark': return 'Bark是iOS推送通知服务需要填写设备密钥'
case 'dingtalk': return '请设置钉钉机器人Webhook URL可选填加签密钥'
case 'feishu': return '请设置飞书机器人Webhook URL'

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { ShoppingCart, RefreshCw, Search, Trash2 } from 'lucide-react'
import { getOrders, deleteOrder } from '@/api/orders'
import { ShoppingCart, RefreshCw, Search, Trash2, Eye, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { getOrders, deleteOrder, getOrderDetail, type OrderDetail } from '@/api/orders'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
import { useAuthStore } from '@/store/authStore'
@ -11,9 +11,11 @@ import type { Order, Account } from '@/types'
const statusMap: Record<string, { label: string; class: string }> = {
processing: { label: '处理中', class: 'badge-warning' },
pending_ship: { label: '待发货', class: 'badge-info' },
processed: { label: '已处理', class: 'badge-info' },
shipped: { label: '已发货', class: 'badge-success' },
completed: { label: '已完成', class: 'badge-success' },
refunding: { label: '退款中', class: 'badge-warning' },
cancelled: { label: '已关闭', class: 'badge-danger' },
unknown: { label: '未知', class: 'badge-gray' },
}
@ -27,14 +29,25 @@ export function Orders() {
const [selectedAccount, setSelectedAccount] = useState('')
const [selectedStatus, setSelectedStatus] = useState('')
const [searchKeyword, setSearchKeyword] = useState('')
const [detailModalOpen, setDetailModalOpen] = useState(false)
const [orderDetail, setOrderDetail] = useState<OrderDetail | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [pageSize] = useState(20)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const loadOrders = async () => {
const loadOrders = async (page: number = currentPage) => {
if (!_hasHydrated || !isAuthenticated || !token) return
try {
setLoading(true)
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined)
const result = await getOrders(selectedAccount || undefined, selectedStatus || undefined, page, pageSize)
if (result.success) {
setOrders(result.data || [])
setTotal(result.total || 0)
setTotalPages(result.total_pages || 0)
setCurrentPage(page)
}
} catch {
addToast({ type: 'error', message: '加载订单列表失败' })
@ -56,25 +69,49 @@ export function Orders() {
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadAccounts()
loadOrders()
loadOrders(1)
}, [_hasHydrated, isAuthenticated, token])
useEffect(() => {
if (!_hasHydrated || !isAuthenticated || !token) return
loadOrders()
setCurrentPage(1)
loadOrders(1)
}, [_hasHydrated, isAuthenticated, token, selectedAccount, selectedStatus])
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个订单吗?')) return
try {
await deleteOrder(id)
addToast({ type: 'success', message: '删除成功' })
loadOrders()
const result = await deleteOrder(id)
if (result.success) {
addToast({ type: 'success', message: '删除成功' })
loadOrders()
} else {
addToast({ type: 'error', message: result.message || '删除失败' })
}
} catch {
addToast({ type: 'error', message: '删除失败' })
}
}
const handleShowDetail = async (orderNo: string) => {
setLoadingDetail(true)
setDetailModalOpen(true)
try {
const result = await getOrderDetail(orderNo)
if (result.success && result.data) {
setOrderDetail(result.data)
} else {
addToast({ type: 'error', message: '获取订单详情失败' })
setDetailModalOpen(false)
}
} catch {
addToast({ type: 'error', message: '获取订单详情失败' })
setDetailModalOpen(false)
} finally {
setLoadingDetail(false)
}
}
const filteredOrders = orders.filter((order) => {
if (!searchKeyword) return true
const keyword = searchKeyword.toLowerCase()
@ -85,7 +122,7 @@ export function Orders() {
)
})
if (loading) {
if (loading && orders.length === 0) {
return <PageLoading />
}
@ -97,7 +134,7 @@ export function Orders() {
<h1 className="page-title"></h1>
<p className="page-description"></p>
</div>
<button onClick={loadOrders} className="btn-ios-secondary w-full sm:w-auto">
<button onClick={() => loadOrders(currentPage)} className="btn-ios-secondary w-full sm:w-auto">
<RefreshCw className="w-4 h-4" />
</button>
@ -134,9 +171,10 @@ export function Orders() {
options={[
{ value: '', label: '所有状态' },
{ value: 'processing', label: '处理中' },
{ value: 'processed', label: '已处理' },
{ value: 'pending_ship', label: '待发货' },
{ value: 'shipped', label: '已发货' },
{ value: 'completed', label: '已完成' },
{ value: 'refunding', label: '退款中' },
{ value: 'cancelled', label: '已关闭' },
]}
placeholder="所有状态"
@ -166,13 +204,12 @@ export function Orders() {
transition={{ delay: 0.1 }}
className="vben-card"
>
<div className="vben-card-header
flex items-center justify-between">
<h2 className="vben-card-title ">
<div className="vben-card-header flex items-center justify-between">
<h2 className="vben-card-title">
<ShoppingCart className="w-4 h-4" />
</h2>
<span className="badge-primary">{filteredOrders.length} </span>
<span className="badge-primary"> {total} </span>
</div>
<div className="overflow-x-auto">
<table className="table-ios">
@ -184,14 +221,16 @@ export function Orders() {
<th></th>
<th></th>
<th></th>
<th></th>
<th>ID</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredOrders.length === 0 ? (
<tr>
<td colSpan={8} className="text-center py-8 text-gray-500">
<td colSpan={10} className="text-center py-8 text-gray-500">
<div className="flex flex-col items-center gap-2">
<ShoppingCart className="w-12 h-12 text-gray-300" />
<p></p>
@ -199,10 +238,10 @@ export function Orders() {
</td>
</tr>
) : (
filteredOrders.map((order, index) => {
filteredOrders.map((order) => {
const status = statusMap[order.status] || statusMap.unknown
return (
<tr key={order.id || order.order_id || index}>
<tr key={order.id}>
<td className="font-mono text-sm">{order.order_id}</td>
<td className="text-sm">{order.item_id}</td>
<td className="text-sm">{order.buyer_id}</td>
@ -211,15 +250,34 @@ export function Orders() {
<td>
<span className={status.class}>{status.label}</span>
</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{order.cookie_id}</td>
<td>
<button
onClick={() => handleDelete(order.id)}
className="p-2 rounded-lg hover:bg-red-50 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
{order.is_bargain ? (
<span className="badge-warning"></span>
) : (
<span className="badge-gray"></span>
)}
</td>
<td className="font-medium text-blue-600 dark:text-blue-400">{order.cookie_id}</td>
<td className="text-sm text-gray-500">
{order.created_at ? new Date(order.created_at).toLocaleString('zh-CN') : '-'}
</td>
<td>
<div className="flex items-center gap-1">
<button
onClick={() => handleShowDetail(order.order_id)}
className="p-2 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
title="查看详情"
>
<Eye className="w-4 h-4 text-blue-500" />
</button>
<button
onClick={() => handleDelete(order.id)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</td>
</tr>
)
@ -228,7 +286,171 @@ export function Orders() {
</tbody>
</table>
</div>
{/* 分页 */}
{totalPages > 0 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-200 dark:border-gray-700">
<div className="text-sm text-gray-500">
{currentPage} {totalPages} {total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => loadOrders(currentPage - 1)}
disabled={currentPage <= 1 || loading}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="上一页"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number
if (totalPages <= 5) {
pageNum = i + 1
} else if (currentPage <= 3) {
pageNum = i + 1
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i
} else {
pageNum = currentPage - 2 + i
}
return (
<button
key={pageNum}
onClick={() => loadOrders(pageNum)}
disabled={loading}
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
currentPage === pageNum
? 'bg-blue-500 text-white'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{pageNum}
</button>
)
})}
</div>
<button
onClick={() => loadOrders(currentPage + 1)}
disabled={currentPage >= totalPages || loading}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="下一页"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</motion.div>
{/* 订单详情弹窗 */}
{detailModalOpen && (
<div className="modal-overlay">
<div className="modal-content max-w-2xl">
<div className="modal-header flex items-center justify-between">
<h2 className="text-lg font-semibold"></h2>
<button
onClick={() => setDetailModalOpen(false)}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<div className="modal-body">
{loadingDetail ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-2 text-gray-500">...</span>
</div>
) : orderDetail ? (
<div className="space-y-4">
{/* 基本信息 */}
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span className="font-mono">{orderDetail.order_id}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span>{orderDetail.item_id || '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span>{orderDetail.buyer_id || '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500">ID</span>
<span className="text-blue-600">{orderDetail.cookie_id || '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span className={statusMap[orderDetail.status]?.class || 'badge-gray'}>
{statusMap[orderDetail.status]?.label || '未知'}
</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
{orderDetail.is_bargain ? (
<span className="badge-warning"></span>
) : (
<span className="badge-gray"></span>
)}
</div>
</div>
</div>
{/* 商品信息 */}
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.spec_name || '无'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.spec_value || '无'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.quantity || 1}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span className="text-amber-600 font-medium">¥{orderDetail.amount || '0.00'}</span>
</div>
</div>
</div>
{/* 时间信息 */}
<div>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"></h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.created_at ? new Date(orderDetail.created_at).toLocaleString('zh-CN') : '未知'}</span>
</div>
<div className="flex justify-between py-1 border-b border-gray-100 dark:border-gray-700">
<span className="text-gray-500"></span>
<span>{orderDetail.updated_at ? new Date(orderDetail.updated_at).toLocaleString('zh-CN') : '未知'}</span>
</div>
</div>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500"></div>
)}
</div>
<div className="modal-footer">
<button onClick={() => setDetailModalOpen(false)} className="btn-ios-secondary">
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'
import { Settings as SettingsIcon, Save, Bot, Mail, Shield, RefreshCw, Key, Download, Upload, Archive, Eye, EyeOff, Copy } from 'lucide-react'
import { Settings as SettingsIcon, Save, Bot, Mail, RefreshCw, Key, Download, Upload, Archive, Eye, EyeOff, Copy } from 'lucide-react'
import { getSystemSettings, updateSystemSettings, testAIConnection, testEmailSend, changePassword, downloadDatabaseBackup, uploadDatabaseBackup, reloadSystemCache, exportUserBackup, importUserBackup } from '@/api/settings'
import { getAccounts } from '@/api/accounts'
import { useUIStore } from '@/store/uiStore'
@ -32,8 +32,14 @@ export function Settings() {
const [testAccountId, setTestAccountId] = useState('')
const [testingAI, setTestingAI] = useState(false)
// QQ秘钥显示状态
const [showQQSecret, setShowQQSecret] = useState(false)
// SMTP密码显示状态
const [showSmtpPassword, setShowSmtpPassword] = useState(false)
// API Key 显示状态
const [showApiKey, setShowApiKey] = useState(false)
// 密码修改显示状态
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const loadSettings = async () => {
if (!_hasHydrated || !isAuthenticated || !token) return
@ -286,7 +292,7 @@ export function Settings() {
<label className="switch-ios">
<input
type="checkbox"
checked={Boolean(settings?.registration_enabled ?? true)}
checked={Boolean(settings?.registration_enabled ?? false)}
onChange={(e) => setSettings(s => s ? { ...s, registration_enabled: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
@ -301,12 +307,27 @@ export function Settings() {
<label className="switch-ios">
<input
type="checkbox"
checked={Boolean(settings?.show_default_login_info ?? true)}
checked={Boolean(settings?.show_default_login_info ?? false)}
onChange={(e) => setSettings(s => s ? { ...s, show_default_login_info: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
<div className="flex items-center justify-between py-3 border-t border-slate-100 dark:border-slate-700">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<label className="switch-ios">
<input
type="checkbox"
checked={Boolean(settings?.login_captcha_enabled ?? true)}
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
/>
<span className="switch-slider"></span>
</label>
</div>
</div>
</div>
@ -331,13 +352,38 @@ export function Settings() {
</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 className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={settings?.ai_api_key || ''}
onChange={(e) => setSettings(s => s ? { ...s, ai_api_key: e.target.value } : null)}
placeholder="sk-..."
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showApiKey ? '隐藏' : '显示'}
>
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (settings?.ai_api_key) {
navigator.clipboard.writeText(settings.ai_api_key)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="input-group">
<label className="input-label"></label>
@ -429,52 +475,51 @@ export function Settings() {
</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 className="relative">
<input
type={showSmtpPassword ? 'text' : 'password'}
value={settings?.smtp_password || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_password: e.target.value } : null)}
placeholder="输入密码或授权码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showSmtpPassword ? '隐藏' : '显示'}
>
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (settings?.smtp_password) {
navigator.clipboard.writeText(settings.smtp_password)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<p className="text-xs text-slate-400 mt-1">(QQ邮箱需要授权码)</p>
</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="text"
value={settings?.smtp_from || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_from: e.target.value } : null)}
placeholder="闲鱼自动回复系统"
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1">使</p>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={Boolean(settings?.smtp_use_tls ?? true)}
onChange={(e) => setSettings(s => s ? { ...s, smtp_use_tls: e.target.checked } : null)}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm">TLS</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={Boolean(settings?.smtp_use_ssl ?? false)}
onChange={(e) => setSettings(s => s ? { ...s, smtp_use_ssl: e.target.checked } : null)}
className="w-4 h-4 rounded border-slate-300"
/>
<span className="text-sm">SSL</span>
</label>
</div>
<p className="text-xs text-slate-400 mt-1">TLS和SSL二选一TLS</p>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="text"
value={settings?.smtp_from || ''}
onChange={(e) => setSettings(s => s ? { ...s, smtp_from: e.target.value } : null)}
placeholder="闲鱼自动回复系统"
className="input-ios"
/>
<p className="text-xs text-slate-400 mt-1">使</p>
</div>
<button onClick={handleTestEmail} className="btn-ios-secondary">
@ -482,216 +527,203 @@ export function Settings() {
</div>
</div>
{/* Security Settings */}
{/* 密码修改 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Shield className="w-4 h-4" />
<Key className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="flex items-center justify-between py-2">
<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">
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<input
type="checkbox"
checked={Boolean(settings?.login_captcha_enabled ?? false)}
onChange={(e) => setSettings(s => s ? { ...s, login_captcha_enabled: e.target.checked } : null)}
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="请输入当前密码"
className="input-ios w-full pr-20"
/>
<span className="switch-slider"></span>
</label>
</div>
{user?.is_admin && (
<>
<div className="border-t border-slate-100 dark:border-slate-700 pt-4">
<label className="input-label flex items-center gap-2">
QQ回复消息API秘钥
<span className="text-xs bg-slate-500 text-white px-1.5 py-0.5 rounded"></span>
</label>
<div className="flex gap-2 mt-1">
<div className="relative flex-1">
<input
type={showQQSecret ? 'text' : 'password'}
value={settings?.qq_reply_secret_key || ''}
onChange={(e) => setSettings(s => s ? { ...s, qq_reply_secret_key: e.target.value } : null)}
placeholder="请输入API秘钥"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowQQSecret(!showQQSecret)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showQQSecret ? '隐藏' : '显示'}
>
{showQQSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (settings?.qq_reply_secret_key) {
navigator.clipboard.writeText(settings.qq_reply_secret_key)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
<button
type="button"
onClick={() => {
const key = Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map(b => b.toString(16).padStart(2, '0')).join('')
setSettings(s => s ? { ...s, qq_reply_secret_key: key } : null)
addToast({ type: 'success', message: '已生成随机秘钥,请保存设置' })
}}
className="btn-ios-secondary whitespace-nowrap"
>
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
/send-message API接口的访问权限使API的应用
</p>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showCurrentPassword ? '隐藏' : '显示'}
>
{showCurrentPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (currentPassword) {
navigator.clipboard.writeText(currentPassword)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</>
)}
</div>
</div>
</div>
</div>
{/* 密码修改和数据备份 - 双列布局 */}
<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">
<Key className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
<div className="input-group">
<label className="input-label"></label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="请输入当前密码"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
className="input-ios"
/>
</div>
<div className="input-group">
<label className="input-label"></label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
className="input-ios"
/>
</div>
<button
onClick={handleChangePassword}
disabled={changingPassword}
className="btn-ios-primary"
>
{changingPassword ? <ButtonLoading /> : <Key className="w-4 h-4" />}
</button>
</div>
</div>
{/* 数据备份 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Archive className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
{/* 用户数据备份 */}
<div>
<p className="font-medium text-slate-900 dark:text-slate-100 mb-1"></p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2"></p>
<div className="flex flex-wrap gap-2">
<button onClick={handleExportUserBackup} className="btn-ios-primary">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
<Upload className="w-4 h-4" />
<input
ref={userBackupFileRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportUserBackup}
/>
</label>
</div>
</div>
{/* 管理员数据库备份 */}
{user?.is_admin && (
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<span className="text-xs bg-slate-500 text-white px-1.5 py-0.5 rounded"></span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2"></p>
<div className="flex flex-wrap gap-2 mb-2">
<button onClick={handleDownloadBackup} className="btn-ios-primary">
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<input
type={showNewPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showNewPassword ? '隐藏' : '显示'}
>
{showNewPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (newPassword) {
navigator.clipboard.writeText(newPassword)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<div className="input-group">
<label className="input-label"></label>
<div className="relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
className="input-ios w-full pr-20"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title={showConfirmPassword ? '隐藏' : '显示'}
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => {
if (confirmPassword) {
navigator.clipboard.writeText(confirmPassword)
addToast({ type: 'success', message: '已复制到剪贴板' })
}
}}
className="p-1.5 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
title="复制"
>
<Copy className="w-4 h-4" />
</button>
</div>
</div>
</div>
<button
onClick={handleChangePassword}
disabled={changingPassword}
className="btn-ios-primary"
>
{changingPassword ? <ButtonLoading /> : <Key className="w-4 h-4" />}
</button>
</div>
</div>
{/* 数据备份 */}
<div className="vben-card">
<div className="vben-card-header">
<h2 className="vben-card-title">
<Archive className="w-4 h-4" />
</h2>
</div>
<div className="vben-card-body space-y-4">
{/* 用户数据备份 */}
<div>
<p className="font-medium text-slate-900 dark:text-slate-100 mb-1"></p>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2"></p>
<div className="flex flex-wrap gap-2">
<button onClick={handleExportUserBackup} className="btn-ios-primary">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
{uploadingBackup ? <ButtonLoading /> : <Upload className="w-4 h-4" />}
<Upload className="w-4 h-4" />
<input
ref={backupFileRef}
ref={userBackupFileRef}
type="file"
accept=".db"
accept=".json"
className="hidden"
onChange={handleUploadBackup}
disabled={uploadingBackup}
onChange={handleImportUserBackup}
/>
</label>
<button
onClick={handleReloadCache}
disabled={reloadingCache}
className="btn-ios-secondary"
>
{reloadingCache ? <ButtonLoading /> : <RefreshCw className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
)}
{/* 管理员数据库备份 */}
{user?.is_admin && (
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<span className="text-xs bg-slate-500 text-white px-1.5 py-0.5 rounded"></span>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-2"></p>
<div className="flex flex-wrap gap-2 mb-2">
<button onClick={handleDownloadBackup} className="btn-ios-primary">
<Download className="w-4 h-4" />
</button>
<label className="btn-ios-secondary cursor-pointer">
{uploadingBackup ? <ButtonLoading /> : <Upload className="w-4 h-4" />}
<input
ref={backupFileRef}
type="file"
accept=".db"
className="hidden"
onChange={handleUploadBackup}
disabled={uploadingBackup}
/>
</label>
<button
onClick={handleReloadCache}
disabled={reloadingCache}
className="btn-ios-secondary"
>
{reloadingCache ? <ButtonLoading /> : <RefreshCw className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
)}
</div>
</div>
</div>
</div>

View File

@ -39,6 +39,9 @@ export interface Account {
export interface AccountDetail extends Account {
keywords?: Keyword[]
keywordCount?: number
username?: string
login_password?: string
show_browser?: boolean
}
// 关键词相关类型
@ -94,19 +97,25 @@ export interface Order {
cookie_id: string
item_id: string
buyer_id: string
chat_id?: string
sku_info?: string
spec_name?: string
spec_value?: string
quantity: number
amount: string
status: OrderStatus
is_bargain?: boolean
created_at?: string
updated_at?: string
}
export type OrderStatus =
| 'processing'
| 'pending_ship'
| 'processed'
| 'shipped'
| 'completed'
| 'refunding'
| 'cancelled'
| 'unknown'

View File

@ -89,6 +89,10 @@ export default defineConfig(({ command }) => ({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/geetest': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/register': {
target: 'http://localhost:8080',
changeOrigin: true,
@ -135,6 +139,10 @@ export default defineConfig(({ command }) => ({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/upload-image': {
target: 'http://localhost:8080',
changeOrigin: true,
},
'/default-reply': {
target: 'http://localhost:8080',
changeOrigin: true,

View File

@ -418,6 +418,47 @@ async def health_check():
}
# ==================== 版本检查和更新日志接口 ====================
import httpx
@app.get('/api/version/check')
async def check_version():
"""检查最新版本(代理外部接口)"""
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.get('https://xianyu.zhinianblog.cn/index.php?action=getVersion')
if response.status_code == 200:
try:
return response.json()
except Exception:
# 如果不是有效JSON返回HTML内容
return {"html": response.text}
else:
return {"error": True, "message": f"远程服务返回状态码: {response.status_code}"}
except Exception as e:
logger.error(f"检查版本失败: {e}")
return {"error": True, "message": f"检查版本失败: {str(e)}"}
@app.get('/api/version/changelog')
async def get_changelog():
"""获取更新日志(代理外部接口)"""
try:
async with httpx.AsyncClient(timeout=20.0) as client:
response = await client.get('https://xianyu.zhinianblog.cn/index.php?action=getUpdateInfo')
if response.status_code == 200:
try:
return response.json()
except Exception:
# 如果不是有效JSON返回HTML内容
return {"html": response.text}
else:
return {"error": True, "message": f"远程服务返回状态码: {response.status_code}"}
except Exception as e:
logger.error(f"获取更新日志失败: {e}")
return {"error": True, "message": f"获取更新日志失败: {str(e)}"}
# 服务 React 前端 SPA - 所有前端路由都返回 index.html
async def serve_frontend():
"""服务 React 前端 SPA"""
@ -721,6 +762,179 @@ async def verify_captcha(request: VerifyCaptchaRequest):
)
# ==================== 极验滑动验证码 ====================
# 极验验证状态存储: {challenge: {"status": int, "expires_at": float}}
geetest_status_store: dict = {}
def cleanup_expired_geetest_status():
"""清理过期的极验验证状态"""
current_time = time.time()
expired_keys = [k for k, v in geetest_status_store.items() if v["expires_at"] < current_time]
for k in expired_keys:
del geetest_status_store[k]
def set_geetest_status(challenge: str, status: int):
"""设置极验验证状态"""
cleanup_expired_geetest_status()
geetest_status_store[challenge] = {
"status": status,
"expires_at": time.time() + 300 # 5分钟有效
}
def get_geetest_status(challenge: str) -> int:
"""获取极验验证状态返回0表示未验证或已过期"""
cleanup_expired_geetest_status()
stored = geetest_status_store.get(challenge)
if stored and stored["expires_at"] > time.time():
return stored["status"]
return 0
class GeetestRegisterResponse(BaseModel):
"""极验验证码初始化响应"""
success: bool
code: int = 200
message: str = ""
data: Optional[dict] = None
class GeetestValidateRequest(BaseModel):
"""极验二次验证请求"""
challenge: str
validate: str
seccode: str
class GeetestValidateResponse(BaseModel):
"""极验二次验证响应"""
success: bool
code: int = 200
message: str = ""
@app.get('/geetest/register', response_model=GeetestRegisterResponse)
async def geetest_register():
"""
获取极验验证码初始化参数
前端调用此接口获取gtchallenge等参数用于初始化验证码组件
"""
try:
from utils.geetest import GeetestLib
gt_lib = GeetestLib()
result = await gt_lib.register()
data = result.to_dict()
logger.info(f"极验初始化结果: status={result.status}, data={data}")
# 记录初始状态
challenge = data.get("challenge", "")
if challenge:
set_geetest_status(challenge, 0)
return GeetestRegisterResponse(
success=True,
code=200,
message="获取成功" if result.status == 1 else "宕机模式",
data=data
)
except Exception as e:
logger.error(f"极验初始化失败: {e}")
# 返回本地初始化结果
try:
from utils.geetest import GeetestLib
gt_lib = GeetestLib()
result = gt_lib.local_init()
data = result.to_dict()
# 记录初始状态
challenge = data.get("challenge", "")
if challenge:
set_geetest_status(challenge, 0)
return GeetestRegisterResponse(
success=True,
code=200,
message="本地初始化",
data=data
)
except Exception as e2:
logger.error(f"极验本地初始化也失败: {e2}")
return GeetestRegisterResponse(
success=False,
code=500,
message="验证码服务异常"
)
@app.post('/geetest/validate', response_model=GeetestValidateResponse)
async def geetest_validate(request: GeetestValidateRequest):
"""
极验二次验证
用户完成滑动验证后前端调用此接口进行二次验证
"""
try:
# 检查是否已经验证过
if get_geetest_status(request.challenge) == 1:
return GeetestValidateResponse(
success=True,
code=200,
message="验证通过"
)
from utils.geetest import GeetestLib
gt_lib = GeetestLib()
# 判断是正常模式还是宕机模式
# 通过challenge长度判断正常模式challenge是32位MD5宕机模式是UUID
is_normal_mode = len(request.challenge) == 32
if is_normal_mode:
result = await gt_lib.success_validate(
request.challenge,
request.validate,
request.seccode
)
else:
result = gt_lib.fail_validate(
request.challenge,
request.validate,
request.seccode
)
if result.status == 1:
# 记录验证通过状态
set_geetest_status(request.challenge, 1)
return GeetestValidateResponse(
success=True,
code=200,
message="验证通过"
)
else:
return GeetestValidateResponse(
success=False,
code=400,
message=result.msg or "验证失败"
)
except Exception as e:
logger.error(f"极验二次验证失败: {e}")
return GeetestValidateResponse(
success=False,
code=500,
message="验证服务异常"
)
# 发送验证码接口(需要先验证图形验证码)
@app.post('/send-verification-code')
async def send_verification_code(request: SendCodeRequest):
@ -1163,6 +1377,46 @@ def add_cookie(item: CookieIn, current_user: Dict[str, Any] = Depends(get_curren
raise HTTPException(status_code=400, detail=str(e))
# ============ 带子路径的 /cookies/{cid}/xxx 路由必须在 /cookies/{cid} 之前定义 ============
class AccountLoginInfoUpdate(BaseModel):
username: Optional[str] = None
login_password: Optional[str] = None
show_browser: Optional[bool] = None
@app.put("/cookies/{cid}/login-info")
def update_cookie_login_info(cid: str, update_data: AccountLoginInfoUpdate, current_user: Dict[str, Any] = Depends(get_current_user)):
"""更新账号登录信息(用户名、密码、是否显示浏览器)"""
try:
# 检查cookie是否属于当前用户
user_id = current_user['user_id']
from db_manager import db_manager
user_cookies = db_manager.get_all_cookies(user_id)
if cid not in user_cookies:
raise HTTPException(status_code=403, detail="无权限操作该Cookie")
# 使用现有的update_cookie_account_info方法更新登录信息
success = db_manager.update_cookie_account_info(
cid,
username=update_data.username,
password=update_data.login_password,
show_browser=update_data.show_browser
)
if success:
return {"success": True, "message": "登录信息已更新"}
else:
raise HTTPException(status_code=500, detail="更新登录信息失败")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============ 通用的 /cookies/{cid} 路由 ============
@app.put('/cookies/{cid}')
def update_cookie(cid: str, item: CookieIn, current_user: Dict[str, Any] = Depends(get_current_user)):
if cookie_manager.manager is None:
@ -2619,6 +2873,25 @@ def delete_message_notification(notification_id: int, _: None = Depends(require_
# ------------------------- 系统设置接口 -------------------------
@app.get('/system-settings/public')
def get_public_system_settings():
"""获取公开的系统设置(无需认证)"""
from db_manager import db_manager
try:
all_settings = db_manager.get_all_system_settings()
# 只返回公开的配置项
public_keys = {"registration_enabled", "show_default_login_info", "login_captcha_enabled"}
return {k: v for k, v in all_settings.items() if k in public_keys}
except Exception as e:
logger.error(f"获取公开系统设置失败: {e}")
# 返回默认值
return {
"registration_enabled": "true",
"show_default_login_info": "true",
"login_captcha_enabled": "true"
}
@app.get('/system-settings')
def get_system_settings(_: None = Depends(require_auth)):
"""获取系统设置(排除敏感信息)"""
@ -2633,9 +2906,6 @@ def get_system_settings(_: None = Depends(require_auth)):
raise HTTPException(status_code=500, detail=str(e))
@app.put('/system-settings/{key}')
def update_system_setting(key: str, setting_data: SystemSettingIn, _: None = Depends(require_auth)):
"""更新系统设置"""
@ -2981,8 +3251,6 @@ def get_cookie_pause_duration(cid: str, current_user: Dict[str, Any] = Depends(g
raise HTTPException(status_code=500, detail=str(e))
class KeywordIn(BaseModel):
keywords: Dict[str, str] # key -> reply
@ -3331,10 +3599,19 @@ async def import_keywords(cid: str, file: UploadFile = File(...), current_user:
update_count = 0
add_count = 0
def clean_cell_value(value):
"""清理单元格值,处理数字转字符串时的 .0 后缀问题"""
if pd.isna(value):
return ''
# 如果是数字类型,先转为整数(如果是整数值)再转字符串
if isinstance(value, float) and value == int(value):
return str(int(value)).strip()
return str(value).strip()
for index, row in df.iterrows():
keyword = str(row['关键词']).strip()
item_id = str(row['商品ID']).strip() if pd.notna(row['商品ID']) and str(row['商品ID']).strip() else None
reply = str(row['关键词内容']).strip()
keyword = clean_cell_value(row['关键词'])
item_id = clean_cell_value(row['商品ID']) or None
reply = clean_cell_value(row['关键词内容'])
if not keyword:
continue # 跳过没有关键词的行
@ -4526,12 +4803,14 @@ async def get_all_items_from_account(request: dict, _: None = Depends(require_au
else:
total_count = result.get('total_count', 0)
total_pages = result.get('total_pages', 1)
logger.info(f"成功获取账号 {cookie_id}{total_count} 个商品(共{total_pages}页)")
saved_count = result.get('total_saved', 0)
logger.info(f"成功获取账号 {cookie_id}{total_count} 个商品(共{total_pages}页),保存 {saved_count}")
return {
"success": True,
"message": f"成功获取 {total_count} 个商品(共{total_pages}页),详细信息已打印到控制台",
"message": f"成功获取商品,共 {total_count} 件,保存 {saved_count}",
"total_count": total_count,
"total_pages": total_pages
"total_pages": total_pages,
"saved_count": saved_count
}
except Exception as e:
@ -4959,40 +5238,47 @@ def get_system_stats(admin_user: Dict[str, Any] = Depends(require_admin)):
try:
log_with_user('info', "查询系统统计信息", admin_user)
stats = {
"users": {
"total": 0,
"active_today": 0
},
"cookies": {
"total": 0,
"enabled": 0
},
"cards": {
"total": 0,
"enabled": 0
},
"system": {
"uptime": "未知",
"version": "1.0.0"
}
}
# 用户统计
all_users = db_manager.get_all_users()
stats["users"]["total"] = len(all_users)
total_users = len(all_users)
# Cookie统计
all_cookies = db_manager.get_all_cookies()
stats["cookies"]["total"] = len(all_cookies)
total_cookies = len(all_cookies)
# 活跃账号统计(启用状态的账号)
active_cookies = 0
for cookie_id in all_cookies.keys():
status = db_manager.get_cookie_status(cookie_id)
if status:
active_cookies += 1
# 卡券统计
all_cards = db_manager.get_all_cards()
if all_cards:
stats["cards"]["total"] = len(all_cards)
stats["cards"]["enabled"] = len([card for card in all_cards if card.get('enabled', True)])
total_cards = len(all_cards) if all_cards else 0
log_with_user('info', "系统统计信息查询完成", admin_user)
# 关键词统计
all_keywords = db_manager.get_all_keywords()
total_keywords = sum(len(kw_list) for kw_list in all_keywords.values())
# 订单统计
total_orders = 0
try:
orders = db_manager.get_all_orders()
total_orders = len(orders) if orders else 0
except:
pass
stats = {
"total_users": total_users,
"total_cookies": total_cookies,
"active_cookies": active_cookies,
"total_cards": total_cards,
"total_keywords": total_keywords,
"total_orders": total_orders
}
log_with_user('info', f"系统统计信息查询完成: {stats}", admin_user)
return stats
except Exception as e:
@ -5552,13 +5838,77 @@ def get_user_orders(current_user: Dict[str, Any] = Depends(get_current_user)):
all_orders.sort(key=lambda x: x.get('created_at', ''), reverse=True)
log_with_user('info', f"用户订单查询成功,共 {len(all_orders)} 条记录", current_user)
return {"success": True, "data": all_orders}
return {"success": True, "data": all_orders, "total": len(all_orders)}
except Exception as e:
log_with_user('error', f"查询用户订单失败: {str(e)}", current_user)
raise HTTPException(status_code=500, detail=f"查询订单失败: {str(e)}")
@app.get('/api/orders/{order_id}')
def get_order_detail(order_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""获取订单详情"""
try:
from db_manager import db_manager
user_id = current_user['user_id']
log_with_user('info', f"查询订单详情: {order_id}", current_user)
# 获取用户的所有Cookie
user_cookies = db_manager.get_all_cookies(user_id)
# 在用户的订单中查找
for cookie_id in user_cookies.keys():
order = db_manager.get_order_by_id(order_id)
if order and order.get('cookie_id') == cookie_id:
log_with_user('info', f"订单详情查询成功: {order_id}", current_user)
return {"success": True, "data": order}
log_with_user('warning', f"订单不存在或无权访问: {order_id}", current_user)
raise HTTPException(status_code=404, detail="订单不存在或无权访问")
except HTTPException:
raise
except Exception as e:
log_with_user('error', f"查询订单详情失败: {str(e)}", current_user)
raise HTTPException(status_code=500, detail=f"查询订单详情失败: {str(e)}")
@app.delete('/api/orders/{order_id}')
def delete_order(order_id: str, current_user: Dict[str, Any] = Depends(get_current_user)):
"""删除订单"""
try:
from db_manager import db_manager
user_id = current_user['user_id']
log_with_user('info', f"删除订单: {order_id}", current_user)
# 获取用户的所有Cookie
user_cookies = db_manager.get_all_cookies(user_id)
# 验证订单属于当前用户
order = db_manager.get_order_by_id(order_id)
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.get('cookie_id') not in user_cookies:
raise HTTPException(status_code=403, detail="无权删除此订单")
# 删除订单
success = db_manager.delete_order(order_id)
if success:
log_with_user('info', f"订单删除成功: {order_id}", current_user)
return {"success": True, "message": "删除成功"}
else:
raise HTTPException(status_code=500, detail="删除失败")
except HTTPException:
raise
except Exception as e:
log_with_user('error', f"删除订单失败: {str(e)}", current_user)
raise HTTPException(status_code=500, detail=f"删除订单失败: {str(e)}")
# ==================== 前端 SPA Catch-All 路由 ====================
# 必须放在所有 API 路由之后,用于处理前端 SPA 的直接访问
# 这样用户直接访问 /dashboard、/accounts 等前端路由时,会返回 index.html

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

10
static/favicon.svg Normal file
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

27
static/index.html Normal file
View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>闲鱼自动回复管理系统</title>
<!-- 在页面加载前立即设置主题,避免闪白 -->
<script>
(function() {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/static/assets/index-BF-GL3W8.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-wyd100t0.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

BIN
static/static/qq-group.png Normal file

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

BIN
static/static/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

View File

@ -1 +1 @@
v1.0.4
v1.0.5

View File

@ -1,177 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用户使用统计模块
只统计有多少人在使用这个系统
"""
import asyncio
import hashlib
import platform
from datetime import datetime
from typing import Dict, Any
import aiohttp
from loguru import logger
class UsageStatistics:
"""用户使用统计收集器 - 只统计用户数量"""
def __init__(self):
# 默认启用统计
self.enabled = True
self.api_endpoint = "http://xianyu.zhinianblog.cn/?action=statistics" # PHP统计接收端点
self.timeout = 5
self.retry_count = 1
# 生成持久化的匿名用户ID
self.anonymous_id = self._get_or_create_anonymous_id()
def _get_or_create_anonymous_id(self) -> str:
"""获取或创建持久化的匿名用户ID"""
# 保存到数据库中确保Docker重建时ID不变
try:
from db_manager import db_manager
# 尝试从数据库获取ID
existing_id = db_manager.get_system_setting('anonymous_user_id')
if existing_id and len(existing_id) == 16:
return existing_id
except Exception:
pass
# 生成新的匿名ID
new_id = self._generate_anonymous_id()
# 保存到数据库
try:
from db_manager import db_manager
db_manager.set_system_setting('anonymous_user_id', new_id)
except Exception:
pass
return new_id
def _generate_anonymous_id(self) -> str:
"""生成匿名用户ID基于机器特征"""
try:
# 使用系统信息生成唯一但匿名的ID
machine_info = f"{platform.machine()}-{platform.processor()}-{platform.system()}"
unique_str = f"{machine_info}-{platform.python_version()}"
# 生成哈希值作为匿名ID
anonymous_id = hashlib.sha256(unique_str.encode()).hexdigest()[:16]
return anonymous_id
except Exception:
# 如果获取系统信息失败使用随机ID
import time
return hashlib.md5(str(time.time()).encode()).hexdigest()[:16]
def _get_basic_info(self) -> Dict[str, Any]:
"""获取基本信息(只包含必要信息)"""
try:
# 从version.txt文件读取版本号
version = self._get_version()
return {
"os": platform.system(),
"version": version
}
except Exception:
return {"os": "unknown", "version": "unknown"}
def _get_version(self) -> str:
"""从static/version.txt文件获取版本号"""
try:
with open("static/version.txt", "r", encoding="utf-8") as f:
version = f.read().strip()
return version if version else "unknown"
except Exception:
return "unknown"
def _prepare_statistics_data(self) -> Dict[str, Any]:
"""准备统计数据(只包含用户统计)"""
return {
"anonymous_id": self.anonymous_id,
"timestamp": datetime.now().isoformat(),
"project": "xianyu-auto-reply",
"info": self._get_basic_info()
}
async def _send_statistics(self, data: Dict[str, Any]) -> bool:
"""发送统计数据到远程API"""
if not self.enabled:
return False
for attempt in range(self.retry_count):
try:
timeout = aiohttp.ClientTimeout(total=self.timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
headers = {
'Content-Type': 'application/json',
'User-Agent': 'XianyuAutoReply/2.2.0'
}
async with session.post(
self.api_endpoint,
json=data,
headers=headers
) as response:
if response.status in [200, 201]:
logger.debug("统计数据上报成功")
return True
else:
logger.debug(f"统计数据上报失败,状态码: {response.status}")
except asyncio.TimeoutError:
logger.debug(f"统计数据上报超时,第{attempt + 1}次尝试")
except Exception as e:
logger.debug(f"统计数据上报异常: {e}")
if attempt < self.retry_count - 1:
await asyncio.sleep(1)
return False
async def report_usage(self) -> bool:
"""报告用户使用统计"""
try:
data = self._prepare_statistics_data()
return await self._send_statistics(data)
except Exception as e:
logger.debug(f"报告使用统计失败: {e}")
return False
# 全局统计实例
usage_stats = UsageStatistics()
async def report_user_count():
"""报告用户数量统计"""
try:
logger.info("正在上报用户统计...")
success = await usage_stats.report_usage()
if success:
logger.info("✅ 用户统计上报成功")
else:
logger.debug("用户统计上报失败")
except Exception as e:
logger.debug(f"用户统计异常: {e}")
def get_anonymous_id() -> str:
"""获取匿名用户ID"""
return usage_stats.anonymous_id
# 测试函数
async def test_statistics():
"""测试统计功能"""
print(f"匿名ID: {get_anonymous_id()}")
await report_user_count()
if __name__ == "__main__":
asyncio.run(test_statistics())

12
utils/geetest/__init__.py Normal file
View File

@ -0,0 +1,12 @@
"""
极验滑动验证码服务模块
功能
1. 验证码初始化register
2. 二次验证validate
3. 支持正常模式和宕机降级模式
"""
from .geetest_lib import GeetestLib
from .geetest_config import GeetestConfig
__all__ = ["GeetestLib", "GeetestConfig"]

View File

@ -0,0 +1,35 @@
"""
极验验证码配置
说明
- captcha_id private_key 需要从极验官网申请
- 当前使用的是示例配置生产环境请替换为自己的密钥
"""
import os
class GeetestConfig:
"""极验验证码配置类"""
# 极验分配的captcha_id从环境变量读取有默认值
CAPTCHA_ID = os.getenv("GEETEST_CAPTCHA_ID", "a30cdbb466e9349385762477cb2c7df6")
# 极验分配的私钥(从环境变量读取,有默认值)
PRIVATE_KEY = os.getenv("GEETEST_PRIVATE_KEY", "6f70322308eb29ae0d85516a14a32d2c")
# 用户标识(可选)
USER_ID = os.getenv("GEETEST_USER_ID", "xianyu_system")
# 客户端类型web, h5, native, unknown
CLIENT_TYPE = "web"
# API地址
API_URL = "http://api.geetest.com"
REGISTER_URL = "/register.php"
VALIDATE_URL = "/validate.php"
# 请求超时时间(秒)
TIMEOUT = 5
# SDK版本
VERSION = "python-fastapi:1.0.0"

View File

@ -0,0 +1,374 @@
"""
极验验证码SDK核心库
功能
1. 验证码初始化register- 获取challenge等参数
2. 二次验证validate- 验证用户滑动结果
3. 支持MD5/SHA256/HMAC-SHA256加密
4. 支持宕机降级模式
"""
import hashlib
import hmac
import json
import uuid
from enum import Enum
from typing import Dict, Optional
from dataclasses import dataclass
import httpx
from loguru import logger
from .geetest_config import GeetestConfig
class DigestMod(Enum):
"""加密算法枚举"""
MD5 = "md5"
SHA256 = "sha256"
HMAC_SHA256 = "hmac-sha256"
@dataclass
class GeetestResult:
"""极验返回结果封装"""
status: int = 0 # 1成功0失败
data: str = "" # 返回数据JSON字符串
msg: str = "" # 备注信息
def to_dict(self) -> dict:
"""转换为字典"""
if self.data:
try:
return json.loads(self.data)
except json.JSONDecodeError:
return {}
return {}
class GeetestLib:
"""
极验验证码SDK核心类
使用方法
1. 初始化: gt_lib = GeetestLib()
2. 获取验证码参数: result = await gt_lib.register()
3. 二次验证: result = await gt_lib.validate(challenge, validate, seccode)
"""
def __init__(
self,
captcha_id: Optional[str] = None,
private_key: Optional[str] = None
):
"""
初始化极验SDK
Args:
captcha_id: 极验分配的captcha_id默认从配置读取
private_key: 极验分配的私钥默认从配置读取
"""
self.captcha_id = captcha_id or GeetestConfig.CAPTCHA_ID
self.private_key = private_key or GeetestConfig.PRIVATE_KEY
self.result = GeetestResult()
def _md5_encode(self, value: str) -> str:
"""MD5加密"""
return hashlib.md5(value.encode("utf-8")).hexdigest()
def _sha256_encode(self, value: str) -> str:
"""SHA256加密"""
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _hmac_sha256_encode(self, value: str, key: str) -> str:
"""HMAC-SHA256加密"""
return hmac.new(
key.encode("utf-8"),
value.encode("utf-8"),
hashlib.sha256
).hexdigest()
def _encrypt_challenge(self, origin_challenge: str, digest_mod: DigestMod) -> str:
"""
加密challenge
Args:
origin_challenge: 原始challenge
digest_mod: 加密算法
Returns:
加密后的challenge
"""
if digest_mod == DigestMod.MD5:
return self._md5_encode(origin_challenge + self.private_key)
elif digest_mod == DigestMod.SHA256:
return self._sha256_encode(origin_challenge + self.private_key)
elif digest_mod == DigestMod.HMAC_SHA256:
return self._hmac_sha256_encode(origin_challenge, self.private_key)
else:
return self._md5_encode(origin_challenge + self.private_key)
async def _request_register(self, params: Dict[str, str]) -> str:
"""
向极验发送验证初始化请求
Args:
params: 请求参数
Returns:
原始challenge字符串
"""
params.update({
"gt": self.captcha_id,
"json_format": "1",
"sdk": GeetestConfig.VERSION
})
url = f"{GeetestConfig.API_URL}{GeetestConfig.REGISTER_URL}"
try:
async with httpx.AsyncClient(timeout=GeetestConfig.TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
logger.debug(f"极验register响应: {data}")
return data.get("challenge", "")
except Exception as e:
logger.error(f"极验register请求失败: {e}")
return ""
def _build_register_result(
self,
origin_challenge: Optional[str],
digest_mod: Optional[DigestMod]
) -> None:
"""
构建验证初始化返回数据
Args:
origin_challenge: 原始challenge
digest_mod: 加密算法
"""
# challenge为空或为0表示失败走宕机模式
if not origin_challenge or origin_challenge == "0":
# 本地生成随机challenge
challenge = uuid.uuid4().hex
data = {
"success": 0,
"gt": self.captcha_id,
"challenge": challenge,
"new_captcha": True
}
self.result = GeetestResult(
status=0,
data=json.dumps(data),
msg="初始化接口失败,后续流程走宕机模式"
)
else:
# 正常模式加密challenge
challenge = self._encrypt_challenge(origin_challenge, digest_mod or DigestMod.MD5)
data = {
"success": 1,
"gt": self.captcha_id,
"challenge": challenge,
"new_captcha": True
}
self.result = GeetestResult(
status=1,
data=json.dumps(data),
msg=""
)
async def register(
self,
digest_mod: DigestMod = DigestMod.MD5,
user_id: Optional[str] = None,
client_type: Optional[str] = None
) -> GeetestResult:
"""
验证码初始化
Args:
digest_mod: 加密算法默认MD5
user_id: 用户标识
client_type: 客户端类型
Returns:
GeetestResult对象
"""
logger.info(f"极验register开始: digest_mod={digest_mod.value}")
params = {
"digestmod": digest_mod.value,
"user_id": user_id or GeetestConfig.USER_ID,
"client_type": client_type or GeetestConfig.CLIENT_TYPE
}
origin_challenge = await self._request_register(params)
self._build_register_result(origin_challenge, digest_mod)
logger.info(f"极验register完成: status={self.result.status}")
return self.result
def local_init(self) -> GeetestResult:
"""
本地初始化宕机降级模式
Returns:
GeetestResult对象
"""
logger.info("极验本地初始化(宕机模式)")
self._build_register_result(None, None)
return self.result
async def _request_validate(
self,
challenge: str,
validate: str,
seccode: str,
params: Dict[str, str]
) -> str:
"""
向极验发送二次验证请求
Args:
challenge: challenge
validate: validate
seccode: seccode
params: 额外参数
Returns:
响应的seccode
"""
params.update({
"seccode": seccode,
"json_format": "1",
"challenge": challenge,
"sdk": GeetestConfig.VERSION,
"captchaid": self.captcha_id
})
url = f"{GeetestConfig.API_URL}{GeetestConfig.VALIDATE_URL}"
try:
async with httpx.AsyncClient(timeout=GeetestConfig.TIMEOUT) as client:
response = await client.post(url, data=params)
response.raise_for_status()
data = response.json()
logger.debug(f"极验validate响应: {data}")
return data.get("seccode", "")
except Exception as e:
logger.error(f"极验validate请求失败: {e}")
return ""
def _check_params(self, challenge: str, validate: str, seccode: str) -> bool:
"""
校验二次验证参数
Args:
challenge: challenge
validate: validate
seccode: seccode
Returns:
参数是否有效
"""
return bool(
challenge and challenge.strip() and
validate and validate.strip() and
seccode and seccode.strip()
)
async def success_validate(
self,
challenge: str,
validate: str,
seccode: str,
user_id: Optional[str] = None,
client_type: Optional[str] = None
) -> GeetestResult:
"""
正常模式下的二次验证
Args:
challenge: 验证流水号
validate: 验证结果
seccode: 验证结果加密串
user_id: 用户标识
client_type: 客户端类型
Returns:
GeetestResult对象
"""
logger.info(f"极验二次验证(正常模式): challenge={challenge[:16]}...")
if not self._check_params(challenge, validate, seccode):
self.result = GeetestResult(
status=0,
data="",
msg="正常模式本地校验参数challenge、validate、seccode不可为空"
)
return self.result
params = {
"user_id": user_id or GeetestConfig.USER_ID,
"client_type": client_type or GeetestConfig.CLIENT_TYPE
}
response_seccode = await self._request_validate(challenge, validate, seccode, params)
if not response_seccode:
self.result = GeetestResult(
status=0,
data="",
msg="请求极验validate接口失败"
)
elif response_seccode == "false":
self.result = GeetestResult(
status=0,
data="",
msg="极验二次验证不通过"
)
else:
self.result = GeetestResult(
status=1,
data="",
msg=""
)
logger.info(f"极验二次验证完成: status={self.result.status}")
return self.result
def fail_validate(
self,
challenge: str,
validate: str,
seccode: str
) -> GeetestResult:
"""
宕机模式下的二次验证简单参数校验
Args:
challenge: 验证流水号
validate: 验证结果
seccode: 验证结果加密串
Returns:
GeetestResult对象
"""
logger.info(f"极验二次验证(宕机模式): challenge={challenge[:16] if challenge else 'None'}...")
if not self._check_params(challenge, validate, seccode):
self.result = GeetestResult(
status=0,
data="",
msg="宕机模式本地校验参数challenge、validate、seccode不可为空"
)
else:
self.result = GeetestResult(
status=1,
data="",
msg=""
)
return self.result