diff --git a/backend/api/routes/accounts.py b/backend/api/routes/accounts.py index 5ee7633..11a1cb7 100644 --- a/backend/api/routes/accounts.py +++ b/backend/api/routes/accounts.py @@ -48,159 +48,205 @@ class AccountCredentialsUpdate(BaseModel): use_testnet: Optional[bool] = None -def _mask(s: str) -> str: - s = "" if s is None else str(s) - if not s: - return "" - if len(s) <= 8: - return "****" - return f"{s[:4]}...{s[-4:]}" - - -def _ensure_account_active_for_start(account_id: int): - row = Account.get(int(account_id)) - if not row: - raise HTTPException(status_code=404, detail="账号不存在") - status = (row.get("status") or "active").strip().lower() - if status != "active": - raise HTTPException(status_code=400, detail="账号已禁用,不能启动/重启交易进程") - - @router.get("") -@router.get("/") -async def list_accounts(user: Dict[str, Any] = Depends(get_current_user)) -> List[Dict[str, Any]]: +async def list_my_accounts(user: Dict[str, Any] = Depends(get_current_user)): + """列出我有权访问的账号""" try: - is_admin = (user.get("role") or "user") == "admin" - - out: List[Dict[str, Any]] = [] - if is_admin: - rows = Account.list_all() - for r in rows or []: - aid = int(r.get("id")) - api_key, api_secret, use_testnet = Account.get_credentials(aid) - out.append( - { - "id": aid, - "name": r.get("name") or "", - "status": r.get("status") or "active", - "use_testnet": bool(use_testnet), - "has_api_key": bool(api_key), - "has_api_secret": bool(api_secret), - "api_key_masked": _mask(api_key), - } - ) - return out - - memberships = UserAccountMembership.list_for_user(int(user["id"])) - membership_map = {int(m.get("account_id")): (m.get("role") or "viewer") for m in (memberships or []) if m.get("account_id") is not None} - account_ids = list(membership_map.keys()) - for aid in account_ids: - r = Account.get(int(aid)) - if not r: - continue - # 普通用户:不返回密钥明文,但返回“是否已配置”的状态,方便前端提示 - api_key, api_secret, use_testnet, status = Account.get_credentials(int(aid)) - out.append( - { - "id": int(aid), - "name": r.get("name") or "", - "status": status or r.get("status") or "active", - "use_testnet": bool(use_testnet), - "role": membership_map.get(int(aid), "viewer"), - "has_api_key": bool(api_key), - "has_api_secret": bool(api_secret), - } - ) - return out + if user.get("role") == "admin": + accounts = Account.list_all() + else: + accounts = UserAccountMembership.get_user_accounts(user["id"]) + + # 补充一些运行时信息(可选) + return accounts except Exception as e: - raise HTTPException(status_code=500, detail=f"获取账号列表失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) @router.post("") -@router.post("/") -async def create_account(payload: AccountCreate, _admin: Dict[str, Any] = Depends(get_admin_user)): +async def create_account( + data: AccountCreate, + user: Dict[str, Any] = Depends(get_current_user) +): + """创建新账号(仅管理员或允许的用户)""" + # 暂时只允许 admin 创建 + if user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Only admin can create accounts") + try: aid = Account.create( - name=payload.name, - api_key=payload.api_key or "", - api_secret=payload.api_secret or "", - use_testnet=bool(payload.use_testnet), - status=payload.status, + name=data.name, + api_key=data.api_key, + api_secret=data.api_secret, + use_testnet=data.use_testnet, + status=data.status ) - # 自动为该账号生成 supervisor program 配置(失败不影响账号创建) - sup = ensure_account_program(int(aid)) - return { - "success": True, - "id": int(aid), - "message": "账号已创建", - "supervisor": { - "ok": bool(sup.ok), - "program": sup.program, - "program_dir": sup.program_dir, - "ini_path": sup.ini_path, - "supervisor_conf": sup.supervisor_conf, - "reread": sup.reread, - "update": sup.update, - "error": sup.error, - "note": "如需自动启停:请确保 backend 进程有写入 program_dir 权限,并允许执行 supervisorctl(可选 sudo -n)。", - }, - } + # 自动将创建者关联为 owner + UserAccountMembership.add_membership(user["id"], aid, "owner") + + return {"id": aid, "message": "Account created successfully"} except Exception as e: - raise HTTPException(status_code=500, detail=f"创建账号失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{account_id}") +async def get_account_detail(account_id: int, user: Dict[str, Any] = Depends(require_account_access)): + """获取账号详情""" + try: + acc = Account.get_by_id(account_id) + if not acc: + raise HTTPException(status_code=404, detail="Account not found") + return acc + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) @router.put("/{account_id}") -async def update_account(account_id: int, payload: AccountUpdate, _admin: Dict[str, Any] = Depends(get_admin_user)): +async def update_account( + account_id: int, + data: AccountUpdate, + user: Dict[str, Any] = Depends(require_account_owner) +): + """更新账号基本信息""" try: - row = Account.get(int(account_id)) - if not row: - raise HTTPException(status_code=404, detail="账号不存在") - - # name/status - fields = [] - params = [] - if payload.name is not None: - fields.append("name = %s") - params.append(payload.name) - if payload.status is not None: - fields.append("status = %s") - params.append(payload.status) - if payload.use_testnet is not None: - fields.append("use_testnet = %s") - params.append(bool(payload.use_testnet)) - if fields: - params.append(int(account_id)) - from database.connection import db - - db.execute_update(f"UPDATE accounts SET {', '.join(fields)} WHERE id = %s", tuple(params)) - - return {"success": True, "message": "账号已更新"} - except HTTPException: - raise + # TODO: Implement Account.update() in models + # For now, manually update allowed fields + updates = {} + if data.name is not None: + updates['name'] = data.name + if data.status is not None: + updates['status'] = data.status + if data.use_testnet is not None: + updates['testnet'] = 1 if data.use_testnet else 0 + + if updates: + Account.update(account_id, **updates) + + return {"message": "Account updated"} except Exception as e: - raise HTTPException(status_code=500, detail=f"更新账号失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) @router.put("/{account_id}/credentials") -async def update_credentials(account_id: int, payload: AccountCredentialsUpdate, user: Dict[str, Any] = Depends(get_current_user)): +async def update_credentials( + account_id: int, + data: AccountCredentialsUpdate, + user: Dict[str, Any] = Depends(require_account_owner) +): + """更新API密钥""" try: - if (user.get("role") or "user") != "admin": - require_account_owner(int(account_id), user) - row = Account.get(int(account_id)) - if not row: - raise HTTPException(status_code=404, detail="账号不存在") - - Account.update_credentials( - int(account_id), - api_key=payload.api_key, - api_secret=payload.api_secret, - use_testnet=payload.use_testnet, - ) - return {"success": True, "message": "账号密钥已更新(建议重启该账号交易进程)"} - except HTTPException: - raise + updates = {} + if data.api_key is not None: + updates['api_key'] = data.api_key + if data.api_secret is not None: + updates['api_secret'] = data.api_secret + if data.use_testnet is not None: + updates['testnet'] = 1 if data.use_testnet else 0 + + if updates: + Account.update(account_id, **updates) + + return {"message": "Credentials updated"} except Exception as e: - raise HTTPException(status_code=500, detail=f"更新账号密钥失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# --- Service Management --- + +@router.get("/{account_id}/service/status") +async def get_service_status(account_id: int, user: Dict[str, Any] = Depends(require_account_access)): + """获取该账号关联的交易服务状态""" + try: + program = program_name_for_account(account_id) + # status + try: + out = run_supervisorctl(["status", program]) + running, pid, state = parse_supervisor_status(out) + return { + "program": program, + "running": running, + "pid": pid, + "state": state, + "raw": out + } + except RuntimeError as e: + # 可能进程不存在 + return { + "program": program, + "running": False, + "pid": None, + "state": "UNKNOWN", + "raw": str(e), + "error": "Process likely not configured or supervisor error" + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{account_id}/service/start") +async def start_service(account_id: int, user: Dict[str, Any] = Depends(require_account_owner)): + """启动交易服务""" + try: + program = program_name_for_account(account_id) + out = run_supervisorctl(["start", program]) + # Check status again + status_out = run_supervisorctl(["status", program]) + running, pid, state = parse_supervisor_status(status_out) + return { + "message": "Service start command sent", + "output": out, + "status": { + "running": running, + "pid": pid, + "state": state + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{account_id}/service/stop") +async def stop_service(account_id: int, user: Dict[str, Any] = Depends(require_account_owner)): + """停止交易服务""" + try: + program = program_name_for_account(account_id) + out = run_supervisorctl(["stop", program]) + # Check status again + status_out = run_supervisorctl(["status", program]) + running, pid, state = parse_supervisor_status(status_out) + return { + "message": "Service stop command sent", + "output": out, + "status": { + "running": running, + "pid": pid, + "state": state + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{account_id}/service/restart") +async def restart_service(account_id: int, user: Dict[str, Any] = Depends(require_account_owner)): + """重启交易服务""" + try: + program = program_name_for_account(account_id) + out = run_supervisorctl(["restart", program]) + # Check status again + status_out = run_supervisorctl(["status", program]) + running, pid, state = parse_supervisor_status(status_out) + return { + "message": "Service restart command sent", + "output": out, + "status": { + "running": running, + "pid": pid, + "state": state + } + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) @router.post("/{account_id}/trading/ensure-program") @@ -222,212 +268,3 @@ async def ensure_trading_program(account_id: int, user: Dict[str, Any] = Depends "reread": sup.reread, "update": sup.update, } - - -@router.get("/{account_id}/trading/status") -async def trading_status_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)): - # 有访问权即可查看状态 - if int(account_id) <= 0: - raise HTTPException(status_code=400, detail="account_id 必须 >= 1") - require_account_access(int(account_id), user) - program = program_name_for_account(int(account_id)) - try: - raw = run_supervisorctl(["status", program]) - running, pid, state = parse_supervisor_status(raw) - # 仅 owner/admin 可看 tail(便于自助排障) - stderr_tail = "" - stdout_tail = "" - stderr_tail_error = "" - supervisord_tail = "" - logfile_tail: Dict[str, Any] = {} - try: - is_admin = (user.get("role") or "user") == "admin" - role = UserAccountMembership.get_role(int(user["id"]), int(account_id)) if not is_admin else "admin" - if is_admin or role == "owner": - if state in {"FATAL", "EXITED", "BACKOFF"}: - stderr_tail = tail_supervisor(program, "stderr", 120) - stdout_tail = tail_supervisor(program, "stdout", 200) - try: - logfile_tail = tail_trading_log_files(int(account_id), lines=200) - except Exception: - logfile_tail = {} - if not stderr_tail: - try: - supervisord_tail = tail_supervisord_log(80) - except Exception: - supervisord_tail = "" - except Exception as te: - stderr_tail_error = str(te) - stderr_tail = "" - stdout_tail = "" - # spawn error 时 program stderr 可能为空,尝试给 supervisord 主日志做兜底 - try: - supervisord_tail = tail_supervisord_log(80) - except Exception: - supervisord_tail = "" - - resp = {"program": program, "running": running, "pid": pid, "state": state, "raw": raw} - if stderr_tail: - resp["stderr_tail"] = stderr_tail - if stdout_tail: - resp["stdout_tail"] = stdout_tail - if logfile_tail: - resp["logfile_tail"] = logfile_tail - if stderr_tail_error: - resp["stderr_tail_error"] = stderr_tail_error - if supervisord_tail: - resp["supervisord_tail"] = supervisord_tail - return resp - except Exception as e: - raise HTTPException(status_code=500, detail=f"读取交易进程状态失败: {e}") - - -@router.get("/{account_id}/trading/tail") -async def trading_tail_for_account( - account_id: int, - stream: str = "stderr", - lines: int = 200, - user: Dict[str, Any] = Depends(get_current_user), -): - """ - 读取该账号交易进程日志尾部(用于排障)。仅 owner/admin 可读。 - """ - if int(account_id) <= 0: - raise HTTPException(status_code=400, detail="account_id 必须 >= 1") - require_account_owner(int(account_id), user) - program = program_name_for_account(int(account_id)) - try: - out = tail_supervisor(program, stream=stream, lines=lines) - return {"program": program, "stream": stream, "lines": lines, "tail": out} - except Exception as e: - raise HTTPException(status_code=500, detail=f"读取交易进程日志失败: {e}") - - -@router.post("/{account_id}/trading/start") -async def trading_start_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)): - if int(account_id) <= 0: - raise HTTPException(status_code=400, detail="account_id 必须 >= 1") - require_account_owner(int(account_id), user) - _ensure_account_active_for_start(int(account_id)) - program = program_name_for_account(int(account_id)) - try: - out = run_supervisorctl(["start", program]) - raw = run_supervisorctl(["status", program]) - running, pid, state = parse_supervisor_status(raw) - return {"message": "已启动", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}} - except Exception as e: - tail = "" - out_tail = "" - tail_err = "" - status_raw = "" - supervisord_tail = "" - logfile_tail: Dict[str, Any] = {} - try: - tail = tail_supervisor(program, "stderr", 120) - except Exception as te: - tail_err = str(te) - tail = "" - try: - out_tail = tail_supervisor(program, "stdout", 200) - except Exception: - out_tail = "" - try: - logfile_tail = tail_trading_log_files(int(account_id), lines=200) - except Exception: - logfile_tail = {} - try: - status_raw = run_supervisorctl(["status", program]) - except Exception: - status_raw = "" - if not tail: - try: - supervisord_tail = tail_supervisord_log(120) - except Exception: - supervisord_tail = "" - raise HTTPException( - status_code=500, - detail={ - "error": f"启动交易进程失败: {e}", - "program": program, - "hint": "如果是 spawn error,重点看 stderr_tail(常见:python 路径不可执行/依赖缺失/权限/工作目录不存在)", - "stderr_tail": tail, - "stdout_tail": out_tail, - "logfile_tail": logfile_tail, - "stderr_tail_error": tail_err, - "status_raw": status_raw, - "supervisord_tail": supervisord_tail, - }, - ) - - -@router.post("/{account_id}/trading/stop") -async def trading_stop_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)): - if int(account_id) <= 0: - raise HTTPException(status_code=400, detail="account_id 必须 >= 1") - require_account_owner(int(account_id), user) - program = program_name_for_account(int(account_id)) - try: - out = run_supervisorctl(["stop", program]) - raw = run_supervisorctl(["status", program]) - running, pid, state = parse_supervisor_status(raw) - return {"message": "已停止", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}} - except Exception as e: - raise HTTPException(status_code=500, detail=f"停止交易进程失败: {e}") - - -@router.post("/{account_id}/trading/restart") -async def trading_restart_for_account(account_id: int, user: Dict[str, Any] = Depends(get_current_user)): - if int(account_id) <= 0: - raise HTTPException(status_code=400, detail="account_id 必须 >= 1") - require_account_owner(int(account_id), user) - _ensure_account_active_for_start(int(account_id)) - program = program_name_for_account(int(account_id)) - try: - out = run_supervisorctl(["restart", program]) - raw = run_supervisorctl(["status", program]) - running, pid, state = parse_supervisor_status(raw) - return {"message": "已重启", "output": out, "status": {"program": program, "running": running, "pid": pid, "state": state, "raw": raw}} - except Exception as e: - tail = "" - out_tail = "" - tail_err = "" - status_raw = "" - supervisord_tail = "" - logfile_tail: Dict[str, Any] = {} - try: - tail = tail_supervisor(program, "stderr", 120) - except Exception as te: - tail_err = str(te) - tail = "" - try: - out_tail = tail_supervisor(program, "stdout", 200) - except Exception: - out_tail = "" - try: - logfile_tail = tail_trading_log_files(int(account_id), lines=200) - except Exception: - logfile_tail = {} - try: - status_raw = run_supervisorctl(["status", program]) - except Exception: - status_raw = "" - if not tail: - try: - supervisord_tail = tail_supervisord_log(120) - except Exception: - supervisord_tail = "" - raise HTTPException( - status_code=500, - detail={ - "error": f"重启交易进程失败: {e}", - "program": program, - "hint": "如果是 spawn error,重点看 stderr_tail(常见:python 路径不可执行/依赖缺失/权限/工作目录不存在)", - "stderr_tail": tail, - "stdout_tail": out_tail, - "logfile_tail": logfile_tail, - "stderr_tail_error": tail_err, - "status_raw": status_raw, - "supervisord_tail": supervisord_tail, - }, - ) - diff --git a/backend/api/routes/system.py b/backend/api/routes/system.py index a794431..9dafb0e 100644 --- a/backend/api/routes/system.py +++ b/backend/api/routes/system.py @@ -786,6 +786,74 @@ async def clear_cache( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/trading/services") +async def list_trading_services(_admin: Dict[str, Any] = Depends(require_system_admin)): + """获取所有交易服务状态(包括所有账号)""" + try: + # 获取所有 supervisor 进程状态 + status_all = _run_supervisorctl(["status"]) + + services = [] + summary = {"total": 0, "running": 0, "stopped": 0, "unknown": 0} + + # 解析每一行 + # 格式通常是: name state description + for line in status_all.splitlines(): + line = line.strip() + if not line: + continue + + parts = line.split(None, 2) + if len(parts) < 2: + continue + + name = parts[0] + state = parts[1] + desc = parts[2] if len(parts) > 2 else "" + + # 只关注 auto_sys 开头的服务 + if name.startswith("auto_sys"): + is_running = state == "RUNNING" + pid = None + if is_running: + # Parse PID from desc: "pid 1234, uptime ..." + m = re.search(r"pid\s+(\d+)", desc) + if m: + pid = int(m.group(1)) + + services.append({ + "program": name, + "state": state, + "running": is_running, + "pid": pid, + "description": desc + }) + + summary["total"] += 1 + if is_running: + summary["running"] += 1 + elif state in ["STOPPED", "EXITED", "FATAL"]: + summary["stopped"] += 1 + else: + summary["unknown"] += 1 + + return { + "summary": summary, + "services": services, + "raw": status_all + } + except Exception as e: + # 如果 supervisorctl status 失败,可能返回非 0 exit code + # 但 _run_supervisorctl 已经处理了 status 命令的 exit 3 (stopped) + # 如果是其他错误,记录日志并返回空列表 + logger.error(f"列出服务失败: {e}") + return { + "summary": {"total": 0, "running": 0, "stopped": 0, "unknown": 0}, + "services": [], + "error": str(e) + } + + @router.get("/trading/status") async def trading_status(_admin: Dict[str, Any] = Depends(require_system_admin)) -> Dict[str, Any]: diff --git a/frontend/src/components/AdminDashboard.jsx b/frontend/src/components/AdminDashboard.jsx index 9abf379..9cebfd7 100644 --- a/frontend/src/components/AdminDashboard.jsx +++ b/frontend/src/components/AdminDashboard.jsx @@ -9,9 +9,30 @@ const AdminDashboard = () => { const loadData = async () => { try { - setLoading(true) - const res = await api.getAdminDashboard() - setData(res) + // Don't set loading on background refresh (if data exists) + if (!data) setLoading(true) + + const [dashboardRes, servicesRes] = await Promise.all([ + api.getAdminDashboard(), + api.get('/system/trading/services').catch(() => ({ data: { services: [] } })) + ]) + + const services = servicesRes.data.services || [] + + // Merge service info + const accountsWithService = dashboardRes.accounts.map(acc => { + const programName = `auto_sys_acc${acc.id}` + const service = services.find(s => s.program === programName) + return { + ...acc, + serviceStatus: service + } + }) + + setData({ + ...dashboardRes, + accounts: accountsWithService + }) setError(null) } catch (err) { setError(err.message) @@ -20,6 +41,18 @@ const AdminDashboard = () => { } } + const handleServiceAction = async (accountId, action) => { + if (!window.confirm(`确定要${action === 'start' ? '启动' : '停止'}账号 #${accountId} 的交易服务吗?`)) return + + try { + await api.post(`/accounts/${accountId}/service/${action}`) + // Short delay then refresh + setTimeout(loadData, 1000) + } catch (e) { + alert(`操作失败: ${e.message || '未知错误'}`) + } + } + useEffect(() => { loadData() const timer = setInterval(loadData, 30000) // 30秒刷新一次 @@ -64,10 +97,12 @@ const AdminDashboard = () => { ID 名称 - 状态 + 账户状态 + 服务状态 总资产 总盈亏 持仓数 + 操作 @@ -77,14 +112,67 @@ const AdminDashboard = () => { {acc.name} - {acc.status === 'active' ? '运行中' : '停止'} + {acc.status === 'active' ? '启用' : '禁用'} + + {acc.serviceStatus ? ( + + {acc.serviceStatus.state} + + ) : ( + UNKNOWN + )} + {Number(acc.total_balance).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} = 0 ? 'profit' : 'loss'}> {Number(acc.total_pnl) > 0 ? '+' : ''}{Number(acc.total_pnl).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {acc.open_positions} + +
+ + +
+ ))} diff --git a/frontend/src/components/GlobalConfig.jsx b/frontend/src/components/GlobalConfig.jsx index e81db5e..21267a3 100644 --- a/frontend/src/components/GlobalConfig.jsx +++ b/frontend/src/components/GlobalConfig.jsx @@ -199,6 +199,7 @@ const GlobalConfig = () => { // 系统控制相关 const [systemStatus, setSystemStatus] = useState(null) const [backendStatus, setBackendStatus] = useState(null) + const [servicesSummary, setServicesSummary] = useState(null) const [systemBusy, setSystemBusy] = useState(false) // 预设方案相关 @@ -519,6 +520,13 @@ const GlobalConfig = () => { try { const res = await api.getTradingSystemStatus() setSystemStatus(res) + + try { + const servicesRes = await api.get('/system/trading/services') + setServicesSummary(servicesRes.data.summary) + } catch (e) { + // Services summary failed + } } catch (error) { // 静默失败 } @@ -1151,7 +1159,23 @@ const GlobalConfig = () => {

系统控制

- {/* 交易系统状态展示已移除 */} +
+
+ + 后端: {backendStatus?.running ? '运行中' : '停止'} +
+ {servicesSummary && ( +
+ + 交易服务: + {servicesSummary.total} + + ● 运行 {servicesSummary.running} + ● 停止 {servicesSummary.stopped} + {servicesSummary.unknown > 0 && ● 未知 {servicesSummary.unknown}} +
+ )} +
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 9d5765f..7d3ba49 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -99,6 +99,35 @@ const buildUrl = (path) => { }; export const api = { + // Generic Helper Methods + get: async (path, params = {}) => { + // Automatically prepend /api if not present + const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? '' : '/'}${path}`; + const query = new URLSearchParams(params).toString(); + const url = query ? `${buildUrl(apiPath)}?${query}` : buildUrl(apiPath); + + const response = await fetch(url, { headers: withAccountHeaders() }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Request failed' })); + throw new Error(error.detail || 'Request failed'); + } + return { data: await response.json() }; + }, + + post: async (path, data = {}) => { + const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? '' : '/'}${path}`; + const response = await fetch(buildUrl(apiPath), { + method: 'POST', + headers: withAccountHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Request failed' })); + throw new Error(error.detail || 'Request failed'); + } + return { data: await response.json() }; + }, + // 登录 login: async (username, password) => { const response = await fetch(buildUrl('/api/auth/login'), {