From 7c71d193fc2e18272074cdd53151c1b568d73493 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Fri, 10 Jul 2026 09:54:49 +0800 Subject: [PATCH] fix(email): support implicit SSL (port 465) for SMTP test; add use_ssl field + encryption selector --- backend/python/app/api/admin/settings.py | 1185 ++++++++-------- backend/python/app/models.py | 2 + frontend/src/pages/admin/SiteSettings.tsx | 1571 +++++++++++---------- 3 files changed, 1407 insertions(+), 1351 deletions(-) diff --git a/backend/python/app/api/admin/settings.py b/backend/python/app/api/admin/settings.py index 51cb3c0..0e6a076 100644 --- a/backend/python/app/api/admin/settings.py +++ b/backend/python/app/api/admin/settings.py @@ -1,579 +1,606 @@ -""" -网站基础设置 API(管理员) -""" -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from ...database import get_db, SessionLocal -from ...models import SiteSetting, EmailConfig, SmsConfig, User, AIModel -from ...auth import get_current_admin -from ...middleware.log_service import LogService -import uuid -import smtplib -import hmac -import hashlib -import base64 -import urllib.parse -import time as time_module -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from datetime import datetime -from pydantic import BaseModel -from typing import Optional, Dict, Any -import logging - -logger = logging.getLogger("aiflow") - -router = APIRouter() - - -# ============================================================ -# Pydantic Models -# ============================================================ - -class EmailConfigUpdate(BaseModel): - smtp_host: Optional[str] = None - smtp_port: Optional[int] = None - username: Optional[str] = None - password: Optional[str] = None - from_address: Optional[str] = None - use_tls: Optional[bool] = None - - -class SmsConfigUpdate(BaseModel): - provider: Optional[str] = None - api_key: Optional[str] = None - api_secret: Optional[str] = None - sign_name: Optional[str] = None - template_code: Optional[str] = None - - -# ============================================================ -# 网站设置 -# ============================================================ - -@router.get("/site") -async def get_site_settings( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取网站设置(返回所有 key-value 对)""" - settings = db.query(SiteSetting).all() - result = {} - for s in settings: - result[s.key] = s.value - return {"code": 0, "data": result, "message": "ok"} - - -@router.put("/site") -async def update_site_settings( - settings: dict, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """批量更新网站设置""" - for key, value in settings.items(): - existing = db.query(SiteSetting).filter(SiteSetting.key == key).first() - if existing: - existing.value = str(value) if value is not None else None - existing.updated_at = datetime.now() - else: - new_setting = SiteSetting( - id=str(uuid.uuid4()), - key=key, - value=str(value) if value is not None else None, - ) - db.add(new_setting) - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_site_settings", - target_type="site_settings", - detail=f"更新网站设置: {list(settings.keys())}", - ) - - return {"message": "网站设置已更新"} - - -# ============================================================ -# 邮件配置 -# ============================================================ - -@router.get("/email") -async def get_email_config( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取邮件配置""" - config = db.query(EmailConfig).first() - if not config: - return {"code": 0, "data": { - "smtp_host": "", - "smtp_port": 587, - "username": "", - "password": "", - "from_address": "", - "use_tls": True, - }, "message": "ok"} - - return {"code": 0, "data": { - "id": config.id, - "smtp_host": config.smtp_host, - "smtp_port": config.smtp_port, - "username": config.username, - "password": config.password, - "from_address": config.from_address, - "use_tls": config.use_tls, - }, "message": "ok"} - - -@router.put("/email") -async def update_email_config( - req: EmailConfigUpdate, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新邮件配置""" - config = db.query(EmailConfig).first() - if not config: - # 必填字段校验:不允许空值 - if not req.smtp_host: - raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") - if not req.username: - raise HTTPException(status_code=400, detail="SMTP用户名不能为空") - if not req.password: - raise HTTPException(status_code=400, detail="SMTP密码不能为空") - if not req.from_address: - raise HTTPException(status_code=400, detail="发件地址不能为空") - config = EmailConfig( - id=str(uuid.uuid4()), - smtp_host=req.smtp_host or "", - smtp_port=req.smtp_port or 587, - username=req.username or "", - password=req.password or "", - from_address=req.from_address or "", - use_tls=req.use_tls if req.use_tls is not None else True, - ) - db.add(config) - else: - if req.smtp_host is not None: - if not req.smtp_host.strip(): - raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") - config.smtp_host = req.smtp_host - if req.smtp_port is not None: - config.smtp_port = req.smtp_port - if req.username is not None: - if not req.username.strip(): - raise HTTPException(status_code=400, detail="SMTP用户名不能为空") - config.username = req.username - if req.password is not None: - if not req.password.strip(): - raise HTTPException(status_code=400, detail="SMTP密码不能为空") - config.password = req.password - if req.from_address is not None: - if not req.from_address.strip(): - raise HTTPException(status_code=400, detail="发件地址不能为空") - config.from_address = req.from_address - if req.use_tls is not None: - config.use_tls = req.use_tls - config.updated_at = datetime.now() - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_email_config", - target_type="email_config", - detail="更新邮件配置", - ) - - return {"code": 0, "data": None, "message": "邮件配置已更新"} - - -# ============================================================ -# 短信配置 -# ============================================================ - -@router.get("/sms") -async def get_sms_config( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取短信配置""" - config = db.query(SmsConfig).first() - if not config: - return {"code": 0, "data": { - "provider": "", - "api_key": "", - "api_secret": "", - "sign_name": "", - "template_code": "", - }, "message": "ok"} - - return {"code": 0, "data": { - "id": config.id, - "provider": config.provider, - "api_key": config.api_key, - "api_secret": config.api_secret, - "sign_name": config.sign_name, - "template_code": config.template_code, - }, "message": "ok"} - - -@router.put("/sms") -async def update_sms_config( - req: SmsConfigUpdate, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新短信配置""" - config = db.query(SmsConfig).first() - if not config: - config = SmsConfig( - id=str(uuid.uuid4()), - provider=req.provider or "", - api_key=req.api_key or "", - api_secret=req.api_secret or "", - sign_name=req.sign_name, - template_code=req.template_code, - ) - db.add(config) - else: - if req.provider is not None: - config.provider = req.provider - if req.api_key is not None: - config.api_key = req.api_key - if req.api_secret is not None: - config.api_secret = req.api_secret - if req.sign_name is not None: - config.sign_name = req.sign_name - if req.template_code is not None: - config.template_code = req.template_code - config.updated_at = datetime.now() - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_sms_config", - target_type="sms_config", - detail="更新短信配置", - ) - - return {"message": "短信配置已更新"} - - - -# ============================================================ -# Logo 上传 -# ============================================================ - -from fastapi import UploadFile, File - -@router.post("/logo/upload") -async def upload_logo( - file: UploadFile = File(...), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """上传平台Logo,保存到本地data目录,返回访问URL""" - import os as _os - import shutil as _shutil - - # Validate file type - allowed_types = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] - if file.content_type not in allowed_types: - raise HTTPException(status_code=400, detail=f"不支持的文件类型: {file.content_type},仅支持 PNG/JPG/SVG/WEBP") - - # Validate size (max 2MB) - contents = await file.read() - if len(contents) > 2 * 1024 * 1024: - raise HTTPException(status_code=400, detail="文件大小不能超过 2MB") - - # Save to /app/data/uploads/logos/ - upload_dir = "/app/data/uploads/logos" - _os.makedirs(upload_dir, exist_ok=True) - - # Generate unique filename - ext = file.filename.split('.')[-1] if '.' in (file.filename or '') else 'png' - filename = f"{uuid.uuid4()}.{ext}" - filepath = _os.path.join(upload_dir, filename) - - with open(filepath, 'wb') as f: - f.write(contents) - - # Build URL - logo_url = f"/data/uploads/logos/{filename}" - - # Update site setting - existing = db.query(SiteSetting).filter(SiteSetting.key == "logo").first() - if existing: - existing.value = logo_url - existing.updated_at = datetime.now() - else: - new_setting = SiteSetting( - id=str(uuid.uuid4()), - key="logo", - value=logo_url, - ) - db.add(new_setting) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="upload_logo", - target_type="site_settings", - detail=f"上传Logo: {filename}", - ) - - return { - "code": 0, - "data": {"url": logo_url, "filename": filename}, - "message": "Logo 上传成功", - } - - -# ============================================================ -# 邮件测试 -# ============================================================ - -@router.post("/email/test") -async def test_email_config( - test_to: str = Query(default=None, description="自定义测试收件地址"), - test_subject: str = Query(default=None, description="自定义测试邮件主题"), - test_body: str = Query(default=None, description="自定义测试邮件正文"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """使用当前配置发送测试邮件;可传参自定义收件人、主题和正文""" - config = db.query(EmailConfig).first() - if not config or not config.smtp_host: - raise HTTPException(status_code=400, detail="邮件配置未设置,请先保存邮件配置") - if not config.username: - raise HTTPException(status_code=400, detail="SMTP用户名未填写,请检查邮件配置") - if not config.password: - raise HTTPException(status_code=400, detail="SMTP密码未填写,请检查邮件配置") - if not config.from_address: - raise HTTPException(status_code=400, detail="发件地址未填写,请检查邮件配置") - - # 优先级:传入参数 > 管理员邮箱 > 发件地址 - admin = db.query(User).filter(User.id == admin_user.id).first() - recipient = test_to or (admin.email if admin and admin.email else None) or config.from_address - if not recipient: - raise HTTPException(status_code=400, detail="无法获取接收地址: 请填写收件邮箱或设置管理员邮箱") - - subject = test_subject or "AIFlow 邮件配置测试" - body = test_body or f"这是一封来自 AIFlow 平台的测试邮件。\n\n发送时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nSMTP服务器: {config.smtp_host}" - - logger.info(f"测试邮件: SMTP={config.smtp_host}:{config.smtp_port}, " - f"from={config.from_address}, to={recipient}") - - try: - msg = MIMEMultipart() - msg["From"] = config.from_address - msg["To"] = recipient - msg["Subject"] = subject - msg.attach(MIMEText(body, "plain")) - - if config.use_tls: - server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) - server.starttls() - else: - server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) - - server.login(config.username, config.password) - server.sendmail(config.from_address, recipient, msg.as_string()) - server.quit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="test_email_config", - target_type="email_config", - detail=f"测试邮件发送成功 -> {recipient} via {config.smtp_host}", - ) - - return {"code": 0, "data": {"message": f"测试邮件已发送到 {recipient}", "recipient": recipient}, "message": "ok"} - except smtplib.SMTPAuthenticationError as e: - logger.error(f"SMTP认证失败: {e}") - raise HTTPException(status_code=400, detail=f"SMTP认证失败,请检查用户名和密码: {str(e)}") - except smtplib.SMTPConnectError as e: - logger.error(f"SMTP连接失败: {e}") - raise HTTPException(status_code=400, detail=f"无法连接到SMTP服务器 {config.smtp_host}:{config.smtp_port}: {str(e)}") - except smtplib.SMTPException as e: - logger.error(f"SMTP发送失败: {e}") - raise HTTPException(status_code=500, detail=f"邮件发送失败(SMTP): {str(e)}") - except OSError as e: - logger.error(f"邮件发送网络错误: {e}") - raise HTTPException(status_code=500, detail=f"网络连接错误: {str(e)}") - except BaseException as e: - logger.error(f"测试邮件发送失败(严重): {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}") - - -@router.post("/email/generate-content") -async def generate_email_content( - topic: str = Query(default="测试邮件"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """AI生成测试邮件内容:调用默认LLM生成邮件主题和正文""" - import httpx as httpx_lib - subjects = [ - "【测试】AIFlow邮件服务配置验证", - "AIFlow平台 - 邮件投递测试", - "测试邮件:您的SMTP服务已就绪", - ] - bodies = [ - f"您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件,用于验证邮件服务配置是否正常。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队", - f"Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team", - f"【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确,能够成功投递邮件。\n\n—— AIFlow", - ] - - # 尝试使用默认LLM生成更好的内容 - try: - default_model = db.query(AIModel).filter( - AIModel.is_default == True, AIModel.is_enabled == True - ).first() - if default_model and default_model.endpoint: - async with httpx_lib.AsyncClient(timeout=15) as client: - headers = {} - if default_model.api_key: - headers["Authorization"] = f"Bearer {default_model.api_key}" - resp = await client.post( - default_model.endpoint, - json={ - "model": default_model.name, - "messages": [ - {"role": "system", "content": "你是一个专业的技术文档撰写者。请根据主题生成一封测试邮件的主题和正文。返回JSON格式: {\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"}, - {"role": "user", "content": f"主题: {topic}"}, - ], - "max_tokens": 400, - }, - headers=headers, - ) - if resp.status_code == 200: - content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "") - try: - data = json.loads(content) - if data.get("subject") and data.get("body"): - return {"subject": data["subject"], "body": data["body"], "source": "ai"} - except: - # 尝试解析非JSON格式 - lines = content.strip().split("\n") - if len(lines) >= 2: - return {"subject": lines[0].replace("主题:", "").strip(), "body": "\n".join(lines[1:]).replace("正文:", "").strip(), "source": "ai_partial"} - except Exception: - pass - - import random - idx = random.randint(0, len(subjects) - 1) - return {"subject": subjects[idx], "body": bodies[idx], "source": "fallback"} - - -# ============================================================ -# 短信测试 -# ============================================================ - -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}&" - h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) - return base64.b64encode(h.digest()).decode('utf-8') - - -@router.post("/sms/test") -async def test_sms_config( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """使用当前配置发送测试短信""" - config = db.query(SmsConfig).first() - if not config: - raise HTTPException(status_code=400, detail="短信配置未设置,请先保存短信配置") - - if not config.api_key or not config.api_secret: - raise HTTPException( - status_code=400, - detail="未配置阿里云短信 AccessKey,请填写 API Key (AccessKey ID) 和 API Secret (AccessKey Secret)" - ) - - if not config.sign_name: - raise HTTPException(status_code=400, detail="未配置短信签名") - - # 获取接收测试短信的手机号(管理员手机号) - admin = db.query(User).filter(User.id == admin_user.id).first() - phone = admin.phone if admin and admin.phone else None - if not phone: - raise HTTPException(status_code=400, detail="管理员未设置手机号,无法发送测试短信") - - logger.info(f"测试短信: provider={config.provider}, sign={config.sign_name}, phone={phone}") - - # 只有阿里云短信支持测试 - if "阿里云" not in (config.provider or ""): - raise HTTPException( - status_code=400, - detail=f"当前短信服务商 '{config.provider}' 暂不支持在线测试,请直接在生产环境测试" - ) - - try: - # 阿里云短信参数 - params = { - "AccessKeyId": config.api_key, - "Action": "SendSms", - "Format": "JSON", - "PhoneNumbers": phone, - "RegionId": "cn-hangzhou", - "SignName": config.sign_name, - "SignatureMethod": "HMAC-SHA1", - "SignatureNonce": str(uuid.uuid4()), - "SignatureVersion": "1.0", - "TemplateCode": config.template_code or "SMS_00000001", - "TemplateParam": '{"code":"123456"}', - "Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "Version": "2017-05-25", - } - params["Signature"] = _aliyun_sign(config.api_secret, params) - - import requests as http_requests - resp = http_requests.post( - "https://dysmsapi.aliyuncs.com/", - data=params, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=10, - ) - result = resp.json() - - logger.info(f"阿里云短信API响应: {result}") - - if result.get("Code") == "OK": - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="test_sms_config", - target_type="sms_config", - detail=f"测试短信发送成功 -> {phone}", - ) - return {"message": f"测试短信已发送到 {phone}", "phone": phone, "biz_id": result.get("BizId")} - else: - error_msg = result.get("Message", result.get("Code", "未知错误")) - raise HTTPException(status_code=400, detail=f"阿里云短信发送失败: {error_msg}") - - except HTTPException: - raise - except Exception as e: - logger.error(f"测试短信发送失败: {e}") - raise HTTPException(status_code=500, detail=f"短信发送失败: {str(e)}") +""" +网站基础设置 API(管理员) +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from ...database import get_db, SessionLocal +from ...models import SiteSetting, EmailConfig, SmsConfig, User, AIModel +from ...auth import get_current_admin +from ...middleware.log_service import LogService +import uuid +import smtplib +import ssl +import hmac +import hashlib +import base64 +import urllib.parse +import time as time_module +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime +from pydantic import BaseModel +from typing import Optional, Dict, Any +import logging + +logger = logging.getLogger("aiflow") + +router = APIRouter() + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class EmailConfigUpdate(BaseModel): + smtp_host: Optional[str] = None + smtp_port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + from_address: Optional[str] = None + use_tls: Optional[bool] = None + use_ssl: Optional[bool] = None + + +class SmsConfigUpdate(BaseModel): + provider: Optional[str] = None + api_key: Optional[str] = None + api_secret: Optional[str] = None + sign_name: Optional[str] = None + template_code: Optional[str] = None + + +# ============================================================ +# 网站设置 +# ============================================================ + +@router.get("/site") +async def get_site_settings( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取网站设置(返回所有 key-value 对)""" + settings = db.query(SiteSetting).all() + result = {} + for s in settings: + result[s.key] = s.value + return {"code": 0, "data": result, "message": "ok"} + + +@router.put("/site") +async def update_site_settings( + settings: dict, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """批量更新网站设置""" + for key, value in settings.items(): + existing = db.query(SiteSetting).filter(SiteSetting.key == key).first() + if existing: + existing.value = str(value) if value is not None else None + existing.updated_at = datetime.now() + else: + new_setting = SiteSetting( + id=str(uuid.uuid4()), + key=key, + value=str(value) if value is not None else None, + ) + db.add(new_setting) + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_site_settings", + target_type="site_settings", + detail=f"更新网站设置: {list(settings.keys())}", + ) + + return {"message": "网站设置已更新"} + + +# ============================================================ +# 邮件配置 +# ============================================================ + +@router.get("/email") +async def get_email_config( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取邮件配置""" + config = db.query(EmailConfig).first() + if not config: + return {"code": 0, "data": { + "smtp_host": "", + "smtp_port": 587, + "username": "", + "password": "", + "from_address": "", + "use_tls": True, + "use_ssl": False, + }, "message": "ok"} + + return {"code": 0, "data": { + "id": config.id, + "smtp_host": config.smtp_host, + "smtp_port": config.smtp_port, + "username": config.username, + "password": config.password, + "from_address": config.from_address, + "use_tls": config.use_tls, + "use_ssl": bool(config.use_ssl) if config.use_ssl is not None else False, + }, "message": "ok"} + + +@router.put("/email") +async def update_email_config( + req: EmailConfigUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新邮件配置""" + config = db.query(EmailConfig).first() + if not config: + # 必填字段校验:不允许空值 + if not req.smtp_host: + raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") + if not req.username: + raise HTTPException(status_code=400, detail="SMTP用户名不能为空") + if not req.password: + raise HTTPException(status_code=400, detail="SMTP密码不能为空") + if not req.from_address: + raise HTTPException(status_code=400, detail="发件地址不能为空") + config = EmailConfig( + id=str(uuid.uuid4()), + smtp_host=req.smtp_host or "", + smtp_port=req.smtp_port or 587, + username=req.username or "", + password=req.password or "", + from_address=req.from_address or "", + use_tls=req.use_tls if req.use_tls is not None else True, + ) + db.add(config) + else: + if req.smtp_host is not None: + if not req.smtp_host.strip(): + raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") + config.smtp_host = req.smtp_host + if req.smtp_port is not None: + config.smtp_port = req.smtp_port + if req.username is not None: + if not req.username.strip(): + raise HTTPException(status_code=400, detail="SMTP用户名不能为空") + config.username = req.username + if req.password is not None: + if not req.password.strip(): + raise HTTPException(status_code=400, detail="SMTP密码不能为空") + config.password = req.password + if req.from_address is not None: + if not req.from_address.strip(): + raise HTTPException(status_code=400, detail="发件地址不能为空") + config.from_address = req.from_address + if req.use_tls is not None: + config.use_tls = req.use_tls + if req.use_ssl is not None: + config.use_ssl = req.use_ssl + config.updated_at = datetime.now() + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_email_config", + target_type="email_config", + detail="更新邮件配置", + ) + + return {"code": 0, "data": None, "message": "邮件配置已更新"} + + +# ============================================================ +# 短信配置 +# ============================================================ + +@router.get("/sms") +async def get_sms_config( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取短信配置""" + config = db.query(SmsConfig).first() + if not config: + return {"code": 0, "data": { + "provider": "", + "api_key": "", + "api_secret": "", + "sign_name": "", + "template_code": "", + }, "message": "ok"} + + return {"code": 0, "data": { + "id": config.id, + "provider": config.provider, + "api_key": config.api_key, + "api_secret": config.api_secret, + "sign_name": config.sign_name, + "template_code": config.template_code, + }, "message": "ok"} + + +@router.put("/sms") +async def update_sms_config( + req: SmsConfigUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新短信配置""" + config = db.query(SmsConfig).first() + if not config: + config = SmsConfig( + id=str(uuid.uuid4()), + provider=req.provider or "", + api_key=req.api_key or "", + api_secret=req.api_secret or "", + sign_name=req.sign_name, + template_code=req.template_code, + ) + db.add(config) + else: + if req.provider is not None: + config.provider = req.provider + if req.api_key is not None: + config.api_key = req.api_key + if req.api_secret is not None: + config.api_secret = req.api_secret + if req.sign_name is not None: + config.sign_name = req.sign_name + if req.template_code is not None: + config.template_code = req.template_code + config.updated_at = datetime.now() + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_sms_config", + target_type="sms_config", + detail="更新短信配置", + ) + + return {"message": "短信配置已更新"} + + + +# ============================================================ +# Logo 上传 +# ============================================================ + +from fastapi import UploadFile, File + +@router.post("/logo/upload") +async def upload_logo( + file: UploadFile = File(...), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """上传平台Logo,保存到本地data目录,返回访问URL""" + import os as _os + import shutil as _shutil + + # Validate file type + allowed_types = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] + if file.content_type not in allowed_types: + raise HTTPException(status_code=400, detail=f"不支持的文件类型: {file.content_type},仅支持 PNG/JPG/SVG/WEBP") + + # Validate size (max 2MB) + contents = await file.read() + if len(contents) > 2 * 1024 * 1024: + raise HTTPException(status_code=400, detail="文件大小不能超过 2MB") + + # Save to /app/data/uploads/logos/ + upload_dir = "/app/data/uploads/logos" + _os.makedirs(upload_dir, exist_ok=True) + + # Generate unique filename + ext = file.filename.split('.')[-1] if '.' in (file.filename or '') else 'png' + filename = f"{uuid.uuid4()}.{ext}" + filepath = _os.path.join(upload_dir, filename) + + with open(filepath, 'wb') as f: + f.write(contents) + + # Build URL + logo_url = f"/data/uploads/logos/{filename}" + + # Update site setting + existing = db.query(SiteSetting).filter(SiteSetting.key == "logo").first() + if existing: + existing.value = logo_url + existing.updated_at = datetime.now() + else: + new_setting = SiteSetting( + id=str(uuid.uuid4()), + key="logo", + value=logo_url, + ) + db.add(new_setting) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="upload_logo", + target_type="site_settings", + detail=f"上传Logo: {filename}", + ) + + return { + "code": 0, + "data": {"url": logo_url, "filename": filename}, + "message": "Logo 上传成功", + } + + +# ============================================================ +# 邮件测试 +# ============================================================ + +@router.post("/email/test") +async def test_email_config( + test_to: str = Query(default=None, description="自定义测试收件地址"), + test_subject: str = Query(default=None, description="自定义测试邮件主题"), + test_body: str = Query(default=None, description="自定义测试邮件正文"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """使用当前配置发送测试邮件;可传参自定义收件人、主题和正文""" + config = db.query(EmailConfig).first() + if not config or not config.smtp_host: + raise HTTPException(status_code=400, detail="邮件配置未设置,请先保存邮件配置") + if not config.username: + raise HTTPException(status_code=400, detail="SMTP用户名未填写,请检查邮件配置") + if not config.password: + raise HTTPException(status_code=400, detail="SMTP密码未填写,请检查邮件配置") + if not config.from_address: + raise HTTPException(status_code=400, detail="发件地址未填写,请检查邮件配置") + + # 优先级:传入参数 > 管理员邮箱 > 发件地址 + admin = db.query(User).filter(User.id == admin_user.id).first() + recipient = test_to or (admin.email if admin and admin.email else None) or config.from_address + if not recipient: + raise HTTPException(status_code=400, detail="无法获取接收地址: 请填写收件邮箱或设置管理员邮箱") + + subject = test_subject or "AIFlow 邮件配置测试" + body = test_body or f"这是一封来自 AIFlow 平台的测试邮件。\n\n发送时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nSMTP服务器: {config.smtp_host}" + + logger.info(f"测试邮件: SMTP={config.smtp_host}:{config.smtp_port}, " + f"from={config.from_address}, to={recipient}") + + # 选择加密方式:use_ssl 优先;未显式设置时按端口推断(465=隐式SSL) + try: + use_ssl = bool(config.use_ssl) if config.use_ssl is not None else (config.smtp_port == 465) + except Exception: + use_ssl = (config.smtp_port == 465) + use_tls = bool(config.use_tls) + + msg = MIMEMultipart() + msg["From"] = config.from_address + msg["To"] = recipient + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + try: + server = None + try: + if use_ssl: + # 端口465等:隐式SSL(SMTPS),需 SMTP_SSL 而非 STARTTLS + server = smtplib.SMTP_SSL( + config.smtp_host, config.smtp_port, timeout=20, + context=ssl.create_default_context(), + ) + elif use_tls: + # 端口587/25:先明文连接再 STARTTLS 升级 + server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=20) + server.starttls(context=ssl.create_default_context()) + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=20) + + server.login(config.username, config.password) + server.sendmail(config.from_address, recipient, msg.as_string()) + finally: + if server is not None: + try: + server.quit() + except Exception: + pass + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="test_email_config", + target_type="email_config", + detail=f"测试邮件发送成功 -> {recipient} via {config.smtp_host}:{config.smtp_port}", + ) + + return {"code": 0, "data": {"message": f"测试邮件已发送到 {recipient}", "recipient": recipient}, "message": "ok"} + except smtplib.SMTPAuthenticationError as e: + logger.error(f"SMTP认证失败: {e}") + raise HTTPException(status_code=400, detail=f"SMTP认证失败,请检查用户名和密码: {str(e)}") + except smtplib.SMTPConnectError as e: + logger.error(f"SMTP连接失败: {e}") + raise HTTPException(status_code=400, detail=f"无法连接到SMTP服务器 {config.smtp_host}:{config.smtp_port}: {str(e)}") + except smtplib.SMTPException as e: + logger.error(f"SMTP发送失败: {e}") + raise HTTPException(status_code=500, detail=f"邮件发送失败(SMTP): {str(e)}") + except OSError as e: + logger.error(f"邮件发送网络错误: {e}") + raise HTTPException(status_code=500, detail=f"网络连接错误: {str(e)}") + except BaseException as e: + logger.error(f"测试邮件发送失败(严重): {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}") + + +@router.post("/email/generate-content") +async def generate_email_content( + topic: str = Query(default="测试邮件"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """AI生成测试邮件内容:调用默认LLM生成邮件主题和正文""" + import httpx as httpx_lib + subjects = [ + "【测试】AIFlow邮件服务配置验证", + "AIFlow平台 - 邮件投递测试", + "测试邮件:您的SMTP服务已就绪", + ] + bodies = [ + f"您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件,用于验证邮件服务配置是否正常。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队", + f"Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team", + f"【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确,能够成功投递邮件。\n\n—— AIFlow", + ] + + # 尝试使用默认LLM生成更好的内容 + try: + default_model = db.query(AIModel).filter( + AIModel.is_default == True, AIModel.is_enabled == True + ).first() + if default_model and default_model.endpoint: + async with httpx_lib.AsyncClient(timeout=15) as client: + headers = {} + if default_model.api_key: + headers["Authorization"] = f"Bearer {default_model.api_key}" + resp = await client.post( + default_model.endpoint, + json={ + "model": default_model.name, + "messages": [ + {"role": "system", "content": "你是一个专业的技术文档撰写者。请根据主题生成一封测试邮件的主题和正文。返回JSON格式: {\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"}, + {"role": "user", "content": f"主题: {topic}"}, + ], + "max_tokens": 400, + }, + headers=headers, + ) + if resp.status_code == 200: + content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "") + try: + data = json.loads(content) + if data.get("subject") and data.get("body"): + return {"subject": data["subject"], "body": data["body"], "source": "ai"} + except: + # 尝试解析非JSON格式 + lines = content.strip().split("\n") + if len(lines) >= 2: + return {"subject": lines[0].replace("主题:", "").strip(), "body": "\n".join(lines[1:]).replace("正文:", "").strip(), "source": "ai_partial"} + except Exception: + pass + + import random + idx = random.randint(0, len(subjects) - 1) + return {"subject": subjects[idx], "body": bodies[idx], "source": "fallback"} + + +# ============================================================ +# 短信测试 +# ============================================================ + +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}&" + h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) + return base64.b64encode(h.digest()).decode('utf-8') + + +@router.post("/sms/test") +async def test_sms_config( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """使用当前配置发送测试短信""" + config = db.query(SmsConfig).first() + if not config: + raise HTTPException(status_code=400, detail="短信配置未设置,请先保存短信配置") + + if not config.api_key or not config.api_secret: + raise HTTPException( + status_code=400, + detail="未配置阿里云短信 AccessKey,请填写 API Key (AccessKey ID) 和 API Secret (AccessKey Secret)" + ) + + if not config.sign_name: + raise HTTPException(status_code=400, detail="未配置短信签名") + + # 获取接收测试短信的手机号(管理员手机号) + admin = db.query(User).filter(User.id == admin_user.id).first() + phone = admin.phone if admin and admin.phone else None + if not phone: + raise HTTPException(status_code=400, detail="管理员未设置手机号,无法发送测试短信") + + logger.info(f"测试短信: provider={config.provider}, sign={config.sign_name}, phone={phone}") + + # 只有阿里云短信支持测试 + if "阿里云" not in (config.provider or ""): + raise HTTPException( + status_code=400, + detail=f"当前短信服务商 '{config.provider}' 暂不支持在线测试,请直接在生产环境测试" + ) + + try: + # 阿里云短信参数 + params = { + "AccessKeyId": config.api_key, + "Action": "SendSms", + "Format": "JSON", + "PhoneNumbers": phone, + "RegionId": "cn-hangzhou", + "SignName": config.sign_name, + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid4()), + "SignatureVersion": "1.0", + "TemplateCode": config.template_code or "SMS_00000001", + "TemplateParam": '{"code":"123456"}', + "Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "Version": "2017-05-25", + } + params["Signature"] = _aliyun_sign(config.api_secret, params) + + import requests as http_requests + resp = http_requests.post( + "https://dysmsapi.aliyuncs.com/", + data=params, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + result = resp.json() + + logger.info(f"阿里云短信API响应: {result}") + + if result.get("Code") == "OK": + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="test_sms_config", + target_type="sms_config", + detail=f"测试短信发送成功 -> {phone}", + ) + return {"message": f"测试短信已发送到 {phone}", "phone": phone, "biz_id": result.get("BizId")} + else: + error_msg = result.get("Message", result.get("Code", "未知错误")) + raise HTTPException(status_code=400, detail=f"阿里云短信发送失败: {error_msg}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"测试短信发送失败: {e}") + raise HTTPException(status_code=500, detail=f"短信发送失败: {str(e)}") diff --git a/backend/python/app/models.py b/backend/python/app/models.py index 1398984..5234101 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -84,6 +84,7 @@ class Conversation(Base): status = Column(String(10), nullable=False, default="active") # active / archived pinned = Column(Boolean, default=False) knowledge_graph_count = Column(Integer, default=0) + knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID") created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) @@ -626,6 +627,7 @@ class EmailConfig(Base): password = Column(String(200), nullable=False) from_address = Column(String(200), nullable=False) use_tls = Column(Boolean, default=True) + use_ssl = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/frontend/src/pages/admin/SiteSettings.tsx b/frontend/src/pages/admin/SiteSettings.tsx index 3cc1690..8ab1b30 100644 --- a/frontend/src/pages/admin/SiteSettings.tsx +++ b/frontend/src/pages/admin/SiteSettings.tsx @@ -1,772 +1,799 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { adminAPI } from '@/api'; - -const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => ( - - {children} - -); - -type TabKey = 'basic' | 'logo' | 'email' | 'sms' | 'registration'; - -const TABS: { key: TabKey; label: string; icon: React.ReactNode }[] = [ - { key: 'basic', label: '基础设置', icon: <> }, - { key: 'logo', label: 'Logo 设置', icon: <> }, - { key: 'email', label: '邮件配置', icon: <> }, - { key: 'sms', label: '短信配置', icon: <> }, - { key: 'registration', label: '注册设置', icon: <> }, -]; - -const SiteSettings: React.FC = () => { - const [activeTab, setActiveTab] = useState('basic'); - const [brand, setBrand] = useState>({ - logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '', - }); - const [mail, setMail] = useState({ - smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true, - }); - const [sms, setSms] = useState({ - provider: '', api_key: '', api_secret: '', sign_name: '', template_code: '', - }); - const [registration, setRegistration] = useState({ - allow_register: true, - require_email_verify: false, - require_phone_verify: false, - default_role: 'user', - welcome_message: '', - }); - const [savedMsg, setSavedMsg] = useState(null); - const [loading, setLoading] = useState(true); - const [testResult, setTestResult] = useState(null); - const [showEmailTest, setShowEmailTest] = useState(false); - const [emailTestTo, setEmailTestTo] = useState(''); - const [emailTestSubject, setEmailTestSubject] = useState(''); - const [emailTestBody, setEmailTestBody] = useState(''); - const [emailGenerating, setEmailGenerating] = useState(false); - const [logoUploading, setLogoUploading] = useState(false); - const [logoPreview, setLogoPreview] = useState(''); - const logoInputRef = useRef(null); - - // AI生成测试邮件内容 - const handleAIGenerateContent = async () => { - setEmailGenerating(true); - try { - const res = await adminAPI.generateEmailContent('测试邮件'); - const data = (res as any).data || res; - setEmailTestSubject(data.subject || ''); - setEmailTestBody(data.body || ''); - } catch (e) { - const subjects = [ - '【测试】AIFlow邮件服务配置验证', - 'AIFlow平台 - 邮件投递测试', - '测试邮件:您的SMTP服务已就绪', - ]; - const bodies = [ - '您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队', - 'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team', - '【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow', - ]; - setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]); - setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]); - } finally { - setEmailGenerating(false); - } - }; - - // Load all configs - useEffect(() => { - async function loadConfig() { - try { - const [siteRes, emailRes, smsRes, regRes] = await Promise.all([ - adminAPI.getSiteSettings(), - adminAPI.getEmailConfig(), - adminAPI.getSmsConfig(), - adminAPI.getSiteSettings().then((r: any) => ({ data: r?.data?.registration || {} })).catch(() => ({ data: {} })), - ]); - if (siteRes?.data) { - setBrand({ - logo: siteRes.data.logo || siteRes.data.site_logo || '', - siteName: siteRes.data.site_name || siteRes.data.siteName || '', - announcement: siteRes.data.announcement || '', - icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '', - contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '', - }); - setLogoPreview(siteRes.data.logo || siteRes.data.site_logo || ''); - } - if (emailRes?.data) { - setMail({ - smtp_host: emailRes.data.smtp_host || '', - smtp_port: emailRes.data.smtp_port || 587, - username: emailRes.data.username || '', - password: emailRes.data.password || '', - from_address: emailRes.data.from_address || '', - use_tls: emailRes.data.use_tls !== false, - }); - } - if (smsRes?.data) { - setSms({ - provider: smsRes.data.provider || '', - api_key: smsRes.data.api_key || '', - api_secret: smsRes.data.api_secret || '', - sign_name: smsRes.data.sign_name || '', - template_code: smsRes.data.template_code || '', - }); - } - // Try to load registration settings from site settings - try { - const regData = (siteRes as any)?.data; - if (regData?.allow_register !== undefined) { - setRegistration({ - allow_register: regData.allow_register === true || regData.allow_register === 'true', - require_email_verify: regData.require_email_verify === true || regData.require_email_verify === 'true', - require_phone_verify: regData.require_phone_verify === true || regData.require_phone_verify === 'true', - default_role: regData.default_role || 'user', - welcome_message: regData.welcome_message || '', - }); - } - } catch {} - } catch (e) { - console.error('加载配置失败:', e); - } finally { - setLoading(false); - } - } - loadConfig(); - }, []); - - const showSaved = (block: string) => { - setSavedMsg(block + ' 保存成功'); - setTimeout(() => setSavedMsg(null), 2000); - }; - - const showTestResult = (result: string) => { - setTestResult(result); - setTimeout(() => setTestResult(null), 5000); - }; - - // Logo upload handler - const handleLogoUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - // Preview locally - const reader = new FileReader(); - reader.onload = (ev) => setLogoPreview(ev.target?.result as string); - reader.readAsDataURL(file); - - // Upload to server - setLogoUploading(true); - try { - const formData = new FormData(); - formData.append('file', file); - const res = await adminAPI.uploadLogo(formData); - const data = (res as any)?.data || res; - const url = data?.url || data?.logo_url || ''; - if (url) { - setBrand({ ...brand, logo: url }); - showSaved('Logo'); - } - } catch (e: any) { - showTestResult('Logo 上传失败: ' + (e.detail || e.message || '未知错误')); - } finally { - setLogoUploading(false); - } - }; - - const saveSiteSettings = async () => { - try { - await adminAPI.updateSiteSettings({ - logo: brand.logo || logoPreview, - site_name: brand.siteName, - announcement: brand.announcement, - icp_number: brand.icpNumber, - contact_phone: brand.contactPhone, - }); - showSaved('基础设置'); - } catch (e: any) { - showTestResult('保存失败: ' + (e.detail || e.message)); - } - }; - - const saveEmailConfig = async () => { - try { - await adminAPI.updateEmailConfig({ - smtp_host: mail.smtp_host, - smtp_port: mail.smtp_port, - username: mail.username, - password: mail.password, - from_address: mail.from_address, - use_tls: mail.use_tls, - }); - showSaved('邮件配置'); - } catch (e: any) { - showTestResult('保存失败: ' + (e.detail || e.message)); - } - }; - - const saveSmsConfig = async () => { - try { - await adminAPI.updateSmsConfig({ - provider: sms.provider, - api_key: sms.api_key, - api_secret: sms.api_secret, - sign_name: sms.sign_name, - template_code: sms.template_code, - }); - showSaved('短信配置'); - } catch (e: any) { - showTestResult('保存失败: ' + (e.detail || e.message)); - } - }; - - const saveRegistrationSettings = async () => { - try { - await adminAPI.updateSiteSettings({ - allow_register: String(registration.allow_register), - require_email_verify: String(registration.require_email_verify), - require_phone_verify: String(registration.require_phone_verify), - default_role: registration.default_role, - welcome_message: registration.welcome_message, - }); - showSaved('注册设置'); - } catch (e: any) { - showTestResult('保存失败: ' + (e.detail || e.message)); - } - }; - - const sendTestEmail = async () => { - if (!emailTestTo.trim()) { - showTestResult('请输入收件邮箱地址'); - return; - } - try { - await adminAPI.updateEmailConfig({ - smtp_host: mail.smtp_host, - smtp_port: mail.smtp_port, - username: mail.username, - password: mail.password, - from_address: mail.from_address, - use_tls: mail.use_tls, - }); - const res = await adminAPI.testEmail({ - test_to: emailTestTo.trim(), - test_subject: emailTestSubject || undefined, - test_body: emailTestBody || undefined, - }); - showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK')); - setShowEmailTest(false); - } catch (e: any) { - showTestResult('测试邮件发送失败: ' + (e.detail || e.message)); - } - }; - - const openEmailTest = () => { - setEmailTestTo(''); - setEmailTestSubject(''); - setEmailTestBody(''); - setShowEmailTest(true); - }; - - const sendTestSms = async () => { - try { - const res = await adminAPI.testSms(); - showTestResult('测试短信发送成功: ' + (res?.message || 'OK')); - } catch (e: any) { - showTestResult('测试短信发送失败: ' + (e.detail || e.message)); - } - }; - - if (loading) { - return
加载中...
; - } - - const FormGroup = ({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) => ( -
- - {children} -
- ); - - const FormRow = ({ children }: { children: React.ReactNode }) => ( -
{children}
- ); - - const FormActions = ({ children }: { children: React.ReactNode }) => ( -
{children}
- ); - - // ========== Render Tab Content ========== - const renderTabContent = () => { - switch (activeTab) { - // ── 基础设置 ── - case 'basic': - return ( -
-
-

基础信息

-
- - - setBrand({ ...brand, siteName: e.target.value })} - placeholder="例如: 七虎AI" /> - - - { setBrand({ ...brand, logo: e.target.value }); setLogoPreview(e.target.value); }} - placeholder="https://... 或上传后自动填入" /> - - - - -