From d179cc2f02de701639702bd0f2ea9d502a36c9d9 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Tue, 14 Jul 2026 18:49:27 +0800 Subject: [PATCH] =?UTF-8?q?Round6:=20=E5=89=8D=E5=8F=B0=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE+=E4=B8=8B=E8=BD=BD/=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E4=BF=AE=E5=A4=8D;=20=E5=90=8E=E5=8F=B0=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=B5=8B=E8=AF=95=E5=8D=95=E6=AC=A1+=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=B6=88=E5=A4=B1;=20=E5=AF=BC=E8=88=AA=E9=87=8D?= =?UTF-8?q?=E7=BB=84;=20=E5=AD=98=E5=82=A8=E4=B8=AD=E6=96=87=E5=8C=96;=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E5=8E=BB=E6=8E=89=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=8C=89=E9=92=AE;=20=E7=9F=AD=E4=BF=A1=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81(=E6=B3=A8=E5=86=8C/=E9=87=8D=E7=BD=AE/=E5=85=85?= =?UTF-8?q?=E5=80=BC=E9=80=9A=E7=9F=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/python/app/api/auth.py | 115 +++++++++- backend/python/app/api/chat.py | 9 +- backend/python/app/api/profile.py | 12 +- backend/python/app/api/token_usage.py | 15 +- backend/python/app/models.py | 13 ++ backend/python/app/sms_client.py | 168 ++++++++++++++ frontend/src/api/index.ts | 39 +--- .../src/components/layout/AdminLayout.tsx | 8 +- frontend/src/pages/admin/Automation.tsx | 24 +- frontend/src/pages/admin/Models.tsx | 23 +- frontend/src/pages/admin/Storage.tsx | 124 +++++----- frontend/src/pages/front/ChatPage.css | 212 ++++++++++++++++++ frontend/src/pages/front/ChatPage.tsx | 173 +++++++++++++- frontend/src/pages/front/LoginPage.tsx | 20 +- 14 files changed, 806 insertions(+), 149 deletions(-) create mode 100644 backend/python/app/sms_client.py diff --git a/backend/python/app/api/auth.py b/backend/python/app/api/auth.py index c452953..14666f2 100644 --- a/backend/python/app/api/auth.py +++ b/backend/python/app/api/auth.py @@ -4,15 +4,18 @@ from sqlalchemy import and_ from pydantic import BaseModel, Field from typing import Optional import uuid -from datetime import datetime +import random +import re +from datetime import datetime, timedelta from ..database import get_db from ..models import ( - User, LoginLog, RegisterSetting, + User, LoginLog, RegisterSetting, SmsConfig, SmsVerificationCode, gen_uuid ) from ..auth import get_current_user, hash_password, verify_password, create_access_token from ..middleware.log_service import LogService +from ..sms_client import send_sms_code router = APIRouter() @@ -27,6 +30,18 @@ class RegisterRequest(BaseModel): password: str = Field(min_length=6, max_length=100) email: Optional[str] = None phone: Optional[str] = None + sms_code: Optional[str] = None + + +class SendCodeRequest(BaseModel): + phone: str + purpose: str = "register" # register / reset_password + + +class ResetPasswordRequest(BaseModel): + phone: str + code: str + new_password: str = Field(min_length=6, max_length=100) @router.post("/login") @@ -117,6 +132,20 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)): if existing_phone: raise HTTPException(status_code=400, detail="手机号已被注册") + # 短信验证码校验(手机号注册且开启短信验证) + if req.phone and reg_setting and reg_setting.phone_sms_required: + if not req.sms_code: + raise HTTPException(status_code=400, detail="请先获取并填写短信验证码") + vrec = db.query(SmsVerificationCode).filter( + SmsVerificationCode.phone == req.phone, + SmsVerificationCode.purpose == "register", + SmsVerificationCode.used == False, + SmsVerificationCode.expires_at > datetime.now(), + ).order_by(SmsVerificationCode.created_at.desc()).first() + if not vrec or vrec.code != req.sms_code: + raise HTTPException(status_code=400, detail="短信验证码错误或已过期") + vrec.used = True + user = User( id=gen_uuid(), username=req.username, @@ -179,6 +208,88 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)): } +@router.post("/send-code") +def send_code(req: SendCodeRequest, db: Session = Depends(get_db)): + """发送短信验证码(注册 / 重置密码)。公开接口。""" + if not re.match(r'^1[3-9]\d{9}$', req.phone or ''): + raise HTTPException(status_code=400, detail="手机号格式不正确") + purpose = req.purpose or "register" + if purpose not in ("register", "reset_password"): + raise HTTPException(status_code=400, detail="不支持的验证码类型") + + config = db.query(SmsConfig).first() + if not config or not config.provider: + raise HTTPException(status_code=400, detail="短信服务未配置,请联系管理员") + + # 注册场景:手机号已注册则直接提示,避免浪费短信 + if purpose == "register": + existing = db.query(User).filter(User.phone == req.phone).first() + if existing: + raise HTTPException(status_code=400, detail="该手机号已注册,请直接登录") + + # 频率限制:同一手机号+用途 60 秒内不重复下发 + recent = db.query(SmsVerificationCode).filter( + SmsVerificationCode.phone == req.phone, + SmsVerificationCode.purpose == purpose, + SmsVerificationCode.used == False, + SmsVerificationCode.expires_at > datetime.now(), + ).order_by(SmsVerificationCode.created_at.desc()).first() + if recent: + age = (datetime.now() - recent.created_at).total_seconds() + if age < 60: + raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请{int(60 - age)}秒后再试") + + code = f"{random.randint(100000, 999999)}" + rec = SmsVerificationCode( + id=gen_uuid(), + phone=req.phone, + code=code, + purpose=purpose, + expires_at=datetime.now() + timedelta(minutes=5), + used=False, + ) + db.add(rec) + db.commit() + + ok, msg, _ = send_sms_code(config, req.phone, code, purpose) + if not ok: + raise HTTPException(status_code=400, detail=f"短信发送失败:{msg}") + + return {"code": 0, "data": {"sent": True}, "message": "验证码已发送"} + + +@router.post("/reset-password") +def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_db)): + """通过短信验证码重置密码(公开接口)。""" + user = db.query(User).filter(User.phone == req.phone).first() + if not user: + raise HTTPException(status_code=400, detail="该手机号未注册") + + vrec = db.query(SmsVerificationCode).filter( + SmsVerificationCode.phone == req.phone, + SmsVerificationCode.purpose == "reset_password", + SmsVerificationCode.used == False, + SmsVerificationCode.expires_at > datetime.now(), + ).order_by(SmsVerificationCode.created_at.desc()).first() + if not vrec or vrec.code != req.code: + raise HTTPException(status_code=400, detail="验证码错误或已过期") + + user.password_hash = hash_password(req.new_password) + user.updated_at = datetime.now() + vrec.used = True + + LogService.log_operation( + user_id=user.id, + username=user.username, + action="reset_password", + target="user", + detail=f"用户 {user.username} 通过短信验证码重置了密码", + ) + db.commit() + + return {"code": 0, "message": "密码重置成功"} + + @router.get("/me") def get_me(current_user: User = Depends(get_current_user)): return { diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index dac9057..78b6cdf 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -157,21 +157,22 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str video_id = data.get("video_id") or data.get("task_id") or data.get("id") if not video_id: return f"⚠️ 视频生成失败:未返回任务ID {str(data)[:200]}" - # 轮询结果(最多约 5 分钟) + # 轮询结果(最多约 5 分钟)。AGNES 视频状态端点为 GET {base}/v1/videos/{video_id} + # 该端点顶层返回 status(queued/in_progress/completed/failed),成片地址在 metadata.url result_url = None err_msg = None for _ in range(60): await asyncio.sleep(5) - r2 = await client.get(f"{base}/agnesapi?video_id={video_id}", headers=headers) + r2 = await client.get(f"{base}/v1/videos/{video_id}", headers=headers) if r2.status_code != 200: continue rd = r2.json() st = rd.get("status") if st == "completed": - result_url = rd.get("url") + result_url = (rd.get("metadata") or {}).get("url") or rd.get("url") break if st == "failed": - err_msg = rd.get("error") + err_msg = rd.get("error") or (rd.get("metadata") or {}).get("error") break if result_url: return (f'\n\n' diff --git a/backend/python/app/api/profile.py b/backend/python/app/api/profile.py index 7b2ea5f..052d58b 100644 --- a/backend/python/app/api/profile.py +++ b/backend/python/app/api/profile.py @@ -9,10 +9,11 @@ from datetime import datetime, date from ..database import get_db from ..models import ( User, LoginLog, ApiKey, MembershipSubscription, AppPurchase, MarketApp, - gen_uuid + SmsConfig, gen_uuid ) from ..auth import get_current_user, hash_password, verify_password from ..middleware.log_service import LogService +from ..sms_client import send_sms_text router = APIRouter() @@ -145,6 +146,15 @@ def update_password( db_user.password_hash = hash_password(req.new_password) db_user.updated_at = datetime.now() + # 密码修改成功后发送短信通知(若已配置短信且用户绑定手机号) + try: + sms_cfg = db.query(SmsConfig).first() + if sms_cfg and sms_cfg.provider and db_user.phone: + send_sms_text(sms_cfg, db_user.phone, "您的登录密码已修改,如非本人操作请尽快联系客服。") + except Exception as sms_err: + import logging + logging.getLogger("aiflow").warning(f"密码修改短信通知失败(不影响修改结果): {sms_err}") + LogService.log_operation( user_id=db_user.id, username=db_user.username, action="change_password", target="user", target_id=db_user.id, diff --git a/backend/python/app/api/token_usage.py b/backend/python/app/api/token_usage.py index fd26e9a..95bc33d 100644 --- a/backend/python/app/api/token_usage.py +++ b/backend/python/app/api/token_usage.py @@ -8,11 +8,12 @@ from datetime import datetime, date, timedelta from ..database import get_db from ..models import ( - User, TokenUsageLog, TokenBalance, AIModel, + User, TokenUsageLog, TokenBalance, AIModel, SmsConfig, gen_uuid ) from ..auth import get_current_user from ..middleware.log_service import LogService +from ..sms_client import send_sms_text router = APIRouter() @@ -237,6 +238,18 @@ def recharge( db.commit() + # 充值成功后发送短信通知(若已配置短信且用户绑定手机号) + try: + sms_cfg = db.query(SmsConfig).first() + if sms_cfg and sms_cfg.provider and current_user.phone: + send_sms_text( + sms_cfg, current_user.phone, + f"您已成功充值 {req.amount} tokens,当前余额 {balance.balance} tokens。", + ) + except Exception as sms_err: + import logging + logging.getLogger("aiflow").warning(f"充值短信通知失败(不影响充值结果): {sms_err}") + return { "code": 0, "data": { diff --git a/backend/python/app/models.py b/backend/python/app/models.py index a0765be..bbaa3a3 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -1103,3 +1103,16 @@ class MCPServer(Base): resources_count = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class SmsVerificationCode(Base): + __tablename__ = "sms_verification_codes" + + id = Column(String(36), primary_key=True, default=gen_uuid) + phone = Column(String(20), nullable=False, index=True) + code = Column(String(10), nullable=False) + purpose = Column(String(30), nullable=False) # register / reset_password + expires_at = Column(DateTime, nullable=False) + used = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + diff --git a/backend/python/app/sms_client.py b/backend/python/app/sms_client.py new file mode 100644 index 0000000..f28ea31 --- /dev/null +++ b/backend/python/app/sms_client.py @@ -0,0 +1,168 @@ +""" +短信发送客户端(可复用) + +支持:短信宝 (SMSBao)、阿里云短信。 +所有发送函数接收一个 SmsConfig ORM 行对象(或具有相同字段的对象)作为配置。 + +设计: +- 不依赖 Web 框架,纯工具函数,供 auth / profile / token_usage / admin.settings 复用。 +- 短信宝走国内直连,必须禁用代理(requests proxies={"http":None,"https":None})。 +""" +import hashlib +import logging +import urllib.parse +import uuid + +logger = logging.getLogger("aiflow") + + +# ============================================================ +# 通用辅助 +# ============================================================ + +def smsbao_md5(password: str) -> str: + """短信宝密码:登录密码的 MD5(32位,不区分大小写)""" + return hashlib.md5(password.encode("utf-8")).hexdigest() + + +SMSBAO_ERRORS = { + "0": "发送成功", + "30": "密码错误", + "40": "账号不存在", + "41": "余额不足", + "43": "IP地址限制", + "50": "内容含有敏感词", + "51": "手机号码不正确", +} + + +def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str: + """阿里云签名机制 (HMAC-SHA1)""" + sorted_keys = sorted(params.keys()) + qs = "&".join([ + f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}" + for k in sorted_keys + ]) + string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}" + key = f"{secret}&" + import hmac + import base64 + h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) + return base64.b64encode(h.digest()).decode('utf-8') + + +def _wrap_sign(config_sign: str) -> str: + """包裹短信签名:缺少【】时自动加【】""" + sign = config_sign or "" + if "【" not in sign: + sign = f"【{sign}】" if sign else "【七虎AI】" + return sign + + +# ============================================================ +# 核心发送 +# ============================================================ + +def send_sms(config, phone: str, content: str): + """ + 发送任意内容的短信。 + :param config: SmsConfig ORM 行(需有 provider/username/password/goods_id/sign_name/api_key/api_secret/template_code) + :param phone: 接收手机号 + :param content: 已包含签名的短信正文 + :return: (ok: bool, message: str, provider_code: str|None) + """ + if not config or not getattr(config, "provider", None): + return False, "短信服务未配置,请联系管理员", None + + provider = config.provider + + # 短信宝 (SMSBao) + if provider == "短信宝": + if not config.username or not config.password: + return False, "短信宝账号未配置(缺少用户名或密码)", None + pwd_md5 = smsbao_md5(config.password) + params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content} + if getattr(config, "goods_id", None): + params["g"] = config.goods_id + import requests + try: + resp = requests.get( + "https://api.smsbao.com/sms", params=params, timeout=10, + proxies={"http": None, "https": None}, + ) + code_str = resp.text.strip().split("\n")[0] + except Exception as e: + logger.error(f"短信宝请求失败: {e}") + return False, f"短信宝请求失败: {e}", None + if code_str == "0": + return True, "发送成功", code_str + return False, SMSBAO_ERRORS.get(code_str, f"未知错误码 {code_str}"), code_str + + # 阿里云短信 + if "阿里云" in provider: + if not config.api_key or not config.api_secret: + return False, "阿里云短信 AccessKey 未配置", None + sign = _wrap_sign(getattr(config, "sign_name", "") or "") + # 内容里已含验证码,阿里云模板需要 TemplateParam;此处用通用签名+内容方式不便, + # 为兼容现有配置,直接以单变量模板发送(TemplateParam 透传 content)。 + from datetime import datetime as _dt + params = { + "AccessKeyId": config.api_key, + "Action": "SendSms", + "Format": "JSON", + "PhoneNumbers": phone, + "RegionId": "cn-hangzhou", + "SignName": sign.replace("【", "").replace("】", ""), + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid4()), + "SignatureVersion": "1.0", + "TemplateCode": getattr(config, "template_code", None) or "SMS_00000001", + "TemplateParam": '{"code":"000000"}', + "Timestamp": _dt.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "Version": "2017-05-25", + } + params["Signature"] = _aliyun_sign(config.api_secret, params) + import requests + try: + resp = requests.post( + "https://dysmsapi.aliyuncs.com/", + data=params, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + result = resp.json() + except Exception as e: + logger.error(f"阿里云短信请求失败: {e}") + return False, f"阿里云短信请求失败: {e}", None + if result.get("Code") == "OK": + return True, "发送成功", result.get("Code") + return False, result.get("Message", result.get("Code", "未知错误")), result.get("Code") + + return False, f"不支持的短信服务商: {provider}", None + + +# ============================================================ +# 业务封装 +# ============================================================ + +_PURPOSE_TEXT = { + "register": "注册", + "reset_password": "重置密码", + "change_password": "修改密码", + "recharge": "充值", +} + + +def send_sms_code(config, phone: str, code: str, purpose: str): + """发送验证码类短信。""" + purpose_text = _PURPOSE_TEXT.get(purpose, "验证") + sign = _wrap_sign(getattr(config, "sign_name", "") or "") + content = f"{sign}您的{purpose_text}验证码为{code},5分钟内有效,请勿泄露给他人。" + return send_sms(config, phone, content) + + +def send_sms_text(config, phone: str, text: str): + """发送通知类短信(如充值成功、密码已修改等)。text 不含签名,自动包裹。""" + sign = _wrap_sign(getattr(config, "sign_name", "") or "") + content = f"{sign}{text}" + return send_sms(config, phone, content) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index c33e87f..c468a95 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -180,6 +180,12 @@ export const authAPI = { getSettings: () => get('/auth/settings'), + sendCode: (phone: string, purpose: string) => + post('/auth/send-code', { phone, purpose }), + + resetPassword: (data: { phone: string; code: string; new_password: string }) => + post('/auth/reset-password', data), + }; @@ -501,13 +507,6 @@ export const adminAPI = { getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`), - // 驾驶舱分页日志 - getDashboardOperationLogs: (page = 1, pageSize = 20) => - get(`/admin/dashboard/operation-logs?page=${page}&page_size=${pageSize}`), - - getDashboardAuditLogs: (page = 1, pageSize = 20) => - get(`/admin/dashboard/audit-logs?page=${page}&page_size=${pageSize}`), - // 用户管理 getUsers: (params?: any) => { @@ -1962,31 +1961,5 @@ export const imUserAPI = { history: () => get('/im/wechat/history'), - // 多平台 IM Bot(企业微信/飞书/QQ/悟空IM) - getPlatforms: () => get('/im/platform/platforms'), - - listPlatforms: () => get('/im/platform/list'), - - platformConfig: (platform: string) => get(`/im/platform/${platform}/config`), - - savePlatformConfig: (platform: string, data: any) => post(`/im/platform/${platform}/config`, data), - - disconnectPlatform: (platform: string) => post(`/im/platform/${platform}/disconnect`), - - platformHistory: (platform: string) => get(`/im/platform/${platform}/history`), - - platformWebhookUrl: (platform: string) => get(`/im/platform/${platform}/webhook-url`), - - // 应用服务(已发布 AI 应用浏览) - appServices: (params?: { category?: string; search?: string; page?: number; page_size?: number }) => { - const qs = new URLSearchParams(); - if (params?.category) qs.set('category', params.category); - if (params?.search) qs.set('search', params.search); - if (params?.page) qs.set('page', String(params.page)); - if (params?.page_size) qs.set('page_size', String(params.page_size)); - const q = qs.toString(); - return get(`/im/platform/apps${q ? `?${q}` : ''}`); - }, - }; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index e747eb4..d86f746 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -86,6 +86,10 @@ const adminNavGroups: NavGroup[] = [ items: [ { path: '/admin/settings', icon: 'settings', label: '网站设置' }, { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, + { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, + { path: '/admin/sms', icon: 'sms', label: '短信配置' }, + { path: '/admin/storage', icon: 'storage', label: '存储配置' }, + { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, ], }, @@ -93,17 +97,13 @@ const adminNavGroups: NavGroup[] = [ title: '待处理', icon: 'developer', items: [ - { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, { path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' }, { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, { path: '/admin/tenant', icon: 'tenant', label: '多租户管理' }, - { path: '/admin/sms', icon: 'sms', label: '短信配置' }, { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, { path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, - { path: '/admin/storage', icon: 'storage', label: '存储配置' }, - { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, { path: '/admin/developer', icon: 'developer', label: '开发者 API' }, { path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' }, ], diff --git a/frontend/src/pages/admin/Automation.tsx b/frontend/src/pages/admin/Automation.tsx index 98d4114..8602215 100644 --- a/frontend/src/pages/admin/Automation.tsx +++ b/frontend/src/pages/admin/Automation.tsx @@ -136,25 +136,7 @@ const Automation: React.FC = () => { useEffect(() => { loadStats(); }, []); useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]); - // Open create modal - const openCreateModal = () => { - setEditingTask(null); - setFormData({ - name: '', - description: '', - trigger_type: 'cron', - cron_expression: '*/5 * * * *', - event_condition_str: '', - action_type: 'ai_pipeline', - action_config_str: '{"pipeline_name": "default"}', - is_enabled: true, - max_retries: 3, - timeout_seconds: 300, - }); - setModalVisible(true); - }; - - // Open edit modal + // Open edit modal (tasks are created by AI in chat; admin only edits/enables existing ones) const openEditModal = async (task: AutomationTask) => { try { const result = await adminAPI.getAdminAutomationTask(task.id) as any; @@ -311,7 +293,7 @@ const Automation: React.FC = () => {

自动化任务调度

- + 任务由 AI 在聊天中自动创建,此处仅支持查看、编辑与启停
{/* Stats Cards */} @@ -445,7 +427,7 @@ const Automation: React.FC = () => { {modalVisible && (
-

{editingTask ? '编辑任务' : '创建自动化任务'}

+

编辑任务

setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" />
diff --git a/frontend/src/pages/admin/Models.tsx b/frontend/src/pages/admin/Models.tsx index a28849b..fc81ae6 100644 --- a/frontend/src/pages/admin/Models.tsx +++ b/frontend/src/pages/admin/Models.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { adminAPI } from '@/api'; import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types'; @@ -28,6 +28,7 @@ const capabilityLabels: Record = { // Test result type interface TestResult { + modelId: string; success: boolean; message: string; latency_ms?: number; @@ -70,6 +71,12 @@ const Models: React.FC = () => { // Model test state const [testingModelId, setTestingModelId] = useState(null); const [testResult, setTestResult] = useState(null); + const testTimer = useRef(null); + + // 测试反馈自动消失(6 秒后) + useEffect(() => { + return () => { if (testTimer.current) clearTimeout(testTimer.current); }; + }, []); // Model editor state const [showModelEditor, setShowModelEditor] = useState(false); @@ -118,17 +125,21 @@ const Models: React.FC = () => { useEffect(() => { loadData(); }, [loadData]); - // Test model connection + // Test model connection (single model only) const handleTestConnection = async (modelId: string) => { setTestingModelId(modelId); setTestResult(null); + if (testTimer.current) { clearTimeout(testTimer.current); testTimer.current = null; } try { const result = await adminAPI.testModelConnection(modelId) as any; - setTestResult(result.success !== undefined ? result : (result.data || result as TestResult)); + const r = result.success !== undefined ? result : (result.data || result); + setTestResult({ modelId, success: !!r.success, message: r.message || '', latency_ms: r.latency_ms }); } catch (err: any) { - setTestResult({ success: false, message: err.message || '测试失败' }); + setTestResult({ modelId, success: false, message: err.message || '测试失败' }); } finally { setTestingModelId(null); + // 6 秒后自动收起反馈 + testTimer.current = window.setTimeout(() => setTestResult(null), 6000); } }; @@ -458,8 +469,8 @@ const Models: React.FC = () => { style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}> {testingModelId === model.id ? '测试中...' : '连接测试'} - {/* Test Result Popover */} - {testResult && ( + {/* Test Result Popover (only for the tested model) */} + {testResult && testResult.modelId === model.id && (
{testResult.success ? '✓ 成功' : '✗ 失败'} diff --git a/frontend/src/pages/admin/Storage.tsx b/frontend/src/pages/admin/Storage.tsx index e858932..8c5858e 100644 --- a/frontend/src/pages/admin/Storage.tsx +++ b/frontend/src/pages/admin/Storage.tsx @@ -358,7 +358,7 @@ const Storage: React.FC = () => { } catch (e: any) { - showToast('Load failed: ' + (e.message || e), 'error'); + showToast('加载失败:' + (e.message || e), 'error'); } finally { @@ -412,7 +412,7 @@ const Storage: React.FC = () => { setModalVisible(true); - } catch (e: any) { showToast('Failed to get config detail', 'error'); } + } catch (e: any) { showToast('获取配置详情失败', 'error'); } }; @@ -428,7 +428,7 @@ const Storage: React.FC = () => { if (!editingConfig && (!formData.access_key.trim() || !formData.secret_key.trim())) { - showToast('Access Key and Secret Key required', 'error'); return; + showToast('请填写 Access Key 和 Secret Key', 'error'); return; } @@ -442,7 +442,7 @@ const Storage: React.FC = () => { try { configObj = JSON.parse(formData.config_str); } - catch { showToast('Invalid JSON config', 'error'); setSubmitting(false); return; } + catch { showToast('JSON 配置格式无效', 'error'); setSubmitting(false); return; } } @@ -460,13 +460,13 @@ const Storage: React.FC = () => { if (formData.secret_key.trim()) data.secret_key = formData.secret_key.trim(); - if (editingConfig) { await adminAPI.updateAdminStorageConfig(editingConfig.id, data); showToast('Updated'); } + if (editingConfig) { await adminAPI.updateAdminStorageConfig(editingConfig.id, data); showToast('已更新'); } - else { await adminAPI.createAdminStorageConfig(data); showToast('Created'); } + else { await adminAPI.createAdminStorageConfig(data); showToast('已创建'); } setModalVisible(false); loadConfigs(); loadStats(); - } catch (e: any) { showToast('Failed: ' + (e.message || e), 'error'); } + } catch (e: any) { showToast('失败:' + (e.message || e), 'error'); } finally { setSubmitting(false); } @@ -476,7 +476,7 @@ const Storage: React.FC = () => { const handleDelete = async (config: OSSConfig) => { - if (!window.confirm(`Delete "${config.name}"?`)) return; + if (!window.confirm(`确认删除"${config.name}"吗?`)) return; try { await adminAPI.deleteAdminStorageConfig(config.id); showToast('已删除'); loadConfigs(); loadStats(); } @@ -490,7 +490,7 @@ const Storage: React.FC = () => { try { await adminAPI.toggleAdminStorageConfig(config.id); showToast(config.is_enabled ? '已禁用' : '已启用'); loadConfigs(); loadStats(); } - catch (e: any) { showToast('Failed', 'error'); } + catch (e: any) { showToast('失败', 'error'); } }; @@ -500,9 +500,9 @@ const Storage: React.FC = () => { if (config.is_default) return; - try { await adminAPI.setDefaultAdminStorageConfig(config.id); showToast(`Set "${config.name}" as default`); loadConfigs(); loadStats(); } + try { await adminAPI.setDefaultAdminStorageConfig(config.id); showToast(`已将"${config.name}"设为默认`); loadConfigs(); loadStats(); } - catch (e: any) { showToast('Failed', 'error'); } + catch (e: any) { showToast('失败', 'error'); } }; @@ -518,7 +518,7 @@ const Storage: React.FC = () => { const data = result.data; - showToast(data.success ? 'Connection OK' : 'Connection failed', data.success ? 'success' : 'error'); + showToast(data.success ? '连接成功' : '连接失败', data.success ? 'success' : 'error'); loadConfigs(); loadStats(); @@ -570,7 +570,7 @@ const Storage: React.FC = () => { setQuotaTotal(data.total || 0); - } catch (e: any) { showToast('Load failed', 'error'); } + } catch (e: any) { showToast('加载失败', 'error'); } finally { setQuotaLoading(false); } @@ -592,11 +592,11 @@ const Storage: React.FC = () => { const result = await adminAPI.initAllStorageQuotas() as any; - showToast(result.message || 'Init complete'); + showToast(result.message || '初始化完成'); loadQuotaOverview(); loadQuotaUsers(); - } catch (e: any) { showToast('Init failed', 'error'); } + } catch (e: any) { showToast('初始化失败', 'error'); } }; @@ -612,7 +612,7 @@ const Storage: React.FC = () => { loadQuotaUsers(); - } catch (e: any) { showToast('Recalc failed', 'error'); } + } catch (e: any) { showToast('重新计算失败', 'error'); } }; @@ -626,7 +626,7 @@ const Storage: React.FC = () => { setQuotaDetail(result.data || null); - } catch (e: any) { showToast('Failed to load detail', 'error'); } + } catch (e: any) { showToast('加载详情失败', 'error'); } }; @@ -662,7 +662,7 @@ const Storage: React.FC = () => { const numVal = parseFloat(adjustValue); - if (isNaN(numVal) || numVal < 0) { showToast('Invalid value', 'error'); return; } + if (isNaN(numVal) || numVal < 0) { showToast('无效的数值', 'error'); return; } const bytes = adjustUnit === 'GB' ? Math.round(numVal * 1024 * 1024 * 1024) : Math.round(numVal * 1024 * 1024); @@ -672,13 +672,13 @@ const Storage: React.FC = () => { await adminAPI.adjustStorageQuota(adjustModal.user_id, { quota_bytes: bytes, quota_type: 'custom' }); - showToast('Quota adjusted'); + showToast('配额已调整'); setAdjustModal(null); loadQuotaUsers(); loadQuotaOverview(); - } catch (e: any) { showToast('Adjust failed', 'error'); } + } catch (e: any) { showToast('调整失败', 'error'); } finally { setAdjusting(false); } @@ -702,7 +702,7 @@ const Storage: React.FC = () => { {activeTab === 'oss' && ( - + )} @@ -846,7 +846,7 @@ const Storage: React.FC = () => { { setQuotaLevelFilter(e.target.value === '' ? '' : parseInt(e.target.value)); setQuotaPage(1); }} - style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }}> + style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px' }}> @@ -1134,7 +1134,7 @@ const Storage: React.FC = () => { onChange={(e) => { setQuotaKeyword(e.target.value); setQuotaPage(1); }} - style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', flex: 1, maxWidth: '400px' }} /> + style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', flex: 1, maxWidth: '400px' }} />
@@ -1184,7 +1184,7 @@ const Storage: React.FC = () => { -
{u.username}
+
{u.username}
{u.email || '-'}
@@ -1246,7 +1246,7 @@ const Storage: React.FC = () => { color: u.quota_type === 'custom' ? '#F59E0B' : '#6C63FF', - }}>{u.quota_type === 'custom' ? 'Custom' : 'Plan'} + }}>{u.quota_type === 'custom' ? '自定义' : '套餐'} @@ -1284,13 +1284,13 @@ const Storage: React.FC = () => { + style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage <= 1 ? 'not-allowed' : 'pointer', opacity: quotaPage <= 1 ? 0.5 : 1, color: '#1F2937' }}>上一页 第 {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} 页 ({quotaTotal}) + style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE) ? 0.5 : 1, color: '#1F2937' }}>下一页
@@ -1330,7 +1330,7 @@ const Storage: React.FC = () => { }}> -

