Round6: 前台图片缩略图+下载/视频修复; 后台连接测试单次+自动消失; 导航重组; 存储中文化; 自动化去掉创建按钮; 短信验证码(注册/重置/充值通知)

This commit is contained in:
QihuAI Design committed 2026-07-14 18:49:27 +08:00
1 parent ba09017884
commit d179cc2f02
14 files changed
+806 -149

No files matched your search

+113 -2
View File
@@ -4,15 +4,18 @@ from sqlalchemy import and_
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional
import uuid import uuid
from datetime import datetime import random
import re
from datetime import datetime, timedelta
from ..database import get_db from ..database import get_db
from ..models import ( from ..models import (
User, LoginLog, RegisterSetting, User, LoginLog, RegisterSetting, SmsConfig, SmsVerificationCode,
gen_uuid gen_uuid
) )
from ..auth import get_current_user, hash_password, verify_password, create_access_token from ..auth import get_current_user, hash_password, verify_password, create_access_token
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..sms_client import send_sms_code
router = APIRouter() router = APIRouter()
@@ -27,6 +30,18 @@ class RegisterRequest(BaseModel):
password: str = Field(min_length=6, max_length=100) password: str = Field(min_length=6, max_length=100)
email: Optional[str] = None email: Optional[str] = None
phone: 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") @router.post("/login")
@@ -117,6 +132,20 @@ def register(req: RegisterRequest, db: Session = Depends(get_db)):
if existing_phone: if existing_phone:
raise HTTPException(status_code=400, detail="手机号已被注册") 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( user = User(
id=gen_uuid(), id=gen_uuid(),
username=req.username, 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") @router.get("/me")
def get_me(current_user: User = Depends(get_current_user)): def get_me(current_user: User = Depends(get_current_user)):
return { return {
+5 -4
View File
@@ -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") video_id = data.get("video_id") or data.get("task_id") or data.get("id")
if not video_id: if not video_id:
return f"⚠️ 视频生成失败:未返回任务ID {str(data)[:200]}" 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 result_url = None
err_msg = None err_msg = None
for _ in range(60): for _ in range(60):
await asyncio.sleep(5) 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: if r2.status_code != 200:
continue continue
rd = r2.json() rd = r2.json()
st = rd.get("status") st = rd.get("status")
if st == "completed": if st == "completed":
result_url = rd.get("url") result_url = (rd.get("metadata") or {}).get("url") or rd.get("url")
break break
if st == "failed": if st == "failed":
err_msg = rd.get("error") err_msg = rd.get("error") or (rd.get("metadata") or {}).get("error")
break break
if result_url: if result_url:
return (f'<video class="md-video" controls src="{result_url}"></video>\n\n' return (f'<video class="md-video" controls src="{result_url}"></video>\n\n'
+11 -1
View File
@@ -9,10 +9,11 @@ from datetime import datetime, date
from ..database import get_db from ..database import get_db
from ..models import ( from ..models import (
User, LoginLog, ApiKey, MembershipSubscription, AppPurchase, MarketApp, User, LoginLog, ApiKey, MembershipSubscription, AppPurchase, MarketApp,
gen_uuid SmsConfig, gen_uuid
) )
from ..auth import get_current_user, hash_password, verify_password from ..auth import get_current_user, hash_password, verify_password
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..sms_client import send_sms_text
router = APIRouter() router = APIRouter()
@@ -145,6 +146,15 @@ def update_password(
db_user.password_hash = hash_password(req.new_password) db_user.password_hash = hash_password(req.new_password)
db_user.updated_at = datetime.now() 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( LogService.log_operation(
user_id=db_user.id, username=db_user.username, action="change_password", user_id=db_user.id, username=db_user.username, action="change_password",
target="user", target_id=db_user.id, target="user", target_id=db_user.id,
+14 -1
View File
@@ -8,11 +8,12 @@ from datetime import datetime, date, timedelta
from ..database import get_db from ..database import get_db
from ..models import ( from ..models import (
User, TokenUsageLog, TokenBalance, AIModel, User, TokenUsageLog, TokenBalance, AIModel, SmsConfig,
gen_uuid gen_uuid
) )
from ..auth import get_current_user from ..auth import get_current_user
from ..middleware.log_service import LogService from ..middleware.log_service import LogService
from ..sms_client import send_sms_text
router = APIRouter() router = APIRouter()
@@ -237,6 +238,18 @@ def recharge(
db.commit() 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 { return {
"code": 0, "code": 0,
"data": { "data": {
+13
View File
@@ -1103,3 +1103,16 @@ class MCPServer(Base):
resources_count = Column(Integer, default=0) resources_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now) created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=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)
+168
View File
@@ -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)
+6 -33
View File
@@ -180,6 +180,12 @@ export const authAPI = {
getSettings: () => get<any>('/auth/settings'), getSettings: () => get<any>('/auth/settings'),
sendCode: (phone: string, purpose: string) =>
post<any>('/auth/send-code', { phone, purpose }),
resetPassword: (data: { phone: string; code: string; new_password: string }) =>
post<any>('/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}` : ''}`), 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<any>(`/admin/dashboard/operation-logs?page=${page}&page_size=${pageSize}`),
getDashboardAuditLogs: (page = 1, pageSize = 20) =>
get<any>(`/admin/dashboard/audit-logs?page=${page}&page_size=${pageSize}`),
// 用户管理 // 用户管理
getUsers: (params?: any) => { getUsers: (params?: any) => {
@@ -1962,31 +1961,5 @@ export const imUserAPI = {
history: () => get<any>('/im/wechat/history'), history: () => get<any>('/im/wechat/history'),
// 多平台 IM Bot(企业微信/飞书/QQ/悟空IM)
getPlatforms: () => get<any>('/im/platform/platforms'),
listPlatforms: () => get<any>('/im/platform/list'),
platformConfig: (platform: string) => get<any>(`/im/platform/${platform}/config`),
savePlatformConfig: (platform: string, data: any) => post<any>(`/im/platform/${platform}/config`, data),
disconnectPlatform: (platform: string) => post<any>(`/im/platform/${platform}/disconnect`),
platformHistory: (platform: string) => get<any>(`/im/platform/${platform}/history`),
platformWebhookUrl: (platform: string) => get<any>(`/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<any>(`/im/platform/apps${q ? `?${q}` : ''}`);
},
}; };
@@ -86,6 +86,10 @@ const adminNavGroups: NavGroup[] = [
items: [ items: [
{ path: '/admin/settings', icon: 'settings', label: '网站设置' }, { path: '/admin/settings', icon: 'settings', label: '网站设置' },
{ path: '/admin/logs', icon: 'logs', 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: '待处理', title: '待处理',
icon: 'developer', icon: 'developer',
items: [ items: [
{ path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' },
{ path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' },
{ path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' }, { path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
{ path: '/admin/tenant', icon: 'tenant', label: '多租户管理' }, { path: '/admin/tenant', icon: 'tenant', label: '多租户管理' },
{ path: '/admin/sms', icon: 'sms', label: '短信配置' },
{ path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/distributions', icon: 'distro', 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', icon: 'developer', label: '开发者 API' },
{ path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' }, { path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' },
], ],
+3 -21
View File
@@ -136,25 +136,7 @@ const Automation: React.FC = () => {
useEffect(() => { loadStats(); }, []); useEffect(() => { loadStats(); }, []);
useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]); useEffect(() => { loadTasks(); }, [page, filterTrigger, keyword]);
// Open create modal // Open edit modal (tasks are created by AI in chat; admin only edits/enables existing ones)
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
const openEditModal = async (task: AutomationTask) => { const openEditModal = async (task: AutomationTask) => {
try { try {
const result = await adminAPI.getAdminAutomationTask(task.id) as any; const result = await adminAPI.getAdminAutomationTask(task.id) as any;
@@ -311,7 +293,7 @@ const Automation: React.FC = () => {
<div className="admin-page"> <div className="admin-page">
<div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}> <div className="admin-page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px", flexWrap: "wrap", gap: "12px" }}>
<h2>自动化任务调度</h2> <h2>自动化任务调度</h2>
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 创建任务</button> <span className="admin-page-hint">任务由 AI 在聊天中自动创建,此处仅支持查看、编辑与启停</span>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
@@ -445,7 +427,7 @@ const Automation: React.FC = () => {
{modalVisible && ( {modalVisible && (
<div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}> <div className="modal-overlay" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}> <div style={{ background: '#fff', borderRadius: '12px', padding: '24px', width: '560px', maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.2)' }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>{editingTask ? '编辑任务' : '创建自动化任务'}</h3> <h3 style={{ margin: '0 0 20px', fontSize: '18px' }}>编辑任务</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div><label style={labelStyle}>任务名称 *</label> <div><label style={labelStyle}>任务名称 *</label>
<input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div> <input type="text" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} style={inputStyle} placeholder="如: 每日报表生成" /></div>
+17 -6
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { adminAPI } from '@/api'; import { adminAPI } from '@/api';
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types'; import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
@@ -28,6 +28,7 @@ const capabilityLabels: Record<string, string> = {
// Test result type // Test result type
interface TestResult { interface TestResult {
modelId: string;
success: boolean; success: boolean;
message: string; message: string;
latency_ms?: number; latency_ms?: number;
@@ -70,6 +71,12 @@ const Models: React.FC = () => {
// Model test state // Model test state
const [testingModelId, setTestingModelId] = useState<string | null>(null); const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null); const [testResult, setTestResult] = useState<TestResult | null>(null);
const testTimer = useRef<number | null>(null);
// 测试反馈自动消失(6 秒后)
useEffect(() => {
return () => { if (testTimer.current) clearTimeout(testTimer.current); };
}, []);
// Model editor state // Model editor state
const [showModelEditor, setShowModelEditor] = useState(false); const [showModelEditor, setShowModelEditor] = useState(false);
@@ -118,17 +125,21 @@ const Models: React.FC = () => {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
// Test model connection // Test model connection (single model only)
const handleTestConnection = async (modelId: string) => { const handleTestConnection = async (modelId: string) => {
setTestingModelId(modelId); setTestingModelId(modelId);
setTestResult(null); setTestResult(null);
if (testTimer.current) { clearTimeout(testTimer.current); testTimer.current = null; }
try { try {
const result = await adminAPI.testModelConnection(modelId) as any; 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) { } catch (err: any) {
setTestResult({ success: false, message: err.message || '测试失败' }); setTestResult({ modelId, success: false, message: err.message || '测试失败' });
} finally { } finally {
setTestingModelId(null); 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') }}> style={{ ...btnStyle(testingModelId === model.id ? '#9CA3AF' : '#059669', 'small') }}>
{testingModelId === model.id ? '测试中...' : '连接测试'} {testingModelId === model.id ? '测试中...' : '连接测试'}
</button> </button>
{/* Test Result Popover */} {/* Test Result Popover (only for the tested model) */}
{testResult && ( {testResult && testResult.modelId === model.id && (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px', display: 'inline-block' }}> <span style={{ fontSize: '11px', padding: '4px 8px', background: testResult.success ? '#DCFCE7' : '#FEE2E2', color: testResult.success ? '#16A34A' : '#DC2626', borderRadius: '4px', display: 'inline-block' }}>
{testResult.success ? '✓ 成功' : '✗ 失败'} {testResult.success ? '✓ 成功' : '✗ 失败'}
+62 -62
View File
@@ -358,7 +358,7 @@ const Storage: React.FC = () => {
} catch (e: any) { } catch (e: any) {
showToast('Load failed: ' + (e.message || e), 'error'); showToast('加载失败:' + (e.message || e), 'error');
} finally { } finally {
@@ -412,7 +412,7 @@ const Storage: React.FC = () => {
setModalVisible(true); 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())) { 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); } 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 (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(); setModalVisible(false); loadConfigs(); loadStats();
} catch (e: any) { showToast('Failed: ' + (e.message || e), 'error'); } } catch (e: any) { showToast('失败:' + (e.message || e), 'error'); }
finally { setSubmitting(false); } finally { setSubmitting(false); }
@@ -476,7 +476,7 @@ const Storage: React.FC = () => {
const handleDelete = async (config: OSSConfig) => { 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(); } 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(); } 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; 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; const data = result.data;
showToast(data.success ? 'Connection OK' : 'Connection failed', data.success ? 'success' : 'error'); showToast(data.success ? '连接成功' : '连接失败', data.success ? 'success' : 'error');
loadConfigs(); loadStats(); loadConfigs(); loadStats();
@@ -570,7 +570,7 @@ const Storage: React.FC = () => {
setQuotaTotal(data.total || 0); setQuotaTotal(data.total || 0);
} catch (e: any) { showToast('Load failed', 'error'); } } catch (e: any) { showToast('加载失败', 'error'); }
finally { setQuotaLoading(false); } finally { setQuotaLoading(false); }
@@ -592,11 +592,11 @@ const Storage: React.FC = () => {
const result = await adminAPI.initAllStorageQuotas() as any; const result = await adminAPI.initAllStorageQuotas() as any;
showToast(result.message || 'Init complete'); showToast(result.message || '初始化完成');
loadQuotaOverview(); loadQuotaUsers(); loadQuotaOverview(); loadQuotaUsers();
} catch (e: any) { showToast('Init failed', 'error'); } } catch (e: any) { showToast('初始化失败', 'error'); }
}; };
@@ -612,7 +612,7 @@ const Storage: React.FC = () => {
loadQuotaUsers(); 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); 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); 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); 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' }); await adminAPI.adjustStorageQuota(adjustModal.user_id, { quota_bytes: bytes, quota_type: 'custom' });
showToast('Quota adjusted'); showToast('配额已调整');
setAdjustModal(null); setAdjustModal(null);
loadQuotaUsers(); loadQuotaOverview(); loadQuotaUsers(); loadQuotaOverview();
} catch (e: any) { showToast('Adjust failed', 'error'); } } catch (e: any) { showToast('调整失败', 'error'); }
finally { setAdjusting(false); } finally { setAdjusting(false); }
@@ -702,7 +702,7 @@ const Storage: React.FC = () => {
{activeTab === 'oss' && ( {activeTab === 'oss' && (
<button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ Add Storage</button> <button className="admin-btn admin-btn-primary" onClick={openCreateModal}>+ 新增存储</button>
)} )}
@@ -846,7 +846,7 @@ const Storage: React.FC = () => {
<select value={filterProvider} onChange={(e) => { setFilterProvider(e.target.value); setPage(1); }} <select value={filterProvider} onChange={(e) => { setFilterProvider(e.target.value); setPage(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' }}>
<option value="">全部类型</option> <option value="">全部类型</option>
@@ -858,7 +858,7 @@ const Storage: React.FC = () => {
onChange={(e) => { setKeyword(e.target.value); setPage(1); }} onChange={(e) => { setKeyword(e.target.value); setPage(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' }} />
</div> </div>
@@ -906,7 +906,7 @@ const Storage: React.FC = () => {
<tr key={cfg.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}> <tr key={cfg.id} style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<td style={{ padding: '12px 16px', fontWeight: 500, color: '#F8F9FC' }}>{cfg.name}</td> <td style={{ padding: '12px 16px', fontWeight: 500, color: '#1F2937' }}>{cfg.name}</td>
<td style={{ padding: '12px 16px' }}> <td style={{ padding: '12px 16px' }}>
@@ -956,7 +956,7 @@ const Storage: React.FC = () => {
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: cfg.is_enabled ? '#EF4444' : '#10B981' }}> style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: 'pointer', fontSize: '12px', color: cfg.is_enabled ? '#EF4444' : '#10B981' }}>
{cfg.is_enabled ? 'Disable' : 'Enable'} {cfg.is_enabled ? '禁用' : '启用'}
</button> </button>
@@ -988,13 +988,13 @@ const Storage: React.FC = () => {
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} <button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1, color: '#F8F9FC' }}>上一页</button> style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page <= 1 ? 'not-allowed' : 'pointer', opacity: page <= 1 ? 0.5 : 1, color: '#1F2937' }}>上一页</button>
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>Page {page} / {Math.ceil(total / PAGE_SIZE)} ({total})</span> <span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>第 {page} / {Math.ceil(total / PAGE_SIZE)} 页 ({total})</span>
<button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)} <button disabled={page >= Math.ceil(total / PAGE_SIZE)} onClick={() => setPage(p => p + 1)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1, color: '#F8F9FC' }}>下一页</button> style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: 'transparent', cursor: page >= Math.ceil(total / PAGE_SIZE) ? 'not-allowed' : 'pointer', opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1, color: '#1F2937' }}>下一页</button>
</div> </div>
@@ -1078,7 +1078,7 @@ const Storage: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#F8F9FC' }}> <span style={{ fontSize: '14px', fontWeight: 600, color: '#1F2937' }}>
{(quotaOverview.tier_labels || {})[level] || `Level ${level}`} {(quotaOverview.tier_labels || {})[level] || `Level ${level}`}
@@ -1086,7 +1086,7 @@ const Storage: React.FC = () => {
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}> <span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', background: 'rgba(108,99,255,0.15)', color: '#6C63FF' }}>
Default 默认
</span> </span>
@@ -1096,7 +1096,7 @@ const Storage: React.FC = () => {
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}> <div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
{(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} users | Used: ${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : 'No users'} {(quotaOverview.by_level || {})[level] ? `${quotaOverview.by_level[level].user_count} 个用户 | 已用:${formatBytes(quotaOverview.by_level[level].total_used_bytes)}` : '暂无用户'}
</div> </div>
@@ -1118,7 +1118,7 @@ const Storage: React.FC = () => {
<select value={quotaLevelFilter} onChange={(e) => { setQuotaLevelFilter(e.target.value === '' ? '' : parseInt(e.target.value)); setQuotaPage(1); }} <select value={quotaLevelFilter} onChange={(e) => { 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' }}>
<option value="">全部等级</option> <option value="">全部等级</option>
@@ -1134,7 +1134,7 @@ const Storage: React.FC = () => {
onChange={(e) => { setQuotaKeyword(e.target.value); setQuotaPage(1); }} 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' }} />
</div> </div>
@@ -1184,7 +1184,7 @@ const Storage: React.FC = () => {
<td style={{ padding: '12px 16px' }}> <td style={{ padding: '12px 16px' }}>
<div style={{ fontWeight: 500, color: '#F8F9FC' }}>{u.username}</div> <div style={{ fontWeight: 500, color: '#1F2937' }}>{u.username}</div>
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>{u.email || '-'}</div> <div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>{u.email || '-'}</div>
@@ -1246,7 +1246,7 @@ const Storage: React.FC = () => {
color: u.quota_type === 'custom' ? '#F59E0B' : '#6C63FF', color: u.quota_type === 'custom' ? '#F59E0B' : '#6C63FF',
}}>{u.quota_type === 'custom' ? 'Custom' : 'Plan'}</span> }}>{u.quota_type === 'custom' ? '自定义' : '套餐'}</span>
</td> </td>
@@ -1284,13 +1284,13 @@ const Storage: React.FC = () => {
<button disabled={quotaPage <= 1} onClick={() => setQuotaPage(p => p - 1)} <button disabled={quotaPage <= 1} onClick={() => setQuotaPage(p => p - 1)}
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: '#F8F9FC' }}>上一页</button> 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' }}>上一页</button>
<span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>第 {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} 页 ({quotaTotal})</span> <span style={{ padding: '6px 12px', color: 'rgba(0,0,0,0.5)', fontSize: '14px' }}>第 {quotaPage} / {Math.ceil(quotaTotal / PAGE_SIZE)} 页 ({quotaTotal})</span>
<button disabled={quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE)} onClick={() => setQuotaPage(p => p + 1)} <button disabled={quotaPage >= Math.ceil(quotaTotal / PAGE_SIZE)} onClick={() => setQuotaPage(p => p + 1)}
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: '#F8F9FC' }}>下一页</button> 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' }}>下一页</button>
</div> </div>
@@ -1330,7 +1330,7 @@ const Storage: React.FC = () => {
}}> }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#F8F9FC' }}> <h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#1F2937' }}>
{editingConfig ? '编辑存储配置' : '新建存储配置'} {editingConfig ? '编辑存储配置' : '新建存储配置'}
@@ -1346,7 +1346,7 @@ const Storage: React.FC = () => {
placeholder="如:生产环境 MinIO" 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' }} />
</div> </div>
@@ -1356,7 +1356,7 @@ const Storage: React.FC = () => {
<select value={formData.provider} onChange={(e) => setFormData({ ...formData, provider: e.target.value })} <select value={formData.provider} onChange={(e) => setFormData({ ...formData, provider: e.target.value })}
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' }}>
<option value="minio">MinIO</option> <option value="minio">MinIO</option>
@@ -1376,7 +1376,7 @@ const Storage: React.FC = () => {
placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : 'https://oss-cn-hangzhou.aliyuncs.com'} placeholder={formData.provider === 'minio' ? 'http://localhost:9000' : 'https://oss-cn-hangzhou.aliyuncs.com'}
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' }} />
</div> </div>
@@ -1388,9 +1388,9 @@ const Storage: React.FC = () => {
<input type="text" value={formData.access_key} onChange={(e) => setFormData({ ...formData, access_key: e.target.value })} <input type="text" value={formData.access_key} onChange={(e) => 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' }} />
</div> </div>
@@ -1400,9 +1400,9 @@ const Storage: React.FC = () => {
<input type="password" value={formData.secret_key} onChange={(e) => setFormData({ ...formData, secret_key: e.target.value })} <input type="password" value={formData.secret_key} onChange={(e) => 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' }} />
</div> </div>
@@ -1418,7 +1418,7 @@ const Storage: React.FC = () => {
placeholder="存储桶名称" 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' }} />
</div> </div>
@@ -1430,7 +1430,7 @@ const Storage: React.FC = () => {
placeholder="区域,如 cn-north-1" 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' }} />
</div> </div>
@@ -1444,7 +1444,7 @@ const Storage: React.FC = () => {
placeholder="上传路径,如 aipaaS/uploads/" 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' }} />
</div> </div>
@@ -1452,13 +1452,13 @@ const Storage: React.FC = () => {
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}>
<input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} /> Set as default <input type="checkbox" checked={formData.is_default} onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })} /> 设为默认
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '14px', cursor: 'pointer', color: 'rgba(0,0,0,0.7)' }}>
<input type="checkbox" checked={formData.is_enabled} onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })} /> Enabled <input type="checkbox" checked={formData.is_enabled} onChange={(e) => setFormData({ ...formData, is_enabled: e.target.checked })} /> 启用
</label> </label>
@@ -1466,13 +1466,13 @@ const Storage: React.FC = () => {
<div> <div>
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>Extra Config (JSON, optional)</label> <label style={{ display: 'block', marginBottom: '4px', fontSize: '13px', color: 'rgba(0,0,0,0.7)', fontWeight: 500 }}>额外配置 (JSON,可选)</label>
<textarea value={formData.config_str} onChange={(e) => setFormData({ ...formData, config_str: e.target.value })} <textarea value={formData.config_str} onChange={(e) => setFormData({ ...formData, config_str: e.target.value })}
placeholder='{"use_ssl": true, "timeout": 30}' rows={3} placeholder='{"use_ssl": true, "timeout": 30}' rows={3}
style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }} /> style={{ width: '100%', padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '13px', fontFamily: 'monospace', boxSizing: 'border-box', resize: 'vertical' }} />
</div> </div>
@@ -1528,7 +1528,7 @@ const Storage: React.FC = () => {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0, fontSize: '18px', color: '#F8F9FC' }}>存储详情</h3> <h3 style={{ margin: 0, fontSize: '18px', color: '#1F2937' }}>存储详情</h3>
<button onClick={() => setQuotaDetail(null)} style={{ background: 'transparent', border: 'none', color: 'rgba(0,0,0,0.5)', cursor: 'pointer', fontSize: '20px' }}>x</button> <button onClick={() => setQuotaDetail(null)} style={{ background: 'transparent', border: 'none', color: 'rgba(0,0,0,0.5)', cursor: 'pointer', fontSize: '20px' }}>x</button>
@@ -1538,7 +1538,7 @@ const Storage: React.FC = () => {
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '20px', fontWeight: 700, color: '#F8F9FC' }}>{quotaDetail.user.username}</div> <div style={{ fontSize: '20px', fontWeight: 700, color: '#1F2937' }}>{quotaDetail.user.username}</div>
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.4)' }}>{quotaDetail.user.email || '-'}</div> <div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.4)' }}>{quotaDetail.user.email || '-'}</div>
@@ -1558,7 +1558,7 @@ const Storage: React.FC = () => {
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)' }}>使用量</span> <span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)' }}>使用量</span>
<span style={{ fontSize: '13px', color: '#fff', fontFamily: 'monospace' }}> <span style={{ fontSize: '13px', color: '#1F2937', fontFamily: 'monospace' }}>
{formatBytes(quotaDetail.quota.used_bytes)} / {formatBytes(quotaDetail.quota.quota_bytes)} {formatBytes(quotaDetail.quota.used_bytes)} / {formatBytes(quotaDetail.quota.quota_bytes)}
@@ -1598,7 +1598,7 @@ const Storage: React.FC = () => {
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)', marginBottom: '4px' }}>文件数</div> <div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)', marginBottom: '4px' }}>文件数</div>
<div style={{ fontSize: '20px', fontWeight: 700, color: '#fff' }}>{quotaDetail.file_count}</div> <div style={{ fontSize: '20px', fontWeight: 700, color: '#1F2937' }}>{quotaDetail.file_count}</div>
</div> </div>
@@ -1618,7 +1618,7 @@ const Storage: React.FC = () => {
<div> <div>
<div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', marginBottom: '8px', fontWeight: 600 }}>By File Type</div> <div style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', marginBottom: '8px', fontWeight: 600 }}>按文件类型</div>
{Object.entries(quotaDetail.file_type_stats).map(([ftype, stat]) => ( {Object.entries(quotaDetail.file_type_stats).map(([ftype, stat]) => (
@@ -1626,7 +1626,7 @@ const Storage: React.FC = () => {
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.7)' }}>{ftype}</span> <span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.7)' }}>{ftype}</span>
<span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontFamily: 'monospace' }}>{stat.count} files / {formatBytes(stat.total_bytes)}</span> <span style={{ fontSize: '13px', color: 'rgba(0,0,0,0.5)', fontFamily: 'monospace' }}>{stat.count} 个文件 / {formatBytes(stat.total_bytes)}</span>
</div> </div>
@@ -1680,11 +1680,11 @@ const Storage: React.FC = () => {
}}> }}>
<h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#F8F9FC' }}>调整配额</h3> <h3 style={{ margin: '0 0 20px', fontSize: '18px', color: '#1F2937' }}>调整配额</h3>
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '16px', fontWeight: 600, color: '#fff' }}>{adjustModal.username}</div> <div style={{ fontSize: '16px', fontWeight: 600, color: '#1F2937' }}>{adjustModal.username}</div>
<div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}> <div style={{ fontSize: '12px', color: 'rgba(0,0,0,0.4)' }}>
@@ -1704,11 +1704,11 @@ const Storage: React.FC = () => {
step="0.1" min="0" step="0.1" min="0"
style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#F8F9FC', fontSize: '14px' }} /> style={{ flex: 1, padding: '8px 12px', borderRadius: '6px', border: '1px solid rgba(0,0,0,0.10)', background: '#FFFFFF', color: '#1F2937', fontSize: '14px' }} />
<select value={adjustUnit} onChange={(e) => setAdjustUnit(e.target.value as 'GB' | 'MB')} <select value={adjustUnit} onChange={(e) => setAdjustUnit(e.target.value as 'GB' | 'MB')}
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' }}>
<option value="GB">GB</option> <option value="GB">GB</option>
+212
View File
@@ -1045,3 +1045,215 @@
border-color: transparent; border-color: transparent;
transform: none; transform: none;
} }
/* ===== AI 生成媒体:固定缩略图 + 下载 ===== */
.md-text-seg {
display: block;
width: 100%;
}
.chat-media-wrap {
position: relative;
display: inline-block;
max-width: 100%;
margin: 2px 0;
}
.chat-media-img {
display: block;
max-width: 320px;
max-height: 320px;
width: auto;
height: auto;
object-fit: contain;
border-radius: var(--radius-md);
background: var(--color-bg-tertiary);
cursor: zoom-in;
box-shadow: var(--shadow-xs);
}
.chat-media-video {
display: block;
max-width: 360px;
max-height: 320px;
width: auto;
height: auto;
border-radius: var(--radius-md);
background: #000;
cursor: zoom-in;
box-shadow: var(--shadow-xs);
}
.chat-media-bar {
margin-top: 6px;
}
.chat-media-link {
font-size: var(--text-xs);
color: var(--color-accent);
text-decoration: none;
}
.chat-media-link:hover {
text-decoration: underline;
}
/* 下载按钮 */
.chat-media-dl-btn {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-full);
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
opacity: 0;
transition: opacity var(--transition-fast), background var(--transition-fast);
z-index: 2;
}
.chat-media-wrap:hover .chat-media-dl-btn {
opacity: 1;
}
.chat-media-dl-btn:hover {
background: rgba(0, 0, 0, 0.75);
}
/* 下载弹层 */
.chat-media-dl-pop {
position: absolute;
top: 44px;
right: 8px;
z-index: 20;
width: 220px;
padding: var(--space-md);
background: var(--color-card-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
animation: fadeIn var(--transition-fast);
}
.chat-media-dl-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.chat-media-dl-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.chat-media-dl-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.chat-media-dl-fmts {
display: flex;
gap: 4px;
}
.chat-media-dl-fmt {
flex: 1;
padding: 4px 0;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.chat-media-dl-fmt.active {
color: var(--color-text-inverse);
background: var(--color-accent);
border-color: var(--color-accent);
}
.chat-media-dl-sizes {
display: flex;
flex-direction: column;
gap: 6px;
}
.chat-media-dl-size {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.chat-media-dl-size:hover {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.chat-media-dl-size-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.chat-media-dl-size-px {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
/* 灯箱预览 */
.chat-lightbox {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-overlay);
animation: fadeIn 0.2s ease;
}
.chat-lightbox-close {
position: absolute;
top: 18px;
right: 22px;
width: 40px;
height: 40px;
border-radius: var(--radius-full);
border: none;
background: rgba(255, 255, 255, 0.15);
color: #fff;
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: background var(--transition-fast);
}
.chat-lightbox-close:hover {
background: rgba(255, 255, 255, 0.3);
}
.chat-lightbox-content {
max-width: 92vw;
max-height: 86vh;
}
+163 -10
View File
@@ -5,8 +5,146 @@ import type { Conversation, Message } from '@/types';
import './ChatPage.css'; import './ChatPage.css';
// ===== Enhanced Markdown Renderer ===== // ===== Enhanced Markdown Renderer =====
// 媒体(图片/视频)以受限缩略图呈现,并提供下载(格式+尺寸)选项
interface MediaInfo { type: 'img' | 'video'; src: string; alt?: string; }
// 全屏灯箱预览
const ChatLightbox: React.FC<{ src: string; type: 'img' | 'video'; onClose: () => void }> = ({ src, type, onClose }) => {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
return () => document.removeEventListener('keydown', h);
}, [onClose]);
return (
<div className="chat-lightbox" onClick={onClose}>
<button className="chat-lightbox-close" onClick={onClose} aria-label="关闭">×</button>
<div className="chat-lightbox-content" onClick={(e) => e.stopPropagation()}>
{type === 'video'
? <video src={src} controls autoPlay style={{ maxWidth: '92vw', maxHeight: '86vh', borderRadius: 8 }} />
: <img src={src} alt="" style={{ maxWidth: '92vw', maxHeight: '86vh', borderRadius: 8 }} />}
</div>
</div>
);
};
// 根据原始尺寸推算 3 个建议下载尺寸(保持宽高比)
function computeSizes(natural: { w: number; h: number }) {
if (natural.w && natural.h) {
const ratio = natural.w / natural.h;
const fit = (maxDim: number) => ratio >= 1
? { w: maxDim, h: Math.round(maxDim / ratio) }
: { w: Math.round(maxDim * ratio), h: maxDim };
return [
{ label: '小图', ...fit(512) },
{ label: '中图', ...fit(1024) },
{ label: '原始', w: natural.w, h: natural.h },
];
}
// 未知原始尺寸时按生成默认 4:3 给出三档
return [
{ label: '小图', w: 512, h: 384 },
{ label: '中图', w: 1024, h: 768 },
{ label: '大图', w: 2048, h: 1536 },
];
}
// 客户端画布转换格式+尺寸后下载;跨域受限时回退到新标签页打开原图
function downloadImage(src: string, format: 'png' | 'jpg' | 'webp', w: number, h: number) {
const fallback = () => window.open(src, '_blank');
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) { fallback(); return; }
if (format === 'jpg') { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, w, h); }
ctx.drawImage(img, 0, 0, w, h);
const mime = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
canvas.toBlob((blob) => {
if (!blob) { fallback(); return; }
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `aiflow-image-${w}x${h}.${format}`;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}, mime, 0.92);
} catch (e) {
fallback();
}
};
img.onerror = fallback;
img.src = src;
}
// 媒体渲染组件:固定尺寸缩略图 + 点击放大 + 下载(格式/尺寸)
const ChatMedia: React.FC<{ info: MediaInfo }> = ({ info }) => {
const [open, setOpen] = useState(false);
const [dlOpen, setDlOpen] = useState(false);
const [fmt, setFmt] = useState<'png' | 'jpg' | 'webp'>('png');
const [natural, setNatural] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
if (info.type === 'video') {
return (
<div className="chat-media-wrap">
<video className="chat-media-video" src={info.src} controls preload="metadata" onClick={() => setOpen(true)} />
<div className="chat-media-bar">
<a className="chat-media-link" href={info.src} target="_blank" rel="noopener" onClick={(e) => e.stopPropagation()}>打开 / 下载原视频</a>
</div>
{open && <ChatLightbox src={info.src} type="video" onClose={() => setOpen(false)} />}
</div>
);
}
const sizes = computeSizes(natural);
return (
<div className="chat-media-wrap">
<img
className="chat-media-img"
src={info.src}
alt={info.alt || '生成图片'}
loading="lazy"
onClick={() => setOpen(true)}
onLoad={(e) => {
const im = e.currentTarget;
if (im.naturalWidth && im.naturalHeight) setNatural({ w: im.naturalWidth, h: im.naturalHeight });
}}
/>
<button className="chat-media-dl-btn" title="下载图片" onClick={(e) => { e.stopPropagation(); setDlOpen((o) => !o); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
{dlOpen && (
<div className="chat-media-dl-pop" onClick={(e) => e.stopPropagation()}>
<div className="chat-media-dl-title">下载图片</div>
<div className="chat-media-dl-row">
<span className="chat-media-dl-label">格式</span>
<div className="chat-media-dl-fmts">
{(['png', 'jpg', 'webp'] as const).map((f) => (
<button key={f} className={`chat-media-dl-fmt ${fmt === f ? 'active' : ''}`} onClick={() => setFmt(f)}>{f.toUpperCase()}</button>
))}
</div>
</div>
<div className="chat-media-dl-label">尺寸</div>
<div className="chat-media-dl-sizes">
{sizes.map((s, i) => (
<button key={i} className="chat-media-dl-size" onClick={() => { downloadImage(info.src, fmt, s.w, s.h); setDlOpen(false); }}>
<span className="chat-media-dl-size-label">{s.label}</span>
<span className="chat-media-dl-size-px">{s.w}×{s.h}</span>
</button>
))}
</div>
</div>
)}
{open && <ChatLightbox src={info.src} type="img" onClose={() => setOpen(false)} />}
</div>
);
};
const MarkdownContent: React.FC<{ content: string }> = ({ content }) => { const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
const html = useMemo(() => { const nodes = useMemo(() => {
let text = content; let text = content;
// 1. Extract code blocks first (protect from other replacements) // 1. Extract code blocks first (protect from other replacements)
@@ -25,12 +163,14 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
return '\x00INLINE' + idx + '\x00'; return '\x00INLINE' + idx + '\x00';
}); });
// 2.5 Extract media blocks (<img>/<video>) to protect from other replacements // 2.5 Extract media blocks (<img>/<video>) → 结构化,后续渲染为 React 组件
const mediaBlocks: string[] = []; const mediaBlocks: MediaInfo[] = [];
text = text.replace(/<(img|video)[^>]*\/?>/gi, (_m: string) => { text = text.replace(/<(img|video)([^>]*?)\/?>/gi, (_m: string, tag: string, attrs: string) => {
const idx = mediaBlocks.length; const type = tag.toLowerCase() === 'img' ? 'img' : 'video';
mediaBlocks.push(_m); const srcM = attrs.match(/src=["']([^"']+)["']/i);
return '\x00MEDIA' + idx + '\x00'; const altM = attrs.match(/alt=["']([^"']*)["']/i);
mediaBlocks.push({ type, src: srcM ? srcM[1] : '', alt: altM ? altM[1] : '' });
return '\x00MEDIA' + (mediaBlocks.length - 1) + '\x00';
}); });
// 3. Headers // 3. Headers
@@ -66,7 +206,6 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
// 10. Restore code blocks and inline codes // 10. Restore code blocks and inline codes
text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || ''); text = text.replace(/\x00CODEBLOCK(\d+)\x00/g, (_m: string, idx: string) => codeBlocks[parseInt(idx)] || '');
text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || ''); text = text.replace(/\x00INLINE(\d+)\x00/g, (_m: string, idx: string) => inlineCodes[parseInt(idx)] || '');
text = text.replace(/\x00MEDIA(\d+)\x00/g, (_m: string, idx: string) => mediaBlocks[parseInt(idx)] || '');
// 11. Wrap in paragraph and clean up // 11. Wrap in paragraph and clean up
text = '<p class="md-p">' + text + '</p>'; text = '<p class="md-p">' + text + '</p>';
@@ -77,10 +216,24 @@ const MarkdownContent: React.FC<{ content: string }> = ({ content }) => {
text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1'); text = text.replace(/<p class="md-p">(<hr[\s\S]*?)<\/p>/g, '$1');
text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1'); text = text.replace(/<p class="md-p">(<blockquote[\s\S]*?<\/blockquote>)<\/p>/g, '$1');
return text; // 12. 拆分为文本片段与媒体组件
const parts = text.split(/(\x00MEDIA\d+\x00)/g);
return parts.map((part: string, i: number) => {
const m = part.match(/^\x00MEDIA(\d+)\x00$/);
if (m) {
const info = mediaBlocks[parseInt(m[1])];
if (info && info.src) return <ChatMedia key={'m' + i} info={info} />;
return null;
}
if (!part) return null;
// 仅清除被媒体切分产生的悬空 <p> 标签,保留段落内部的成对 <p>
let s = part.replace(/^<\/p>/, '').replace(/<p class="md-p">$/, '');
if (!s.trim()) return null;
return <div key={'t' + i} className="md-text-seg" dangerouslySetInnerHTML={{ __html: s }} />;
});
}, [content]); }, [content]);
return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />; return <div className="markdown-body">{nodes}</div>;
}; };
+15 -5
View File
@@ -44,10 +44,16 @@ const LoginPage: React.FC = () => {
}, 1000); }, 1000);
}; };
const handleSendSms = () => { const handleSendSms = async () => {
if (!regPhone.trim() || smsCountdown > 0) return; if (!regPhone.trim() || smsCountdown > 0) return;
setSmsSent(true); try {
startCountdown(setSmsCountdown); await authAPI.sendCode(regPhone, 'register');
setSmsSent(true);
startCountdown(setSmsCountdown);
setErrorMsg('');
} catch (e: any) {
setErrorMsg(e.detail || e.message || '验证码发送失败');
}
}; };
const handleSendEmailCode = () => { const handleSendEmailCode = () => {
@@ -115,8 +121,12 @@ const LoginPage: React.FC = () => {
setLoading(true); setLoading(true);
try { try {
const data: any = { username: regUsername, password: regPassword }; const data: any = { username: regUsername, password: regPassword };
if (registerMethod === 'phone') data.phone = regPhone; if (registerMethod === 'phone') {
else data.email = regEmail; data.phone = regPhone;
data.sms_code = smsCode;
} else {
data.email = regEmail;
}
const res = await authAPI.register(data); const res = await authAPI.register(data);
const { access_token, user: userData } = res.data; const { access_token, user: userData } = res.data;
localStorage.setItem('aiflow_token', access_token); localStorage.setItem('aiflow_token', access_token);