修复已知bug,完善系统功能
This commit is contained in:
parent
347ee75985
commit
446320b62c
7
Start.py
7
Start.py
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
112
db_manager.py
112
db_manager.py
@ -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:
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
// ========== 数据管理 ==========
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: '后端暂未实现订单状态更新接口' }
|
||||
}
|
||||
|
||||
@ -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 || '发送测试邮件失败' }
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码(管理员)
|
||||
|
||||
48
frontend/src/components/common/DisclaimerContent.tsx
Normal file
48
frontend/src/components/common/DisclaimerContent.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
92
frontend/src/components/common/DisclaimerModal.tsx
Normal file
92
frontend/src/components/common/DisclaimerModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
frontend/src/components/common/GeetestCaptcha.tsx
Normal file
247
frontend/src/components/common/GeetestCaptcha.tsx
Normal 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
|
||||
@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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` : '发送'}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
19
frontend/src/pages/disclaimer/Disclaimer.tsx
Normal file
19
frontend/src/pages/disclaimer/Disclaimer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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}页)
|
||||
获取中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
424
reply_server.py
424
reply_server.py
@ -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():
|
||||
"""
|
||||
获取极验验证码初始化参数
|
||||
|
||||
前端调用此接口获取gt、challenge等参数,用于初始化验证码组件
|
||||
"""
|
||||
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
|
||||
|
||||
488
static/assets/index-BF-GL3W8.js
Normal file
488
static/assets/index-BF-GL3W8.js
Normal file
File diff suppressed because one or more lines are too long
1
static/assets/index-wyd100t0.css
Normal file
1
static/assets/index-wyd100t0.css
Normal file
File diff suppressed because one or more lines are too long
10
static/favicon.svg
Normal file
10
static/favicon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6"/>
|
||||
<stop offset="100%" style="stop-color:#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="8" fill="url(#grad)"/>
|
||||
<path d="M16 7C11.03 7 7 10.58 7 15c0 2.5 1.4 4.7 3.5 6.1L9 25l4.5-2.3c.8.2 1.6.3 2.5.3 4.97 0 9-3.58 9-8s-4.03-8-9-8z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
27
static/index.html
Normal file
27
static/index.html
Normal 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
BIN
static/static/qq-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
BIN
static/static/wechat-group.png
Normal file
BIN
static/static/wechat-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
BIN
static/static/wechat-group1.png
Normal file
BIN
static/static/wechat-group1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
BIN
static/static/wechat.png
Normal file
BIN
static/static/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
static/static/xianyu-group.png
Normal file
BIN
static/static/xianyu-group.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 733 KiB |
@ -1 +1 @@
|
||||
v1.0.4
|
||||
v1.0.5
|
||||
|
||||
@ -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
12
utils/geetest/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""
|
||||
极验滑动验证码服务模块
|
||||
|
||||
功能:
|
||||
1. 验证码初始化(register)
|
||||
2. 二次验证(validate)
|
||||
3. 支持正常模式和宕机降级模式
|
||||
"""
|
||||
from .geetest_lib import GeetestLib
|
||||
from .geetest_config import GeetestConfig
|
||||
|
||||
__all__ = ["GeetestLib", "GeetestConfig"]
|
||||
35
utils/geetest/geetest_config.py
Normal file
35
utils/geetest/geetest_config.py
Normal 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"
|
||||
374
utils/geetest/geetest_lib.py
Normal file
374
utils/geetest/geetest_lib.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user