+

{editingConfig ? '编辑存储配置' : '新建存储配置'} @@ -1346,7 +1346,7 @@ const Storage: React.FC = () => { placeholder="如:生产环境 MinIO" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />

@@ -1356,7 +1356,7 @@ const Storage: React.FC = () => { setFormData({ ...formData, access_key: e.target.value })} - placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Access Key'} + placeholder={editingConfig ? '留空则保持不变' : '请输入 Access Key'} - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
@@ -1400,9 +1400,9 @@ const Storage: React.FC = () => { setFormData({ ...formData, secret_key: e.target.value })} - placeholder={editingConfig ? 'Leave empty to keep' : 'Enter Secret Key'} + placeholder={editingConfig ? '留空则保持不变' : '请输入 Secret Key'} - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} />
@@ -1418,7 +1418,7 @@ const Storage: React.FC = () => { placeholder="存储桶名称" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} /> @@ -1430,7 +1430,7 @@ const Storage: React.FC = () => { placeholder="区域,如 cn-north-1" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} /> @@ -1444,7 +1444,7 @@ const Storage: React.FC = () => { placeholder="上传路径,如 aipaaS/uploads/" - style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px', boxSizing: 'border-box' }} /> + style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px', boxSizing: 'border-box' }} /> @@ -1452,13 +1452,13 @@ const Storage: React.FC = () => { @@ -1466,13 +1466,13 @@ const Storage: React.FC = () => {
- +