From 2afeb5249c7fa4f5849d0953eda52a5285586fbc Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 12 Jun 2026 17:10:12 +0800 Subject: [PATCH 01/17] Update metrics.yml Update uv.lock --- .github/workflows/metrics.yml | 3 ++- uv.lock | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index aac16fdf5..c882fc2cf 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -11,6 +11,7 @@ permissions: jobs: github-metrics: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: lowlighter/metrics@latest with: @@ -20,5 +21,5 @@ jobs: user: Mai-with-u repo: MaiBot base: header, activity, repositories, metadata + plugins_errors_fatal: yes plugin_followup: yes - plugin_contributors: yes \ No newline at end of file diff --git a/uv.lock b/uv.lock index 9c0cf5f1b..bdc024f6c 100644 --- a/uv.lock +++ b/uv.lock @@ -876,7 +876,7 @@ requires-dist = [ { name = "httpx", extras = ["socks"] }, { name = "jieba", specifier = ">=0.42.1" }, { name = "json-repair", specifier = ">=0.47.6" }, - { name = "maibot-dashboard", specifier = "==1.4.0.dev20260608205" }, + { name = "maibot-dashboard", specifier = "==1.4.0" }, { name = "maibot-plugin-sdk", specifier = ">=2.5.3" }, { name = "maim-message", specifier = "==0.6.8" }, { name = "mcp" }, @@ -912,11 +912,11 @@ dev = [ [[package]] name = "maibot-dashboard" -version = "1.4.0.dev20260608205" +version = "1.4.0" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/6b/1d23eea2546a5860028b9af486d481fded39aef84a878e1bde04bc939b3e/maibot_dashboard-1.4.0.dev20260608205.tar.gz", hash = "sha256:ddd1ad1c8972bb400653cb93fc0c77711dc348fd1903c3225828e247150f9659", size = 2543132, upload-time = "2026-06-08T17:46:18.58Z" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/21/fc860e2751d784ed2967acbc924a2ac9e121e60e1b477cb5fbb89000584a/maibot_dashboard-1.4.0.tar.gz", hash = "sha256:e5e765be208dae850be87f3535eb365294ef71519e120938124e578df3ed1603", size = 2552102, upload-time = "2026-06-12T08:49:47.01Z" } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/46/eea37f4383dca7b400a26fc6a91064a9ec62a253a7e81fc32f624ef47e36/maibot_dashboard-1.4.0.dev20260608205-py3-none-any.whl", hash = "sha256:7fcf5a1a822fd2ed25ef1a05c88c7e0f1d06a77f9a83ee2f93bceb313fbe024c", size = 2610751, upload-time = "2026-06-08T17:46:17.071Z" }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/ba/c510c4b0e6d028c1b502170f30d9b692bf9d3fa274b1203b6317980be8fb/maibot_dashboard-1.4.0-py3-none-any.whl", hash = "sha256:18d978eaaab16fb3fe70fe8b2842f70d9b289bcd68e1ccc6276740ec7f474025", size = 2618899, upload-time = "2026-06-12T08:49:45.498Z" }, ] [[package]] From 051a0cb38d84d0778b9b5b21f39a5f54336e7989 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 12 Jun 2026 20:12:51 +0800 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E5=9C=A8=E5=B0=8F=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E5=85=BC=E5=AE=B9=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/config/config.py | 2 +- src/learners/behavior_pattern_maintenance.py | 77 ++++++++++++++++--- src/learners/behavior_selector.py | 5 +- src/plugin_runtime/__init__.py | 32 ++++++++ src/plugin_runtime/host/rpc_server.py | 12 +-- src/plugin_runtime/host/supervisor.py | 7 +- .../runner/manifest_validator.py | 70 +++++++++++++---- uv.lock | 2 +- 9 files changed, 174 insertions(+), 35 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6fdf5f320..a03ea22c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "MaiBot" -version = "1.0.0" +version = "1.0.1" description = "MaiCore 是一个基于大语言模型的可交互智能体" requires-python = ">=3.12" dependencies = [ diff --git a/src/config/config.py b/src/config/config.py index 936bdd6d5..e2c5cfa36 100644 --- a/src/config/config.py +++ b/src/config/config.py @@ -59,7 +59,7 @@ MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute() LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute() A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute() -MMC_VERSION: str = "1.0.0" +MMC_VERSION: str = "1.0.1" CONFIG_VERSION: str = "8.14.2" MODEL_CONFIG_VERSION: str = "1.17.3" diff --git a/src/learners/behavior_pattern_maintenance.py b/src/learners/behavior_pattern_maintenance.py index 8240364cf..8a430e786 100644 --- a/src/learners/behavior_pattern_maintenance.py +++ b/src/learners/behavior_pattern_maintenance.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Any, Optional, Sequence -from sqlmodel import select +from sqlmodel import Session, select import difflib import json @@ -131,10 +131,12 @@ def maintain_session( if decay_result.decayed or decay_result.disabled: session.add(pattern) + cluster_distribution_by_id = self._load_cluster_distributions(session, patterns) merged_count = self._merge_similar_patterns( session_patterns=patterns, now=maintenance_time, touched_pattern_ids=touched_pattern_ids, + cluster_distribution_by_id=cluster_distribution_by_id, ) result = BehaviorPatternMaintenanceResult( @@ -340,6 +342,7 @@ def _merge_similar_patterns( session_patterns: list[BehaviorExperiencePath], now: datetime, touched_pattern_ids: list[int], + cluster_distribution_by_id: dict[int, str], ) -> int: merged_count = 0 merged_pattern_ids: set[int] = set() @@ -360,7 +363,11 @@ def _merge_similar_patterns( for right_pattern in patterns[left_index + 1 :]: if right_pattern.id in merged_pattern_ids or not right_pattern.enabled: continue - if not self._should_merge(left_pattern, right_pattern): + if not self._should_merge( + left_pattern, + right_pattern, + cluster_distribution_by_id=cluster_distribution_by_id, + ): continue keeper, duplicate = self._choose_keeper(left_pattern, right_pattern) @@ -377,6 +384,29 @@ def _merge_similar_patterns( return merged_count + @staticmethod + def _load_cluster_distributions( + session: Session, + patterns: Sequence[BehaviorExperiencePath], + ) -> dict[int, str]: + cluster_ids = { + int(pattern.scene_cluster_id) + for pattern in patterns + if pattern.scene_cluster_id is not None + } + if not cluster_ids: + return {} + + statement = select(BehaviorSceneCluster).where( + BehaviorSceneCluster.id.in_(cluster_ids) # type: ignore[attr-defined] + ) + clusters = session.exec(statement).all() + return { + int(cluster.id): str(cluster.tag_distribution or "") + for cluster in clusters + if cluster.id is not None + } + @staticmethod def _text_similarity(left_text: str, right_text: str) -> float: normalized_left = " ".join(str(left_text or "").split()).strip() @@ -395,10 +425,19 @@ def _path_payload(path: BehaviorExperiencePath) -> dict[str, str]: } @staticmethod - def _cluster_distribution(path: BehaviorExperiencePath) -> str: + def _cluster_distribution( + path: BehaviorExperiencePath, + *, + cluster_distribution_by_id: Optional[dict[int, str]] = None, + ) -> str: + if path.scene_cluster_id is None: + return "" + cluster_id = int(path.scene_cluster_id) + if cluster_distribution_by_id is not None: + return cluster_distribution_by_id.get(cluster_id, "") try: with get_db_session(auto_commit=False) as session: - scene_cluster = session.get(BehaviorSceneCluster, path.scene_cluster_id) + scene_cluster = session.get(BehaviorSceneCluster, cluster_id) if scene_cluster is None: return "" return str(scene_cluster.tag_distribution or "") @@ -407,9 +446,19 @@ def _cluster_distribution(path: BehaviorExperiencePath) -> str: return "" @classmethod - def _cluster_distribution_overlap(cls, left_pattern: BehaviorExperiencePath, right_pattern: BehaviorExperiencePath) -> float: - left_distribution = cls._load_cluster_distribution(cls._cluster_distribution(left_pattern)) - right_distribution = cls._load_cluster_distribution(cls._cluster_distribution(right_pattern)) + def _cluster_distribution_overlap( + cls, + left_pattern: BehaviorExperiencePath, + right_pattern: BehaviorExperiencePath, + *, + cluster_distribution_by_id: Optional[dict[int, str]] = None, + ) -> float: + left_distribution = cls._load_cluster_distribution( + cls._cluster_distribution(left_pattern, cluster_distribution_by_id=cluster_distribution_by_id) + ) + right_distribution = cls._load_cluster_distribution( + cls._cluster_distribution(right_pattern, cluster_distribution_by_id=cluster_distribution_by_id) + ) if not left_distribution or not right_distribution: return 0.0 shared_tags = set(left_distribution) & set(right_distribution) @@ -441,13 +490,23 @@ def _load_cluster_distribution(raw_value: str) -> dict[str, float]: return {} return {tag: probability / total_probability for tag, probability in distribution.items()} - def _should_merge(self, left_pattern: BehaviorExperiencePath, right_pattern: BehaviorExperiencePath) -> bool: + def _should_merge( + self, + left_pattern: BehaviorExperiencePath, + right_pattern: BehaviorExperiencePath, + *, + cluster_distribution_by_id: dict[int, str], + ) -> bool: if left_pattern.actor_type != right_pattern.actor_type or left_pattern.learning_type != right_pattern.learning_type: return False left_payload = self._path_payload(left_pattern) right_payload = self._path_payload(right_pattern) - cluster_overlap = self._cluster_distribution_overlap(left_pattern, right_pattern) + cluster_overlap = self._cluster_distribution_overlap( + left_pattern, + right_pattern, + cluster_distribution_by_id=cluster_distribution_by_id, + ) if cluster_overlap < MERGE_CLUSTER_DISTRIBUTION_MIN_OVERLAP: return False diff --git a/src/learners/behavior_selector.py b/src/learners/behavior_selector.py index b47dd77cd..9993b8af4 100644 --- a/src/learners/behavior_selector.py +++ b/src/learners/behavior_selector.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, field from typing import Any +import asyncio + from src.common.logger import get_logger from src.common.utils.utils_config import BehaviorConfigUtils, ChatConfigUtils from src.config.config import global_config @@ -251,7 +253,8 @@ async def retrieve_for_planner( sub_agent_runner=scenario_agent_runner, include_context_in_prompt=include_context_in_prompt, ) - candidates = self._load_behavior_candidates( + candidates = await asyncio.to_thread( + self._load_behavior_candidates, session_id, scenario_profile=scenario_profile, max_count=max(1, min(3, int(max_count))), diff --git a/src/plugin_runtime/__init__.py b/src/plugin_runtime/__init__.py index 4292358f3..cea581fcf 100644 --- a/src/plugin_runtime/__init__.py +++ b/src/plugin_runtime/__init__.py @@ -4,6 +4,12 @@ 这些环境变量用于子进程 IPC 通信,值在运行时动态生成。 """ +from functools import lru_cache +from pathlib import Path + +import tomllib + + # Host 端在 spawn Runner 子进程时设置、Runner 端启动时读取的环境变量名 ENV_IPC_ADDRESS = "MAIBOT_IPC_ADDRESS" """IPC 传输层监听地址(UDS socket 路径或 TCP host:port)""" @@ -28,3 +34,29 @@ ENV_GLOBAL_CONFIG_SNAPSHOT = "MAIBOT_GLOBAL_CONFIG_SNAPSHOT" """Runner 启动时注入的全局配置快照(JSON 对象)""" + + +@lru_cache(maxsize=None) +def detect_host_application_version(project_root: Path | None = None) -> str: + """读取当前 Host 应用版本号。 + + Args: + project_root: 项目根目录;留空时自动从当前文件位置推断。 + + Returns: + str: ``pyproject.toml`` 中声明的主程序版本;读取失败时返回空字符串。 + """ + + root = project_root or Path(__file__).resolve().parents[2] + pyproject_path = root / "pyproject.toml" + try: + with pyproject_path.open("rb") as pyproject_file: + pyproject_data = tomllib.load(pyproject_file) + except Exception: + return "" + + project_data = pyproject_data.get("project", {}) + if not isinstance(project_data, dict): + return "" + + return str(project_data.get("version", "") or "").strip() diff --git a/src/plugin_runtime/host/rpc_server.py b/src/plugin_runtime/host/rpc_server.py index 0d3ffa94c..19071ccaf 100644 --- a/src/plugin_runtime/host/rpc_server.py +++ b/src/plugin_runtime/host/rpc_server.py @@ -7,7 +7,7 @@ 4. 请求-响应关联与超时管理 """ -from typing import Any, Callable, Dict, List, Optional, Tuple, Coroutine +from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple import asyncio import contextlib @@ -16,14 +16,14 @@ import time from src.common.logger import get_logger +from src.plugin_runtime import detect_host_application_version from src.plugin_runtime.protocol.codec import Codec, MsgPackCodec from src.plugin_runtime.protocol.envelope import ( - PROTOCOL_VERSION, - MIN_SDK_VERSION, - MAX_SDK_VERSION, Envelope, HelloPayload, HelloResponsePayload, + MAX_SDK_VERSION, + MIN_SDK_VERSION, MessageType, RequestIdGenerator, ) @@ -48,11 +48,13 @@ def __init__( session_token: Optional[str] = None, codec: Optional[Codec] = None, send_queue_size: int = 128, + host_version: str = "", ): self._transport = transport self._session_token = session_token or secrets.token_hex(32) self._codec = codec or MsgPackCodec() self._send_queue_size = send_queue_size + self._host_version = host_version or detect_host_application_version() self._id_gen = RequestIdGenerator() self._connection: Optional[Connection] = None # 当前活跃的 Runner 连接 @@ -351,7 +353,7 @@ async def _handle_handshake(self, conn: Connection) -> bool: # 发送响应 self.clear_handshake_state() - resp_payload = HelloResponsePayload(accepted=True, host_version=PROTOCOL_VERSION) + resp_payload = HelloResponsePayload(accepted=True, host_version=self._host_version) resp = envelope.make_response(payload=resp_payload.model_dump()) await conn.send_frame(self._codec.encode_envelope(resp)) return True diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py index e92e856c2..ba68dc661 100644 --- a/src/plugin_runtime/host/supervisor.py +++ b/src/plugin_runtime/host/supervisor.py @@ -23,6 +23,7 @@ ENV_PLUGIN_DIRS, ENV_RUNNER_GROUP, ENV_SESSION_TOKEN, + detect_host_application_version, ) from src.plugin_runtime.protocol.envelope import ( BootstrapPluginPayload, @@ -35,7 +36,6 @@ LLMProviderInvokePayload, MessageGatewayStateUpdatePayload, MessageGatewayStateUpdateResultPayload, - PROTOCOL_VERSION, ReceiveExternalMessageResultPayload, RegisterPluginPayload, ReloadPluginResultPayload, @@ -114,6 +114,7 @@ def __init__( runtime_config = global_config.plugin_runtime self._group_name: str = str(group_name or "third_party").strip() or "third_party" self._plugin_dirs: List[Path] = plugin_dirs or [] + self._host_version: str = detect_host_application_version(_PROJECT_ROOT) self._health_interval: float = health_check_interval_sec or runtime_config.health_check_interval_sec or 30.0 self._runner_spawn_timeout: float = runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0 self._max_restart_attempts: int = max_restart_attempts or runtime_config.max_restart_attempts or 3 @@ -132,7 +133,7 @@ def __init__( self._log_bridge = RunnerLogBridge() codec = MsgPackCodec() - self._rpc_server = RPCServer(transport=self._transport, codec=codec) + self._rpc_server = RPCServer(transport=self._transport, codec=codec, host_version=self._host_version) self._runner_process: Optional[asyncio.subprocess.Process] = None self._registered_plugins: Dict[str, RegisterPluginPayload] = {} @@ -1487,7 +1488,7 @@ def _build_runner_environment(self) -> Dict[str, str]: return { ENV_BLOCKED_PLUGIN_REASONS: json.dumps(self._blocked_plugin_reasons, ensure_ascii=False), ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugins, ensure_ascii=False), - ENV_HOST_VERSION: PROTOCOL_VERSION, + ENV_HOST_VERSION: self._host_version, ENV_IPC_ADDRESS: self._transport.get_address(), ENV_PLUGIN_DIRS: os.pathsep.join(str(path) for path in self._plugin_dirs), ENV_RUNNER_GROUP: self._group_name, diff --git a/src/plugin_runtime/runner/manifest_validator.py b/src/plugin_runtime/runner/manifest_validator.py index 887907ec1..9d3ca0938 100644 --- a/src/plugin_runtime/runner/manifest_validator.py +++ b/src/plugin_runtime/runner/manifest_validator.py @@ -11,7 +11,6 @@ import json import re -import tomllib from packaging.requirements import InvalidRequirement, Requirement from packaging.specifiers import InvalidSpecifier, SpecifierSet @@ -20,6 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator from src.common.logger import get_logger +from src.plugin_runtime import detect_host_application_version logger = get_logger("plugin_runtime.runner.manifest_validator") @@ -140,6 +140,25 @@ def is_in_range(version: str, min_version: str = "", max_version: str = "") -> T return False, f"版本 {normalized_version} 高于最大支持 {normalized_max_version}" return True, "" + @staticmethod + def is_same_major_higher_version(version: str, max_version: str) -> bool: + """判断版本是否仅在同一主版本内高于声明上限。 + + Args: + version: 当前版本号。 + max_version: Manifest 声明的最大支持版本号。 + + Returns: + bool: 当前版本高于上限且两者主版本号相同时返回 ``True``。 + """ + + if not version or not max_version: + return False + + current_major, _current_minor, _current_patch = VersionComparator.parse_version(version) + max_major, _max_minor, _max_patch = VersionComparator.parse_version(max_version) + return current_major == max_major and VersionComparator.compare(version, max_version) > 0 + @staticmethod def is_valid_semver(version: str) -> bool: """判断字符串是否为严格三段式语义版本号。 @@ -765,6 +784,7 @@ class ManifestValidator: """严格的插件 Manifest v2 校验器。""" SUPPORTED_MANIFEST_VERSIONS = [2] + _LOGGED_WARNING_KEYS: Set[Tuple[str, str]] = set() def __init__( self, @@ -829,6 +849,7 @@ def parse_manifest(self, manifest: Dict[str, Any], source: Optional[str] = None) if self.errors: self._log_errors(source=source or parsed_manifest.id) return None + self._log_warnings(source=source or parsed_manifest.id) return parsed_manifest @@ -992,7 +1013,16 @@ def _validate_runtime_compatibility(self, manifest: PluginManifest) -> None: manifest.host_application.max_version, ) if not host_ok: - self.errors.append(f"Host 版本不兼容: {host_message} (当前 Host: {self._host_version})") + if VersionComparator.is_same_major_higher_version( + self._host_version, + manifest.host_application.max_version, + ): + self.warnings.append( + f"Host {self._host_version} 超出声明上限 " + f"{VersionComparator.normalize_version(manifest.host_application.max_version)},兼容模式加载" + ) + else: + self.errors.append(f"Host 版本不兼容: {host_message} (当前 Host: {self._host_version})") sdk_ok, sdk_message = VersionComparator.is_in_range( self._sdk_version, @@ -1118,6 +1148,29 @@ def _log_errors(self, source: Optional[str] = None) -> None: return logger.error(f"Manifest 校验失败: 共 {len(self.errors)} 项,{error_summary}") + def _log_warnings(self, source: Optional[str] = None) -> None: + """输出当前累计的 Manifest 兼容性警告。""" + if not self.warnings: + return + + source_key = source or "" + pending_warnings = [ + warning + for warning in self.warnings + if (source_key, warning) not in self._LOGGED_WARNING_KEYS + ] + if not pending_warnings: + return + + for warning in pending_warnings: + self._LOGGED_WARNING_KEYS.add((source_key, warning)) + + warning_summary = ";".join(pending_warnings) + if source: + logger.warning(f"Manifest 兼容模式 [{source}]: 共 {len(pending_warnings)} 项,{warning_summary}") + return + logger.warning(f"Manifest 兼容模式: 共 {len(pending_warnings)} 项,{warning_summary}") + @classmethod def _resolve_project_root(cls) -> Path: """推断当前项目根目录。 @@ -1138,18 +1191,7 @@ def _detect_default_host_version(cls, project_root: Path) -> str: Returns: str: 探测到的 Host 版本号;失败时返回空字符串。 """ - pyproject_path = project_root / "pyproject.toml" - try: - with pyproject_path.open("rb") as pyproject_file: - pyproject_data = tomllib.load(pyproject_file) - except Exception: - return "" - - project_data = pyproject_data.get("project", {}) - if not isinstance(project_data, dict): - return "" - - raw_version = str(project_data.get("version", "") or "").strip() + raw_version = detect_host_application_version(project_root) if VersionComparator.is_valid_project_version(raw_version): return raw_version return "" diff --git a/uv.lock b/uv.lock index bdc024f6c..ea8adf790 100644 --- a/uv.lock +++ b/uv.lock @@ -815,7 +815,7 @@ wheels = [ [[package]] name = "maibot" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "ahocorasick-rs" }, From 84e42fe1fb93acb9e42236a15366c3f4661fa545 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 12 Jun 2026 20:23:37 +0800 Subject: [PATCH 03/17] =?UTF-8?q?script:=20=E6=B7=BB=E5=8A=A0=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/generate_repository_metrics.py | 184 ++++++++++++++++++ .github/workflows/metrics.yml | 33 +++- README.md | 2 +- depends-data/repository-metrics.svg | 87 +++++++++ 4 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 .github/scripts/generate_repository_metrics.py create mode 100644 depends-data/repository-metrics.svg diff --git a/.github/scripts/generate_repository_metrics.py b/.github/scripts/generate_repository_metrics.py new file mode 100644 index 000000000..108567c94 --- /dev/null +++ b/.github/scripts/generate_repository_metrics.py @@ -0,0 +1,184 @@ +from datetime import datetime, timedelta, timezone +from html import escape +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.error import HTTPError +from urllib.parse import quote_plus +from urllib.request import Request, urlopen + +import argparse +import json +import os +import re + + +API_ROOT = "https://api.github.com" +LINK_LAST_PAGE_RE = re.compile(r"[?&]page=(\d+)>;\s*rel=\"last\"") + + +def request_json(path: str, token: Optional[str]) -> Tuple[Any, Dict[str, str]]: + request = Request(f"{API_ROOT}{path}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", "2022-11-28") + if token: + request.add_header("Authorization", f"Bearer {token}") + + try: + with urlopen(request, timeout=30) as response: + body = response.read().decode("utf-8") + return json.loads(body), dict(response.headers) + except HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + raise RuntimeError(f"GitHub API 请求失败: {error.code} {error.reason}: {body}") from error + + +def count_paginated(path: str, token: Optional[str]) -> int: + data, headers = request_json(path, token) + if not isinstance(data, list): + raise RuntimeError(f"GitHub API 返回了非列表数据: {path}") + + link = headers.get("Link", "") + match = LINK_LAST_PAGE_RE.search(link) + if match: + return int(match.group(1)) + return len(data) + + +def search_count(query: str, token: Optional[str]) -> int: + data, _ = request_json(f"/search/issues?q={quote_plus(query)}&per_page=1", token) + return int(data["total_count"]) + + +def format_number(value: int) -> str: + return f"{value:,}" + + +def parse_time(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def format_relative_time(value: str, now: datetime) -> str: + delta = now - parse_time(value) + if delta.days >= 1: + return f"{delta.days} 天前" + hours = max(1, delta.seconds // 3600) + return f"{hours} 小时前" + + +def collect_metrics(repo: str, token: Optional[str]) -> Dict[str, Any]: + now = datetime.now(timezone.utc) + since = now - timedelta(days=30) + since_iso = since.isoformat(timespec="seconds").replace("+00:00", "Z") + + repository, _ = request_json(f"/repos/{repo}", token) + default_branch = repository["default_branch"] + latest_commit, _ = request_json(f"/repos/{repo}/commits/{default_branch}", token) + + return { + "repo": repo, + "description": repository.get("description") or "", + "stars": int(repository["stargazers_count"]), + "forks": int(repository["forks_count"]), + "watchers": int(repository["subscribers_count"]), + "open_issues": search_count(f"repo:{repo} type:issue state:open", token), + "open_prs": search_count(f"repo:{repo} type:pr state:open", token), + "closed_issues_30d": search_count(f"repo:{repo} type:issue state:closed closed:>={since.date()}", token), + "merged_prs_30d": search_count(f"repo:{repo} type:pr is:merged merged:>={since.date()}", token), + "commits_30d": count_paginated( + f"/repos/{repo}/commits?sha={default_branch}&since={since_iso}&per_page=1", + token, + ), + "contributors": count_paginated(f"/repos/{repo}/contributors?anon=true&per_page=1", token), + "default_branch": default_branch, + "latest_sha": latest_commit["sha"][:7], + "latest_message": latest_commit["commit"]["message"].splitlines()[0], + "latest_commit_at": latest_commit["commit"]["author"]["date"], + "generated_at": now.astimezone(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M UTC+8"), + } + + +def metric_card(x: int, y: int, title: str, value: str, detail: str, color: str) -> str: + return f""" + + + + {escape(title)} + {escape(value)} + {escape(detail)} + """ + + +def render_svg(metrics: Dict[str, Any]) -> str: + cards: List[Tuple[str, str, str, str]] = [ + ("Stars", format_number(metrics["stars"]), "累计收藏", "#f59f00"), + ("Forks", format_number(metrics["forks"]), "派生仓库", "#2f9e44"), + ("Open Issues", format_number(metrics["open_issues"]), "待处理问题", "#e8590c"), + ("Open PRs", format_number(metrics["open_prs"]), "待合并请求", "#66a80f"), + ("Commits", format_number(metrics["commits_30d"]), "最近 30 天", "#099268"), + ("Merged PRs", format_number(metrics["merged_prs_30d"]), "最近 30 天", "#f08c00"), + ("Closed Issues", format_number(metrics["closed_issues_30d"]), "最近 30 天", "#d9480f"), + ("Contributors", format_number(metrics["contributors"]), "贡献者", "#37b24d"), + ] + + card_svgs: List[str] = [] + for index, (title, value, detail, color) in enumerate(cards): + x = 28 + (index % 4) * 222 + y = 126 + (index // 4) * 98 + card_svgs.append(metric_card(x, y, title, value, detail, color)) + + latest_commit_time = format_relative_time(metrics["latest_commit_at"], datetime.now(timezone.utc)) + latest_message = escape(metrics["latest_message"][:78]) + + return f""" + {escape(metrics["repo"])} 仓库动态 + 自动生成的仓库动态统计图,包含 PR、Issue、Commit、Star 和 Fork 数据。 + + + + + + + + + + + + + + + {escape(metrics["repo"])} + {escape(metrics["description"][:105])} + 默认分支 + {escape(metrics["default_branch"])} + 更新于 {escape(metrics["generated_at"])} +{"".join(card_svgs)} + + + 最后提交 + {escape(metrics["latest_sha"])} + {latest_commit_time} + {latest_message} + + +""" + + +def main() -> None: + parser = argparse.ArgumentParser(description="生成轻量仓库动态 SVG。") + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"), help="owner/repo") + parser.add_argument("--output", default="depends-data/repository-metrics.svg", help="SVG 输出路径") + args = parser.parse_args() + + if not args.repo: + raise RuntimeError("缺少仓库名称,请传入 --repo 或设置 GITHUB_REPOSITORY。") + + token = os.environ.get("METRICS_TOKEN") or os.environ.get("GITHUB_TOKEN") + metrics = collect_metrics(args.repo, token) + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(render_svg(metrics), encoding="utf-8", newline="\n") + print(f"已生成 {output}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index c882fc2cf..63b09eb27 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -11,15 +11,28 @@ permissions: jobs: github-metrics: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 5 steps: - - uses: lowlighter/metrics@latest + - uses: actions/checkout@v4 with: - token: ${{ secrets.METRICS_TOKEN }} - filename: assets/repository-metrics.svg - template: repository - user: Mai-with-u - repo: MaiBot - base: header, activity, repositories, metadata - plugins_errors_fatal: yes - plugin_followup: yes + ref: ${{ github.ref }} + + - name: Generate repository metrics + env: + GITHUB_TOKEN: ${{ secrets.METRICS_TOKEN || github.token }} + run: | + python .github/scripts/generate_repository_metrics.py \ + --repo Mai-with-u/MaiBot \ + --output depends-data/repository-metrics.svg + + - name: Commit repository metrics + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add depends-data/repository-metrics.svg + if git diff --cached --quiet; then + echo "No metrics changes." + else + git commit -m "docs: 更新仓库状态图" + git push origin HEAD:${{ github.ref_name }} + fi diff --git a/README.md b/README.md index a2121e798..5c60e1c3f 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务 ## 📊 仓库状态 Repository Status -![Alt](assets/repository-metrics.svg "麦麦仓库状态") +![Alt](depends-data/repository-metrics.svg "麦麦仓库状态") ### Star 趋势 Star History diff --git a/depends-data/repository-metrics.svg b/depends-data/repository-metrics.svg new file mode 100644 index 000000000..7164812e9 --- /dev/null +++ b/depends-data/repository-metrics.svg @@ -0,0 +1,87 @@ + + Mai-with-u/MaiBot 仓库动态 + 自动生成的仓库动态统计图,包含 PR、Issue、Commit、Star 和 Fork 数据。 + + + + + + + + + + + + + + + Mai-with-u/MaiBot + MaiSaka, an LLM-based intelligent agent, is a digital lifeform devoted to understanding you and interacti + 默认分支 + main + 更新于 2026-06-12 20:21 UTC+8 + + + + + Stars + 5,130 + 累计收藏 + + + + + Forks + 553 + 派生仓库 + + + + + Open Issues + 41 + 待处理问题 + + + + + Open PRs + 2 + 待合并请求 + + + + + Commits + 395 + 最近 30 天 + + + + + Merged PRs + 35 + 最近 30 天 + + + + + Closed Issues + 65 + 最近 30 天 + + + + + Contributors + 112 + 贡献者 + + + + 最后提交 + 014e5fb + 2 小时前 + Update metrics.yml + + From 8b676ed56b941351bfa7ce7704ede8a872d7beca Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Fri, 12 Jun 2026 20:36:33 +0800 Subject: [PATCH 04/17] Update metrics.yml --- .github/workflows/metrics.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml index 63b09eb27..75cdc6f13 100644 --- a/.github/workflows/metrics.yml +++ b/.github/workflows/metrics.yml @@ -19,7 +19,7 @@ jobs: - name: Generate repository metrics env: - GITHUB_TOKEN: ${{ secrets.METRICS_TOKEN || github.token }} + GITHUB_TOKEN: ${{ github.token }} run: | python .github/scripts/generate_repository_metrics.py \ --repo Mai-with-u/MaiBot \ From d240807e769f485b381c8fa695b8c8e31be947bc Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sat, 13 Jun 2026 00:09:17 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(webui):=20?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AF=B7=E6=B1=82=E5=B1=82=E5=B9=B6=E5=BC=95?= =?UTF-8?q?=E5=85=A5=20TanStack=20Query=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/lib/http 请求客户端深模块(createApiClient + ApiError + 路由未命中诊断 + 25 个单测),backendApi / statsApi / authApi 三个实例分别承担主后端、统计服务与认证流程 - 全量迁移 17 个 lib API 模块、plugin-api、setup 向导与 8 处页面内联请求到请求客户端,删除 fetch-with-auth.ts / api-helpers.ts / lib/api.ts,移除 axios 依赖(一言改原生 fetch) - 删除 memory-api 对 localhost:8001 的静默多路由兜底(保留诊断);修复 knowledge-api 裸 fetch 缺失认证;planner/adapter-config-api 的 HTTP 错误不再被当业务数据静默返回 - logout / checkAuthStatus 移至 lib/auth.ts;登录验证走 authApi,401 透传后端信息且不再触发整页跳转 - 引入 @tanstack/react-query:lib/query.ts 编码全局约定(查询失败局部呈现、变更失败全局 toast、不自动重试、不聚焦刷新),person 页面与 person-api 切换为 throw 契约样板 --- dashboard/bun.lock | 12 +- dashboard/package-lock.json | 156 ++- dashboard/package.json | 8 +- dashboard/src/components/layout/Header.tsx | 2 +- dashboard/src/hooks/use-auth.ts | 18 +- dashboard/src/lib/adapter-config-api.ts | 55 +- dashboard/src/lib/api-base.ts | 14 - dashboard/src/lib/api-helpers.ts | 55 - dashboard/src/lib/api.ts | 19 - dashboard/src/lib/auth.ts | 32 + dashboard/src/lib/behavior-api.ts | 71 +- dashboard/src/lib/config-api.ts | 177 ++-- dashboard/src/lib/emoji-api.ts | 161 +-- dashboard/src/lib/expression-api.ts | 988 ++++-------------- dashboard/src/lib/fetch-with-auth.ts | 97 -- .../src/lib/http/__tests__/client.test.ts | 298 ++++++ dashboard/src/lib/http/client.ts | 197 ++++ dashboard/src/lib/http/compat.ts | 24 + dashboard/src/lib/http/envelope.ts | 21 + dashboard/src/lib/http/errors.ts | 24 + dashboard/src/lib/http/index.ts | 13 + dashboard/src/lib/http/instances.ts | 36 + dashboard/src/lib/jargon-api.ts | 152 +-- dashboard/src/lib/knowledge-api.ts | 44 +- dashboard/src/lib/log-websocket.ts | 2 +- dashboard/src/lib/memory-api.ts | 180 +--- dashboard/src/lib/pack-api.ts | 228 ++-- dashboard/src/lib/person-api.ts | 321 ++---- dashboard/src/lib/planner-api.ts | 74 +- dashboard/src/lib/plugin-api/config.ts | 155 ++- dashboard/src/lib/plugin-api/install-flow.ts | 60 +- dashboard/src/lib/plugin-api/installed.ts | 47 +- dashboard/src/lib/plugin-api/marketplace.ts | 188 ++-- dashboard/src/lib/plugin-stats.ts | 174 ++- dashboard/src/lib/prompt-api.ts | 56 +- dashboard/src/lib/prompt-generator-api.ts | 26 +- dashboard/src/lib/query.ts | 55 + dashboard/src/lib/reasoning-process-api.ts | 34 +- dashboard/src/lib/survey-api.ts | 144 ++- dashboard/src/lib/system-api.ts | 262 ++--- dashboard/src/lib/unified-ws.ts | 16 +- dashboard/src/main.tsx | 4 + dashboard/src/routes/auth.tsx | 31 +- dashboard/src/routes/chat/index.tsx | 72 +- dashboard/src/routes/index.tsx | 35 +- dashboard/src/routes/person.tsx | 187 ++-- dashboard/src/routes/plugin-detail.tsx | 47 +- dashboard/src/routes/plugin-mirrors.tsx | 68 +- .../routes/resource/emoji/EmojiDialogs.tsx | 15 +- .../src/routes/settings/LocalCacheTab.tsx | 13 +- dashboard/src/routes/settings/OtherTab.tsx | 19 +- dashboard/src/routes/setup/api.ts | 82 +- 52 files changed, 2277 insertions(+), 2992 deletions(-) delete mode 100644 dashboard/src/lib/api-helpers.ts delete mode 100644 dashboard/src/lib/api.ts create mode 100644 dashboard/src/lib/auth.ts delete mode 100644 dashboard/src/lib/fetch-with-auth.ts create mode 100644 dashboard/src/lib/http/__tests__/client.test.ts create mode 100644 dashboard/src/lib/http/client.ts create mode 100644 dashboard/src/lib/http/compat.ts create mode 100644 dashboard/src/lib/http/envelope.ts create mode 100644 dashboard/src/lib/http/errors.ts create mode 100644 dashboard/src/lib/http/index.ts create mode 100644 dashboard/src/lib/http/instances.ts create mode 100644 dashboard/src/lib/query.ts diff --git a/dashboard/bun.lock b/dashboard/bun.lock index 8f74c0a07..f59ab7a1c 100644 --- a/dashboard/bun.lock +++ b/dashboard/bun.lock @@ -37,6 +37,7 @@ "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@react-spring/web": "10.0.3", + "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.140.0", "@tanstack/react-virtual": "^3.13.13", "@tanstack/router-devtools": "^1.140.0", @@ -47,7 +48,6 @@ "@uppy/react": "^5.1.1", "@uppy/xhr-upload": "^5.1.1", "@use-gesture/react": "^10.3.1", - "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -583,6 +583,10 @@ "@tanstack/history": ["@tanstack/history@1.154.14", "https://registry.npmmirror.com/@tanstack/history/-/history-1.154.14.tgz", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], + "@tanstack/react-router": ["@tanstack/react-router@1.159.4", "https://registry.npmmirror.com/@tanstack/react-router/-/react-router-1.159.4.tgz", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.159.4", "isbot": "^5.1.22", "srvx": "^0.11.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-z3DhNkRh/joky5b+X4jEYOn9q4Jieie6mVFP62wgwM9pVlNRYh6aIroiU95ZyOwDXDijItVEZtvHuipbLHy4jw=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.159.4", "https://registry.npmmirror.com/@tanstack/react-router-devtools/-/react-router-devtools-1.159.4.tgz", { "dependencies": { "@tanstack/router-devtools-core": "1.159.4" }, "peerDependencies": { "@tanstack/react-router": "^1.159.4", "@tanstack/router-core": "^1.159.4", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-7HXV4b5WZMdWoP6HD+mURh4mq1ssRg0dfcVYx+AzhaLboFzy4LyzdUtMpmNgRFgz3mBXLBoo+gMbKSjKlmsZmw=="], @@ -867,8 +871,6 @@ "axe-core": ["axe-core@4.11.1", "https://registry.npmmirror.com/axe-core/-/axe-core-4.11.1.tgz", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], - "axios": ["axios@1.13.5", "https://registry.npmmirror.com/axios/-/axios-1.13.5.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="], - "axobject-query": ["axobject-query@4.1.0", "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], @@ -1225,8 +1227,6 @@ "flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - "follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], - "for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -1797,8 +1797,6 @@ "property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - "pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 36a77bd84..198486c3e 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { "name": "maibot-dashboard", - "version": "1.0.5", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maibot-dashboard", - "version": "1.0.5", + "version": "1.4.0", "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-javascript": "^6.2.4", @@ -40,6 +40,7 @@ "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@react-spring/web": "10.0.3", + "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.140.0", "@tanstack/react-virtual": "^3.13.13", "@tanstack/router-devtools": "^1.140.0", @@ -50,7 +51,6 @@ "@uppy/react": "^5.1.1", "@uppy/xhr-upload": "^5.1.1", "@use-gesture/react": "^10.3.1", - "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -4785,6 +4785,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -4847,6 +4911,32 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-router": { "version": "1.168.10", "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.10.tgz", @@ -6836,6 +6926,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -6885,17 +6976,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7305,6 +7385,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7642,6 +7723,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -8363,6 +8445,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -8594,6 +8677,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -9014,6 +9098,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9023,6 +9108,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9039,6 +9125,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -9051,6 +9138,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9713,26 +9801,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9783,6 +9851,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -9876,6 +9945,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9946,6 +10016,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9979,6 +10050,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -10164,6 +10236,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10270,6 +10343,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10282,6 +10356,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -10297,6 +10372,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -12041,6 +12117,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -12953,6 +13030,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -12971,6 +13049,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -14225,15 +14304,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", diff --git a/dashboard/package.json b/dashboard/package.json index f67243a6f..c8b62f2e0 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -122,6 +122,7 @@ "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@react-spring/web": "10.0.3", + "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.140.0", "@tanstack/react-virtual": "^3.13.13", "@tanstack/router-devtools": "^1.140.0", @@ -132,7 +133,6 @@ "@uppy/react": "^5.1.1", "@uppy/xhr-upload": "^5.1.1", "@use-gesture/react": "^10.3.1", - "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -160,8 +160,8 @@ "tailwind-merge": "^3.4.0" }, "devDependencies": { - "@tailwindcss/vite": "^4.2.1", "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -176,6 +176,7 @@ "electron-store": "11.0.2", "electron-vite": "^5.0.0", "eslint": "^9.39.1", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", @@ -186,7 +187,6 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.49.0", "vite": "^7.2.7", - "vitest": "^4.0.18", - "eslint-plugin-jsx-a11y": "^6.10.2" + "vitest": "^4.0.18" } } diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx index 523252b1a..9b8fda154 100644 --- a/dashboard/src/components/layout/Header.tsx +++ b/dashboard/src/components/layout/Header.tsx @@ -37,7 +37,7 @@ import { import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { toggleThemeWithTransition } from '@/components/use-theme' import { useBackground } from '@/hooks/use-background' -import { logout } from '@/lib/fetch-with-auth' +import { logout } from '@/lib/auth' import { isElectron } from '@/lib/runtime' import { cn } from '@/lib/utils' import type { WorkspaceMode } from './types' diff --git a/dashboard/src/hooks/use-auth.ts b/dashboard/src/hooks/use-auth.ts index d614f20ab..073383db7 100644 --- a/dashboard/src/hooks/use-auth.ts +++ b/dashboard/src/hooks/use-auth.ts @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react' import { useNavigate } from '@tanstack/react-router' -import { checkAuthStatus } from '@/lib/fetch-with-auth' + +import { checkAuthStatus } from '@/lib/auth' +import { authApi } from '@/lib/http' export function useAuthGuard() { const navigate = useNavigate() @@ -49,18 +51,8 @@ export async function checkAuth(): Promise { */ export async function checkFirstSetup(): Promise { try { - const response = await fetch('/api/webui/setup/status', { - method: 'GET', - credentials: 'include', - }) - - const data = await response.json() - - if (response.ok) { - return data.is_first_setup - } - - return false + const data = await authApi.get<{ is_first_setup: boolean }>('/api/webui/setup/status') + return data.is_first_setup } catch (error) { console.error('检查首次配置状态失败:', error) return false diff --git a/dashboard/src/lib/adapter-config-api.ts b/dashboard/src/lib/adapter-config-api.ts index e7c1aee7a..bebfd26be 100644 --- a/dashboard/src/lib/adapter-config-api.ts +++ b/dashboard/src/lib/adapter-config-api.ts @@ -2,7 +2,7 @@ * 适配器配置API客户端 */ -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import { backendApi, requireSuccess } from '@/lib/http' const API_BASE = '/api/webui/config' @@ -31,13 +31,15 @@ interface ConfigMessageResponse { * 获取保存的适配器配置文件路径 */ export async function getSavedConfigPath(): Promise { - const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`) - const data: ConfigPathResponse = await response.json() - + const data = await backendApi.get(`${API_BASE}/adapter-config/path`, { + errorMessage: '获取适配器配置路径失败', + }) + + // 未保存过路径属于正常情况,返回 null 而不是抛错 if (!data.success || !data.path) { return null } - + return { path: data.path, lastModified: data.lastModified, @@ -48,48 +50,31 @@ export async function getSavedConfigPath(): Promise { * 保存适配器配置文件路径偏好设置 */ export async function saveConfigPath(path: string): Promise { - const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ path }), + const data = await backendApi.post(`${API_BASE}/adapter-config/path`, { + body: { path }, + errorMessage: '保存路径失败', }) - - const data: ConfigMessageResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || '保存路径失败') - } + requireSuccess(data, '保存路径失败') } /** * 从指定路径读取适配器配置文件 */ export async function loadConfigFromPath(path: string): Promise { - const response = await fetchWithAuth( - `${API_BASE}/adapter-config?path=${encodeURIComponent(path)}` - ) - const data: ConfigContentResponse = await response.json() - - if (!data.success) { - throw new Error('读取配置文件失败') - } - - return data.content + const data = await backendApi.get(`${API_BASE}/adapter-config`, { + query: { path }, + errorMessage: '读取配置文件失败', + }) + return requireSuccess(data, '读取配置文件失败').content } /** * 保存适配器配置到指定路径 */ export async function saveConfigToPath(path: string, content: string): Promise { - const response = await fetchWithAuth(`${API_BASE}/adapter-config`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ path, content }), + const data = await backendApi.post(`${API_BASE}/adapter-config`, { + body: { path, content }, + errorMessage: '保存配置失败', }) - - const data: ConfigMessageResponse = await response.json() - - if (!data.success) { - throw new Error(data.message || '保存配置失败') - } + requireSuccess(data, '保存配置失败') } diff --git a/dashboard/src/lib/api-base.ts b/dashboard/src/lib/api-base.ts index d5b1f6512..00fae81e5 100644 --- a/dashboard/src/lib/api-base.ts +++ b/dashboard/src/lib/api-base.ts @@ -59,20 +59,6 @@ export async function getWsBaseUrl(): Promise { return `${protocol}//${host}` } -/** - * Get synchronous API base URL for axios baseURL configuration - * Note: axios instance baseURL is set at module initialization time (synchronous). - * Since window.electronAPI.getActiveBackendUrl() is async, this function returns - * empty string. The actual Electron backend URL will be injected via axios request - * interceptor (Task 7) to support dynamic backend switching at runtime. - */ -export function getAxiosBaseUrl(): string { - // Always return empty string: - // - Browser: Vite proxy / same-origin handles paths - // - Electron: axios interceptor injects dynamic baseURL - return '' -} - /** * Resolve full API path by prepending base URL if needed * - Electron: Prepends configured backend URL diff --git a/dashboard/src/lib/api-helpers.ts b/dashboard/src/lib/api-helpers.ts deleted file mode 100644 index 9ae17ab54..000000000 --- a/dashboard/src/lib/api-helpers.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * API response parsing and error handling helpers - * Provides unified error handling across API modules - */ - -import type { ApiResponse } from '@/types/api' - -/** - * Parse an HTTP response into a typed ApiResponse - * Handles JSON parsing, error extraction, and HTTP status codes - */ -export async function parseResponse(response: Response): Promise> { - if (response.ok) { - try { - const data = await response.json() - return { success: true, data } - } catch { - return { - success: false, - error: 'Failed to parse response body', - } - } - } - - try { - const errorData = await response.json() - const errorMessage = - errorData.error?.detail ?? - errorData.error?.message ?? - errorData.detail ?? - errorData.message ?? - response.statusText - - return { - success: false, - error: String(errorMessage), - } - } catch { - return { - success: false, - error: response.statusText || 'Unknown error', - } - } -} - -/** - * Extract data from successful ApiResponse or throw error - * Simplifies error handling in async functions - */ -export function throwIfError(result: ApiResponse): T { - if (result.success) { - return result.data - } - throw new Error(result.error) -} \ No newline at end of file diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts deleted file mode 100644 index 94a4d94ca..000000000 --- a/dashboard/src/lib/api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import axios from 'axios' - -import { getApiBaseUrl } from './api-base' - -const apiClient = axios.create({ - baseURL: '', // 统一为空,通过拦截器动态设置 - timeout: 10000, -}) - -// Electron 端:动态注入后端 URL;浏览器端 getApiBaseUrl() 返回空字符串,行为不变 -apiClient.interceptors.request.use(async (config) => { - const baseUrl = await getApiBaseUrl() - if (baseUrl && !config.baseURL) { - config.baseURL = baseUrl - } - return config -}) - -export default apiClient diff --git a/dashboard/src/lib/auth.ts b/dashboard/src/lib/auth.ts new file mode 100644 index 000000000..66ca8f80f --- /dev/null +++ b/dashboard/src/lib/auth.ts @@ -0,0 +1,32 @@ +/** + * 认证流程工具:登出与认证状态探测。 + * + * 走 authApi 实例(携带 Cookie 但 401 不跳转)—— + * 在这两个场景里 401 / 未认证是正常业务结果,不应触发整页跳转。 + */ +import { authApi } from '@/lib/http' + +/** + * 调用登出接口并跳转到登录页 + */ +export async function logout(): Promise { + try { + await authApi.post('/api/webui/auth/logout', { parse: 'response' }) + } catch (error) { + console.error('登出请求失败:', error) + } + // 无论成功与否都跳转到登录页 + window.location.href = '/auth' +} + +/** + * 检查当前认证状态 + */ +export async function checkAuthStatus(): Promise { + try { + const data = await authApi.get<{ authenticated?: boolean }>('/api/webui/auth/check') + return data.authenticated === true + } catch { + return false + } +} diff --git a/dashboard/src/lib/behavior-api.ts b/dashboard/src/lib/behavior-api.ts index f37b9c860..7f00cc927 100644 --- a/dashboard/src/lib/behavior-api.ts +++ b/dashboard/src/lib/behavior-api.ts @@ -1,4 +1,11 @@ -import { fetchWithAuth } from './fetch-with-auth' +/** + * 行为学习(Behavior)API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint 与响应类型。公开函数保持 throw 契约: + * HTTP / 网络层失败由请求客户端以 ApiError 抛出。 + */ +import { backendApi } from '@/lib/http' const API_BASE = '/api/webui/behavior' @@ -234,17 +241,8 @@ export interface BehaviorRetrievalDebugRequest { max_count: number } -async function readJson(response: Response): Promise { - if (!response.ok) { - const text = await response.text() - throw new Error(text || `请求失败:${response.status}`) - } - return response.json() as Promise -} - export async function listBehaviorChats(): Promise<{ success: boolean; data: BehaviorChatInfo[] }> { - const response = await fetchWithAuth(`${API_BASE}/chats`) - return readJson(response) + return backendApi.get<{ success: boolean; data: BehaviorChatInfo[] }>(`${API_BASE}/chats`) } export async function listBehaviorPaths(params: { @@ -258,12 +256,20 @@ export async function listBehaviorPaths(params: { page?: number page_size?: number }): Promise { - const query = new URLSearchParams() - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== '') query.set(key, String(value)) + // 字符串参数为空字符串时跳过(与原 URLSearchParams 构建语义一致) + return backendApi.get(`${API_BASE}/paths`, { + query: { + session_id: params.session_id || undefined, + search: params.search || undefined, + enabled: params.enabled || undefined, + actor_type: params.actor_type || undefined, + learning_type: params.learning_type || undefined, + sort_by: params.sort_by || undefined, + sort_order: params.sort_order || undefined, + page: params.page, + page_size: params.page_size, + }, }) - const response = await fetchWithAuth(`${API_BASE}/paths?${query.toString()}`) - return readJson(response) } export async function listBehaviorClusters(params: { @@ -272,37 +278,34 @@ export async function listBehaviorClusters(params: { page?: number page_size?: number }): Promise { - const query = new URLSearchParams() - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== '') query.set(key, String(value)) + // 字符串参数为空字符串时跳过(与原 URLSearchParams 构建语义一致) + return backendApi.get(`${API_BASE}/clusters`, { + query: { + session_id: params.session_id || undefined, + search: params.search || undefined, + page: params.page, + page_size: params.page_size, + }, }) - const response = await fetchWithAuth(`${API_BASE}/clusters?${query.toString()}`) - return readJson(response) } export async function getBehaviorGraphData(params: { session_id?: string } = {}): Promise<{ success: boolean; data: BehaviorGraphData }> { - const query = new URLSearchParams() - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== '') query.set(key, String(value)) + return backendApi.get<{ success: boolean; data: BehaviorGraphData }>(`${API_BASE}/graph-data`, { + query: { session_id: params.session_id || undefined }, }) - const suffix = query.toString() ? `?${query.toString()}` : '' - const response = await fetchWithAuth(`${API_BASE}/graph-data${suffix}`) - return readJson(response) } export async function getBehaviorPathDetail(pathId: number): Promise<{ success: boolean; data: BehaviorPathDetail }> { - const response = await fetchWithAuth(`${API_BASE}/paths/${pathId}`) - return readJson(response) + return backendApi.get<{ success: boolean; data: BehaviorPathDetail }>(`${API_BASE}/paths/${pathId}`) } export async function debugBehaviorRetrieval( payload: BehaviorRetrievalDebugRequest ): Promise<{ success: boolean; data: BehaviorRetrievalDebugPayload }> { - const response = await fetchWithAuth(`${API_BASE}/retrieval-debug`, { - method: 'POST', - body: JSON.stringify(payload), - }) - return readJson(response) + return backendApi.post<{ success: boolean; data: BehaviorRetrievalDebugPayload }>( + `${API_BASE}/retrieval-debug`, + { body: payload } + ) } diff --git a/dashboard/src/lib/config-api.ts b/dashboard/src/lib/config-api.ts index d3dddf002..d67e24c6a 100644 --- a/dashboard/src/lib/config-api.ts +++ b/dashboard/src/lib/config-api.ts @@ -1,9 +1,12 @@ /** * 配置API客户端 + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案、响应解包规则与配置数据缓存。 + * 公开函数暂保持 ApiResponse 契约(经 toApiResponse 包装),待页面层统一切换 throw 契约后移除。 */ -import { parseResponse } from '@/lib/api-helpers' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { ApiError, backendApi, toApiResponse } from '@/lib/http' import type { ApiResponse } from '@/types/api' import type { ConfigSchema } from '@/types/config-schema' @@ -37,9 +40,15 @@ function getCachedSchema(key: string, url: string): Promise parseResponse(response)) - .catch((error) => { + const request = backendApi + .get(url, { cache: 'no-store', errorMessage: '获取配置架构失败' }) + .then((data): ApiResponse => ({ success: true, data })) + .catch((error): ApiResponse => { + // HTTP 层失败收敛为 success: false 并保留在缓存中,避免对失败的配置接口反复发起请求 + if (error instanceof ApiError && error.status !== undefined) { + return { success: false, error: error.message } + } + // 请求未到达服务器等异常沿用原行为:剔除缓存并向调用方抛出 schemaRequestCache.delete(key) throw error }) @@ -54,12 +63,18 @@ function getCachedConfigData(key: string, url: string): Promise parseResponse(response)) - .then((result): ApiResponse> => ( - result.success ? { success: true, data: unwrapConfigResponse(result.data) } : result - )) - .catch((error) => { + const request = backendApi + .get(url, { cache: 'no-store', errorMessage: '获取配置失败' }) + .then((data): ApiResponse> => ({ + success: true, + data: unwrapConfigResponse(data), + })) + .catch((error): ApiResponse> => { + // HTTP 层失败收敛为 success: false 并保留在缓存中,避免对失败的配置接口反复发起请求 + if (error instanceof ApiError && error.status !== undefined) { + return { success: false, error: error.message } + } + // 请求未到达服务器等异常沿用原行为:剔除缓存并向调用方抛出 configDataCache.delete(key) throw error }) @@ -107,9 +122,13 @@ export async function getConfigSectionSchema(sectionName: string): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/bot`, { cache: 'no-store' }) - const result = await parseResponse(response) - return result.success ? { success: true, data: unwrapConfigResponse(result.data) } : result + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/bot`, { + cache: 'no-store', + errorMessage: '获取配置失败', + }) + return unwrapConfigResponse(data) + }) } /** Cached config data for lightweight status summaries. */ @@ -121,9 +140,13 @@ export async function getBotConfigCached(): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/model`, { cache: 'no-store' }) - const result = await parseResponse(response) - return result.success ? { success: true, data: unwrapConfigResponse(result.data) } : result + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/model`, { + cache: 'no-store', + errorMessage: '获取配置失败', + }) + return unwrapConfigResponse(data) + }) } /** Cached model config data for lightweight status summaries. */ @@ -137,11 +160,12 @@ export async function getModelConfigCached(): Promise ): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/bot`, { - method: 'POST', - body: JSON.stringify(config), - }) - const result = await parseResponse>(response) + const result = await toApiResponse(() => + backendApi.post>(`${API_BASE}/bot`, { + body: config, + errorMessage: '更新配置失败', + }) + ) if (result.success) { invalidateConfigDataCache('bot') notifyBotConfigUpdated() @@ -153,19 +177,24 @@ export async function updateBotConfig( * 获取麦麦主程序配置的原始 TOML 内容 */ export async function getBotConfigRaw(): Promise> { - const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { cache: 'no-store' }) - return parseResponse(response) + return toApiResponse(() => + backendApi.get(`${API_BASE}/bot/raw`, { + cache: 'no-store', + errorMessage: '获取原始配置失败', + }) + ) } /** * 更新麦麦主程序配置(原始 TOML 内容) */ export async function updateBotConfigRaw(rawContent: string): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { - method: 'POST', - body: JSON.stringify({ raw_content: rawContent }), - }) - const result = await parseResponse>(response) + const result = await toApiResponse(() => + backendApi.post>(`${API_BASE}/bot/raw`, { + body: { raw_content: rawContent }, + errorMessage: '更新配置失败', + }) + ) if (result.success) { invalidateConfigDataCache('bot') notifyBotConfigUpdated() @@ -179,11 +208,12 @@ export async function updateBotConfigRaw(rawContent: string): Promise ): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/model`, { - method: 'POST', - body: JSON.stringify(config), - }) - const result = await parseResponse>(response) + const result = await toApiResponse(() => + backendApi.post>(`${API_BASE}/model`, { + body: config, + errorMessage: '更新配置失败', + }) + ) if (result.success) invalidateConfigDataCache('model') return result } @@ -195,11 +225,12 @@ export async function updateBotConfigSection( sectionName: string, sectionData: unknown ): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, { - method: 'POST', - body: JSON.stringify(sectionData), - }) - const result = await parseResponse>(response) + const result = await toApiResponse(() => + backendApi.post>(`${API_BASE}/bot/section/${sectionName}`, { + body: sectionData, + errorMessage: '更新配置失败', + }) + ) if (result.success) { invalidateConfigDataCache('bot') notifyBotConfigUpdated() @@ -214,11 +245,12 @@ export async function updateModelConfigSection( sectionName: string, sectionData: unknown ): Promise>> { - const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, { - method: 'POST', - body: JSON.stringify(sectionData), - }) - const result = await parseResponse>(response) + const result = await toApiResponse(() => + backendApi.post>(`${API_BASE}/model/section/${sectionName}`, { + body: sectionData, + errorMessage: '更新配置失败', + }) + ) if (result.success) invalidateConfigDataCache('model') return result } @@ -257,14 +289,15 @@ export interface ModelClientType { * 获取当前主程序与插件已注册的模型客户端类型 */ export async function fetchModelClientTypes(): Promise> { - const response = await fetchWithAuth('/api/webui/models/client-types') - const parsed = await parseResponse<{ client_types?: ModelClientType[] } | ModelClientType[]>(response) - if (!parsed.success) { - return parsed - } - const body = parsed.data - const clientTypes = Array.isArray(body) ? body : Array.isArray(body?.client_types) ? body.client_types : [] - return { success: true, data: clientTypes } + return toApiResponse(async () => { + const body = await backendApi.get<{ client_types?: ModelClientType[] } | ModelClientType[]>( + '/api/webui/models/client-types', + { + errorMessage: '获取模型客户端类型失败', + } + ) + return Array.isArray(body) ? body : Array.isArray(body?.client_types) ? body.client_types : [] + }) } /** @@ -278,20 +311,21 @@ export async function fetchProviderModels( parser: 'openai' | 'gemini' = 'openai', endpoint: string = '/models' ): Promise> { - const params = new URLSearchParams({ - provider_name: providerName, - parser, - endpoint, + return toApiResponse(async () => { + // 后端返回 { success, models, provider, count },需要展开取出 models 数组 + const body = await backendApi.get<{ models?: ModelListItem[] } | ModelListItem[]>( + '/api/webui/models/list', + { + query: { + provider_name: providerName, + parser, + endpoint, + }, + errorMessage: '获取模型列表失败', + } + ) + return Array.isArray(body) ? body : Array.isArray(body?.models) ? body.models : [] }) - const response = await fetchWithAuth(`/api/webui/models/list?${params}`) - // 后端返回 { success, models, provider, count },需要展开取出 models 数组 - const parsed = await parseResponse<{ models?: ModelListItem[] } | ModelListItem[]>(response) - if (!parsed.success) { - return parsed - } - const body = parsed.data - const models = Array.isArray(body) ? body : Array.isArray(body?.models) ? body.models : [] - return { success: true, data: models } } /** @@ -312,11 +346,10 @@ export interface TestConnectionResult { export async function testProviderConnection( providerName: string ): Promise> { - const params = new URLSearchParams({ - provider_name: providerName, - }) - const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, { - method: 'POST', - }) - return parseResponse(response) + return toApiResponse(() => + backendApi.post('/api/webui/models/test-connection-by-name', { + query: { provider_name: providerName }, + errorMessage: '测试提供商连接失败', + }) + ) } diff --git a/dashboard/src/lib/emoji-api.ts b/dashboard/src/lib/emoji-api.ts index 2ea9f4e07..7350a2322 100644 --- a/dashboard/src/lib/emoji-api.ts +++ b/dashboard/src/lib/emoji-api.ts @@ -1,16 +1,18 @@ /** * 表情包管理 API 客户端 + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint 与业务错误文案。请求失败时抛出 ApiError(throw 契约)。 */ - -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import type { - EmojiListResponse, + EmojiDeleteResponse, EmojiDetailResponse, + EmojiListResponse, + EmojiStatsResponse, + EmojiStatus, EmojiUpdateRequest, EmojiUpdateResponse, - EmojiDeleteResponse, - EmojiStatus, - EmojiStatsResponse, } from '@/types/emoji' const API_BASE = '/api/webui/emoji' @@ -29,39 +31,29 @@ export async function getEmojiList(params: { sort_by?: string sort_order?: 'asc' | 'desc' }): Promise { - const query = new URLSearchParams() - if (params.page) query.append('page', params.page.toString()) - if (params.page_size) query.append('page_size', params.page_size.toString()) - if (params.search) query.append('search', params.search) - if (params.is_registered !== undefined) query.append('is_registered', params.is_registered.toString()) - if (params.is_banned !== undefined) query.append('is_banned', params.is_banned.toString()) - if (params.status) query.append('status', params.status) - if (params.format) query.append('format', params.format) - if (params.sort_by) query.append('sort_by', params.sort_by) - if (params.sort_order) query.append('sort_order', params.sort_order) - - const response = await fetchWithAuth(`${API_BASE}/list?${query}`, { + return backendApi.get(`${API_BASE}/list`, { + query: { + page: params.page || undefined, + page_size: params.page_size || undefined, + search: params.search || undefined, + is_registered: params.is_registered, + is_banned: params.is_banned, + status: params.status || undefined, + format: params.format || undefined, + sort_by: params.sort_by || undefined, + sort_order: params.sort_order || undefined, + }, + errorMessage: '获取表情包列表失败', }) - - if (!response.ok) { - throw new Error(`获取表情包列表失败: ${response.statusText}`) - } - - return response.json() } /** * 获取表情包详情 */ export async function getEmojiDetail(id: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/${id}`, { + return backendApi.get(`${API_BASE}/${id}`, { + errorMessage: '获取表情包详情失败', }) - - if (!response.ok) { - throw new Error(`获取表情包详情失败: ${response.statusText}`) - } - - return response.json() } /** @@ -71,76 +63,46 @@ export async function updateEmoji( id: number, data: EmojiUpdateRequest ): Promise { - const response = await fetchWithAuth(`${API_BASE}/${id}`, { - method: 'PATCH', - body: JSON.stringify(data), + return backendApi.patch(`${API_BASE}/${id}`, { + body: data, + errorMessage: '更新表情包失败', }) - - if (!response.ok) { - throw new Error(`更新表情包失败: ${response.statusText}`) - } - - return response.json() } /** * 删除表情包 */ export async function deleteEmoji(id: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/${id}`, { - method: 'DELETE', + return backendApi.delete(`${API_BASE}/${id}`, { + errorMessage: '删除表情包失败', }) - - if (!response.ok) { - throw new Error(`删除表情包失败: ${response.statusText}`) - } - - return response.json() } /** * 获取表情包统计数据 */ export async function getEmojiStats(): Promise { - const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { + return backendApi.get(`${API_BASE}/stats/summary`, { + errorMessage: '获取统计数据失败', }) - - if (!response.ok) { - throw new Error(`获取统计数据失败: ${response.statusText}`) - } - - return response.json() } /** * 注册表情包 */ export async function registerEmoji(id: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/${id}/register`, { - method: 'POST', + return backendApi.post(`${API_BASE}/${id}/register`, { + errorMessage: '注册表情包失败', }) - - if (!response.ok) { - throw new Error(`注册表情包失败: ${response.statusText}`) - } - - return response.json() } /** * 封禁表情包 */ export async function banEmoji(id: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/${id}/ban`, { - method: 'POST', - + return backendApi.post(`${API_BASE}/${id}/ban`, { + errorMessage: '封禁表情包失败', }) - - if (!response.ok) { - throw new Error(`封禁表情包失败: ${response.statusText}`) - } - - return response.json() } /** @@ -173,18 +135,10 @@ export async function batchDeleteEmojis(emojiIds: number[]): Promise<{ failed_count: number failed_ids: number[] }> { - const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { - method: 'POST', - - body: JSON.stringify({ emoji_ids: emojiIds }), + return backendApi.post(`${API_BASE}/batch/delete`, { + body: { emoji_ids: emojiIds }, + errorMessage: '批量删除失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '批量删除失败') - } - - return response.json() } /** @@ -231,28 +185,18 @@ export interface ThumbnailPreheatResponse { * 获取缩略图缓存统计信息 */ export async function getThumbnailCacheStats(): Promise { - const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/stats`, {}) - - if (!response.ok) { - throw new Error(`获取缩略图缓存统计失败: ${response.statusText}`) - } - - return response.json() + return backendApi.get(`${API_BASE}/thumbnail-cache/stats`, { + errorMessage: '获取缩略图缓存统计失败', + }) } /** * 清理孤立的缩略图缓存 */ export async function cleanupThumbnailCache(): Promise { - const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/cleanup`, { - method: 'POST', + return backendApi.post(`${API_BASE}/thumbnail-cache/cleanup`, { + errorMessage: '清理缩略图缓存失败', }) - - if (!response.ok) { - throw new Error(`清理缩略图缓存失败: ${response.statusText}`) - } - - return response.json() } /** @@ -260,28 +204,17 @@ export async function cleanupThumbnailCache(): Promise * @param limit 最多预热数量 (1-1000) */ export async function preheatThumbnailCache(limit: number = 100): Promise { - const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/preheat?limit=${limit}`, { - method: 'POST', + return backendApi.post(`${API_BASE}/thumbnail-cache/preheat`, { + query: { limit }, + errorMessage: '预热缩略图缓存失败', }) - - if (!response.ok) { - throw new Error(`预热缩略图缓存失败: ${response.statusText}`) - } - - return response.json() } /** * 清空所有缩略图缓存 */ export async function clearAllThumbnailCache(): Promise { - const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/clear`, { - method: 'DELETE', + return backendApi.delete(`${API_BASE}/thumbnail-cache/clear`, { + errorMessage: '清空缩略图缓存失败', }) - - if (!response.ok) { - throw new Error(`清空缩略图缓存失败: ${response.statusText}`) - } - - return response.json() } diff --git a/dashboard/src/lib/expression-api.ts b/dashboard/src/lib/expression-api.ts index 45ce70104..754a4e4c9 100644 --- a/dashboard/src/lib/expression-api.ts +++ b/dashboard/src/lib/expression-api.ts @@ -1,8 +1,12 @@ /** * 表达方式管理 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案与响应体 success 标记的解包规则。 + * 公开函数暂保持 ApiResponse 契约(经 toApiResponse 包装),待页面层统一切换 throw 契约后移除。 */ -import { fetchWithAuth } from '@/lib/fetch-with-auth' -import { formatApiError } from '@/lib/api-error' +import { ApiError, backendApi, requireSuccess, toApiResponse } from '@/lib/http' +import type { ApiResponse } from '@/types/api' import type { BatchReviewItem, BatchReviewResponse, @@ -29,7 +33,6 @@ import type { ReviewListResponse, ReviewStats, } from '@/types/expression' -import type { ApiResponse } from '@/types/api' const API_BASE = '/api/webui/expression' @@ -39,44 +42,13 @@ const API_BASE = '/api/webui/expression' export async function getChatList( params: { include_legacy?: boolean } = {} ): Promise> { - const queryParams = new URLSearchParams() - if (params.include_legacy) queryParams.append('include_legacy', 'true') - const response = await fetchWithAuth(`${API_BASE}/chats?${queryParams}`, {}) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取聊天列表失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取聊天列表失败', - } - } - } - - try { - const data: ChatListResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } else { - return { - success: false, - error: '获取聊天列表失败', - } - } - } catch { - return { - success: false, - error: '无法解析聊天列表响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/chats`, { + query: { include_legacy: params.include_legacy ? true : undefined }, + errorMessage: '获取聊天列表失败', + }) + return requireSuccess(data, '获取聊天列表失败').data + }) } /** @@ -85,43 +57,13 @@ export async function getChatList( export async function getExpressionChatTargets( params: { include_legacy?: boolean } = {} ): Promise> { - const queryParams = new URLSearchParams() - if (params.include_legacy) queryParams.append('include_legacy', 'true') - const response = await fetchWithAuth(`${API_BASE}/chat-targets?${queryParams}`, {}) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取导入目标聊天流失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取导入目标聊天流失败', - } - } - } - - try { - const data: ChatListResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } - return { - success: false, - error: '获取导入目标聊天流失败', - } - } catch { - return { - success: false, - error: '无法解析导入目标聊天流响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/chat-targets`, { + query: { include_legacy: params.include_legacy ? true : undefined }, + errorMessage: '获取导入目标聊天流失败', + }) + return requireSuccess(data, '获取导入目标聊天流失败').data + }) } /** @@ -130,43 +72,13 @@ export async function getExpressionChatTargets( export async function getExpressionGroups( params: { include_legacy?: boolean } = {} ): Promise> { - const queryParams = new URLSearchParams() - if (params.include_legacy) queryParams.append('include_legacy', 'true') - const response = await fetchWithAuth(`${API_BASE}/groups?${queryParams}`, {}) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取表达互通组失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取表达互通组失败', - } - } - } - - try { - const data: ExpressionGroupListResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } - return { - success: false, - error: '获取表达互通组失败', - } - } catch { - return { - success: false, - error: '无法解析表达互通组响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/groups`, { + query: { include_legacy: params.include_legacy ? true : undefined }, + errorMessage: '获取表达互通组失败', + }) + return requireSuccess(data, '获取表达互通组失败').data + }) } /** @@ -182,53 +94,22 @@ export async function getExpressionList(params: { review_filter?: 'all' | 'user_checked' | 'unchecked' sort_by?: 'time' }): Promise> { - const queryParams = new URLSearchParams() - - if (params.page) queryParams.append('page', params.page.toString()) - if (params.page_size) queryParams.append('page_size', params.page_size.toString()) - if (params.search) queryParams.append('search', params.search) - if (params.chat_id) queryParams.append('chat_id', params.chat_id) - if (params.include_legacy) queryParams.append('include_legacy', 'true') - if (params.review_filter) queryParams.append('review_filter', params.review_filter) - if (params.sort_by) queryParams.append('sort_by', params.sort_by) - params.chat_ids?.forEach((chatId) => queryParams.append('chat_ids', chatId)) - - const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {}) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取表达方式列表失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取表达方式列表失败', - } - } - } - - try { - const data: ExpressionListResponse = await response.json() - if (data.success) { - return { - success: true, - data: data, - } - } else { - return { - success: false, - error: '获取表达方式列表失败', - } - } - } catch { - return { - success: false, - error: '无法解析表达方式列表响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/list`, { + query: { + page: params.page || undefined, + page_size: params.page_size || undefined, + search: params.search || undefined, + chat_id: params.chat_id || undefined, + include_legacy: params.include_legacy ? true : undefined, + review_filter: params.review_filter, + sort_by: params.sort_by, + chat_ids: params.chat_ids, + }, + errorMessage: '获取表达方式列表失败', + }) + return requireSuccess(data, '获取表达方式列表失败') + }) } /** @@ -238,38 +119,12 @@ export async function exportExpressions(params: { chat_id: string ids?: number[] }): Promise> { - const response = await fetchWithAuth(`${API_BASE}/export`, { - method: 'POST', - body: JSON.stringify(params), - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '导出表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '导出表达方式失败', - } - } - } - - try { - const data: ExpressionExportResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析表达方式导出响应', - } - } + return toApiResponse(() => + backendApi.post(`${API_BASE}/export`, { + body: params, + errorMessage: '导出表达方式失败', + }) + ) } /** @@ -279,38 +134,12 @@ export async function importExpressions(params: { chat_id: string expressions: ExpressionExportItem[] }): Promise> { - const response = await fetchWithAuth(`${API_BASE}/import`, { - method: 'POST', - body: JSON.stringify(params), - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '导入表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '导入表达方式失败', - } - } - } - - try { - const data: ExpressionImportResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析表达方式导入响应', - } - } + return toApiResponse(() => + backendApi.post(`${API_BASE}/import`, { + body: params, + errorMessage: '导入表达方式失败', + }) + ) } /** @@ -319,38 +148,12 @@ export async function importExpressions(params: { export async function clearExpressions(params: { chat_id: string }): Promise> { - const response = await fetchWithAuth(`${API_BASE}/clear`, { - method: 'POST', - body: JSON.stringify(params), - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '清除表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '清除表达方式失败', - } - } - } - - try { - const data: ExpressionClearResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析表达方式清除响应', - } - } + return toApiResponse(() => + backendApi.post(`${API_BASE}/clear`, { + body: params, + errorMessage: '清除表达方式失败', + }) + ) } /** @@ -359,38 +162,12 @@ export async function clearExpressions(params: { export async function previewLegacyExpressionImport(params: { db_path: string }): Promise> { - const response = await fetchWithAuth(`${API_BASE}/legacy-import/preview`, { - method: 'POST', - body: JSON.stringify(params), - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '预览旧版导入失败'), - } - } catch { - return { - success: false, - error: response.statusText || '预览旧版导入失败', - } - } - } - - try { - const data: LegacyExpressionImportPreviewResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析旧版导入预览响应', - } - } + return toApiResponse(() => + backendApi.post(`${API_BASE}/legacy-import/preview`, { + body: params, + errorMessage: '预览旧版导入失败', + }) + ) } /** @@ -401,38 +178,15 @@ export async function previewLegacyExpressionImportFile( ): Promise> { const formData = new FormData() formData.append('file', file) - const response = await fetchWithAuth(`${API_BASE}/legacy-import/preview-file`, { - method: 'POST', - body: formData, - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '预览旧版导入失败'), - } - } catch { - return { - success: false, - error: response.statusText || '预览旧版导入失败', - } - } - } - - try { - const data: LegacyExpressionImportPreviewResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析旧版导入预览响应', - } - } + return toApiResponse(() => + backendApi.post( + `${API_BASE}/legacy-import/preview-file`, + { + body: formData, + errorMessage: '预览旧版导入失败', + } + ) + ) } /** @@ -446,126 +200,37 @@ export async function importLegacyExpressions(params: { target_chat_ids?: string[] }> }): Promise> { - const response = await fetchWithAuth(`${API_BASE}/legacy-import/import`, { - method: 'POST', - body: JSON.stringify(params), - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '旧版导入失败'), - } - } catch { - return { - success: false, - error: response.statusText || '旧版导入失败', - } - } - } - - try { - const data: LegacyExpressionImportResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析旧版导入响应', - } - } + return toApiResponse(() => + backendApi.post(`${API_BASE}/legacy-import/import`, { + body: params, + errorMessage: '旧版导入失败', + }) + ) } /** * 获取表达方式详细信息 */ export async function getExpressionDetail(expressionId: number): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {}) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取表达方式详情失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取表达方式详情失败', - } - } - } - - try { - const data: ExpressionDetailResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } else { - return { - success: false, - error: '获取表达方式详情失败', - } - } - } catch { - return { - success: false, - error: '无法解析表达方式详情响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/${expressionId}`, { + errorMessage: '获取表达方式详情失败', + }) + return requireSuccess(data, '获取表达方式详情失败').data + }) } /** * 创建表达方式 */ export async function createExpression(data: ExpressionCreateRequest): Promise> { - const response = await fetchWithAuth(`${API_BASE}/`, { - method: 'POST', - - body: JSON.stringify(data), + return toApiResponse(async () => { + const responseData = await backendApi.post(`${API_BASE}/`, { + body: data, + errorMessage: '创建表达方式失败', + }) + return requireSuccess(responseData, '创建表达方式失败').data }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '创建表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '创建表达方式失败', - } - } - } - - try { - const responseData: ExpressionCreateResponse = await response.json() - if (responseData.success) { - return { - success: true, - data: responseData.data, - } - } else { - return { - success: false, - error: responseData.message || '创建表达方式失败', - } - } - } catch { - return { - success: false, - error: '无法解析创建表达方式响应', - } - } } /** @@ -575,180 +240,66 @@ export async function updateExpression( expressionId: number, data: ExpressionUpdateRequest ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { - method: 'PATCH', - - body: JSON.stringify(data), + return toApiResponse(async () => { + const responseData = await backendApi.patch( + `${API_BASE}/${expressionId}`, + { + body: data, + errorMessage: '更新表达方式失败', + } + ) + return requireSuccess(responseData, '更新表达方式失败').data || {} }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '更新表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '更新表达方式失败', - } - } - } - - try { - const responseData: ExpressionUpdateResponse = await response.json() - if (responseData.success) { - return { - success: true, - data: responseData.data || {}, - } - } else { - return { - success: false, - error: responseData.message || '更新表达方式失败', - } - } - } catch { - return { - success: false, - error: '无法解析更新表达方式响应', - } - } } /** - * 删除表达方式 + * 更新表达方式审核状态 */ export async function updateExpressionReviewStatus( expressionId: number, approved: boolean ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${expressionId}/review-status`, { - method: 'PATCH', - body: JSON.stringify({ approved }), + return toApiResponse(async () => { + const responseData = await backendApi.patch( + `${API_BASE}/${expressionId}/review-status`, + { + body: { approved }, + errorMessage: '更新表达方式审核状态失败', + } + ) + const checked = requireSuccess(responseData, '更新表达方式审核状态失败') + if (!checked.data) { + throw new ApiError(checked.message || '更新表达方式审核状态失败', { detail: checked }) + } + return checked.data }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '更新表达方式审核状态失败'), - } - } catch { - return { - success: false, - error: response.statusText || '更新表达方式审核状态失败', - } - } - } - - try { - const responseData: ExpressionUpdateResponse = await response.json() - if (responseData.success && responseData.data) { - return { - success: true, - data: responseData.data, - } - } - return { - success: false, - error: responseData.message || '更新表达方式审核状态失败', - } - } catch { - return { - success: false, - error: '无法解析表达方式审核状态响应', - } - } } +/** + * 删除表达方式 + */ export async function deleteExpression(expressionId: number): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, { - method: 'DELETE', + return toApiResponse(async () => { + const data = await backendApi.delete(`${API_BASE}/${expressionId}`, { + errorMessage: '删除表达方式失败', + }) + requireSuccess(data, '删除表达方式失败') + return {} }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '删除表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '删除表达方式失败', - } - } - } - - try { - const data: ExpressionDeleteResponse = await response.json() - if (data.success) { - return { - success: true, - data: {}, - } - } else { - return { - success: false, - error: data.message || '删除表达方式失败', - } - } - } catch { - return { - success: false, - error: '无法解析删除表达方式响应', - } - } } /** * 批量删除表达方式 */ export async function batchDeleteExpressions(expressionIds: number[]): Promise> { - const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { - method: 'POST', - - body: JSON.stringify({ ids: expressionIds }), + return toApiResponse(async () => { + const data = await backendApi.post(`${API_BASE}/batch/delete`, { + body: { ids: expressionIds }, + errorMessage: '批量删除表达方式失败', + }) + requireSuccess(data, '批量删除表达方式失败') + return {} }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '批量删除表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '批量删除表达方式失败', - } - } - } - - try { - const data: ExpressionDeleteResponse = await response.json() - if (data.success) { - return { - success: true, - data: {}, - } - } else { - return { - success: false, - error: data.message || '批量删除表达方式失败', - } - } - } catch { - return { - success: false, - error: '无法解析批量删除表达方式响应', - } - } } /** @@ -757,44 +308,13 @@ export async function batchDeleteExpressions(expressionIds: number[]): Promise> { - const queryParams = new URLSearchParams() - if (params.include_legacy) queryParams.append('include_legacy', 'true') - const response = await fetchWithAuth(`${API_BASE}/stats/summary?${queryParams}`, {}) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取统计数据失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取统计数据失败', - } - } - } - - try { - const data: ExpressionStatsResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } else { - return { - success: false, - error: '获取统计数据失败', - } - } - } catch { - return { - success: false, - error: '无法解析统计数据响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/stats/summary`, { + query: { include_legacy: params.include_legacy ? true : undefined }, + errorMessage: '获取统计数据失败', + }) + return requireSuccess(data, '获取统计数据失败').data + }) } // ============ 审核相关 API ============ @@ -803,35 +323,11 @@ export async function getExpressionStats( * 获取审核统计数据 */ export async function getReviewStats(): Promise> { - const response = await fetchWithAuth(`${API_BASE}/review/stats`) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取审核统计失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取审核统计失败', - } - } - } - - try { - const data = (await response.json()) as ReviewStats - return { - success: true, - data: data, - } - } catch { - return { - success: false, - error: '无法解析审核统计响应', - } - } + return toApiResponse(() => + backendApi.get(`${API_BASE}/review/stats`, { + errorMessage: '获取审核统计失败', + }) + ) } /** @@ -846,52 +342,21 @@ export async function getReviewList(params: { chat_id?: string exclude_ids?: number[] }): Promise> { - const queryParams = new URLSearchParams() - - if (params.page) queryParams.append('page', params.page.toString()) - if (params.page_size) queryParams.append('page_size', params.page_size.toString()) - if (params.filter_type) queryParams.append('filter_type', params.filter_type) - if (params.order) queryParams.append('order', params.order) - if (params.search) queryParams.append('search', params.search) - if (params.chat_id) queryParams.append('chat_id', params.chat_id) - params.exclude_ids?.forEach((id) => queryParams.append('exclude_ids', id.toString())) - - const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取审核列表失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取审核列表失败', - } - } - } - - try { - const data: ReviewListResponse = await response.json() - if (data.success) { - return { - success: true, - data: data, - } - } else { - return { - success: false, - error: '获取审核列表失败', - } - } - } catch { - return { - success: false, - error: '无法解析审核列表响应', - } - } + return toApiResponse(async () => { + const data = await backendApi.get(`${API_BASE}/review/list`, { + query: { + page: params.page || undefined, + page_size: params.page_size || undefined, + filter_type: params.filter_type, + order: params.order, + search: params.search || undefined, + chat_id: params.chat_id || undefined, + exclude_ids: params.exclude_ids, + }, + errorMessage: '获取审核列表失败', + }) + return requireSuccess(data, '获取审核列表失败') + }) } /** @@ -900,47 +365,18 @@ export async function getReviewList(params: { export async function batchReviewExpressions( items: BatchReviewItem[] ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/review/batch`, { - method: 'POST', - body: JSON.stringify({ items }), + return toApiResponse(async () => { + const data = await backendApi.post(`${API_BASE}/review/batch`, { + body: { items }, + errorMessage: '批量审核失败', + }) + return requireSuccess(data, '批量审核失败') }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '批量审核失败'), - } - } catch { - return { - success: false, - error: response.statusText || '批量审核失败', - } - } - } - - try { - const data: BatchReviewResponse = await response.json() - if (data.success) { - return { - success: true, - data: data, - } - } else { - return { - success: false, - error: '批量审核失败', - } - } - } catch { - return { - success: false, - error: '无法解析批量审核响应', - } - } } +/** + * 获取 AI 审核记录 + */ export async function getExpressionReviewLogs( params: { limit?: number @@ -948,74 +384,30 @@ export async function getExpressionReviewLogs( chat_id?: string } = {} ): Promise> { - const queryParams = new URLSearchParams() - if (params.limit) queryParams.append('limit', params.limit.toString()) - if (params.passed !== undefined) queryParams.append('passed', params.passed ? 'true' : 'false') - if (params.chat_id) queryParams.append('chat_id', params.chat_id) - - const response = await fetchWithAuth(`${API_BASE}/review/logs?${queryParams}`) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '获取 AI 审核记录失败'), - } - } catch { - return { - success: false, - error: response.statusText || '获取 AI 审核记录失败', - } - } - } - - try { - const data: ExpressionReviewLogListResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析 AI 审核记录响应', - } - } + return toApiResponse(() => + backendApi.get(`${API_BASE}/review/logs`, { + query: { + limit: params.limit || undefined, + passed: params.passed, + chat_id: params.chat_id || undefined, + }, + errorMessage: '获取 AI 审核记录失败', + }) + ) } +/** + * 恢复被 AI 审核拒绝的表达方式 + */ export async function approveExpressionReviewLog( reviewLogId: string ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/review/logs/${reviewLogId}/approve`, { - method: 'POST', - }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: formatApiError(errorData, '恢复表达方式失败'), - } - } catch { - return { - success: false, - error: response.statusText || '恢复表达方式失败', - } - } - } - - try { - const data: ExpressionReviewLogApproveResponse = await response.json() - return { - success: true, - data, - } - } catch { - return { - success: false, - error: '无法解析恢复表达方式响应', - } - } + return toApiResponse(() => + backendApi.post( + `${API_BASE}/review/logs/${reviewLogId}/approve`, + { + errorMessage: '恢复表达方式失败', + } + ) + ) } diff --git a/dashboard/src/lib/fetch-with-auth.ts b/dashboard/src/lib/fetch-with-auth.ts deleted file mode 100644 index d35296785..000000000 --- a/dashboard/src/lib/fetch-with-auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { getApiBaseUrl } from './api-base' -import { isElectron } from './runtime' - -// 带自动认证处理的 fetch 封装 - -/** - * 将相对路径在 Electron 端转换为绝对路径 - * 浏览器端直接返回原始 input,行为不变 - */ -async function resolveUrl(input: RequestInfo | URL): Promise { - if (isElectron() && typeof input === 'string' && input.startsWith('/')) { - const base = await getApiBaseUrl() - return base ? `${base}${input}` : input - } - return input -} - -/** - * 增强的 fetch 函数,自动处理 401 错误并跳转到登录页 - * 使用 HttpOnly Cookie 进行认证,自动携带 credentials - * - * 对于 FormData 请求,不自动设置 Content-Type,让浏览器自动设置 multipart/form-data - */ -export async function fetchWithAuth( - input: RequestInfo | URL, - init?: RequestInit -): Promise { - // 检查是否是 FormData 请求 - const isFormData = init?.body instanceof FormData - - // 构建 headers,对于 FormData 不设置 Content-Type - const headers: HeadersInit = isFormData - ? { ...init?.headers } - : { 'Content-Type': 'application/json', ...init?.headers } - - // 合并默认配置,确保携带 Cookie - const config: RequestInit = { - ...init, - credentials: 'include', // 确保携带 Cookie - headers, - } - - const response = await fetch(await resolveUrl(input), config) - - // 检测 401 未授权错误 - if (response.status === 401) { - // 跳转到登录页 - window.location.href = '/auth' - - // 抛出错误以便调用者可以处理 - throw new Error('认证失败,请重新登录') - } - - return response -} - -/** - * 获取带认证的请求配置 - * 现在使用 Cookie 认证,不再需要手动设置 Authorization header - */ -export function getAuthHeaders(): HeadersInit { - return { - 'Content-Type': 'application/json', - } -} - -/** - * 调用登出接口并跳转到登录页 - */ -export async function logout(): Promise { - try { - await fetch(await resolveUrl('/api/webui/auth/logout'), { - method: 'POST', - credentials: 'include', - }) - } catch (error) { - console.error('登出请求失败:', error) - } - // 无论成功与否都跳转到登录页 - window.location.href = '/auth' -} - -/** - * 检查当前认证状态 - */ -export async function checkAuthStatus(): Promise { - try { - const response = await fetch(await resolveUrl('/api/webui/auth/check'), { - method: 'GET', - credentials: 'include', - }) - const data = await response.json() - return data.authenticated === true - } catch { - return false - } -} diff --git a/dashboard/src/lib/http/__tests__/client.test.ts b/dashboard/src/lib/http/__tests__/client.test.ts new file mode 100644 index 000000000..44865d502 --- /dev/null +++ b/dashboard/src/lib/http/__tests__/client.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { toApiResponse } from '../compat' +import { createApiClient } from '../client' +import { ApiError } from '../errors' + +/** 构造一个文本/JSON 响应 */ +function makeResponse(body: string, init: ResponseInit = {}): Response { + return new Response(body, { status: 200, ...init }) +} + +function jsonResponse(data: unknown, init: ResponseInit = {}): Response { + return makeResponse(JSON.stringify(data), init) +} + +function mockFetch(response: Response | Error): ReturnType { + // Response 的 body 只能消费一次,每次调用返回克隆以支持同一桩响应多次请求 + const fn = vi.fn(() => + response instanceof Error ? Promise.reject(response) : Promise.resolve(response.clone()) + ) + vi.stubGlobal('fetch', fn) + return fn +} + +/** 断言请求抛出 ApiError 并返回它,便于进一步检查字段 */ +async function expectApiError(promise: Promise): Promise { + try { + await promise + } catch (error) { + expect(error).toBeInstanceOf(ApiError) + return error as ApiError + } + throw new Error('预期抛出 ApiError,但请求成功了') +} + +function makeClient(overrides: Partial[0]> = {}) { + return createApiClient({ + resolveBaseUrl: () => '', + ...overrides, + }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('createApiClient', () => { + describe('URL 构建', () => { + it('拼接 base URL 与路径', async () => { + const fetchMock = mockFetch(jsonResponse({ ok: true })) + const client = makeClient({ resolveBaseUrl: async () => 'http://backend:8000' }) + + await client.get('/api/webui/chats') + + expect(fetchMock.mock.calls[0][0]).toBe('http://backend:8000/api/webui/chats') + }) + + it('序列化 query 参数:跳过 undefined/null,布尔转字符串,数组展开', async () => { + const fetchMock = mockFetch(jsonResponse({})) + const client = makeClient() + + await client.get('/api/list', { + query: { + page: 2, + search: undefined, + chat_id: null, + include_legacy: true, + ids: [1, 2], + }, + }) + + expect(fetchMock.mock.calls[0][0]).toBe('/api/list?page=2&include_legacy=true&ids=1&ids=2') + }) + + it('路径已含 query 时用 & 续接', async () => { + const fetchMock = mockFetch(jsonResponse({})) + const client = makeClient() + + await client.get('/api/list?limit=10', { query: { page: 1 } }) + + expect(fetchMock.mock.calls[0][0]).toBe('/api/list?limit=10&page=1') + }) + }) + + describe('请求体与请求头', () => { + it('对象 body 自动 JSON 序列化并携带 Content-Type', async () => { + const fetchMock = mockFetch(jsonResponse({})) + const client = makeClient() + + await client.post('/api/create', { body: { name: '麦麦' } }) + + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.body).toBe(JSON.stringify({ name: '麦麦' })) + expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' }) + }) + + it('FormData 原样发送且不设置 Content-Type', async () => { + const fetchMock = mockFetch(jsonResponse({})) + const client = makeClient() + const formData = new FormData() + formData.append('file', new Blob(['x']), 'a.txt') + + await client.post('/api/upload', { body: formData }) + + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.body).toBe(formData) + expect(init.headers).not.toHaveProperty('Content-Type') + }) + + it('cache 选项透传给 fetch', async () => { + const fetchMock = mockFetch(jsonResponse({})) + const client = makeClient() + + await client.get('/api/config', { cache: 'no-store' }) + + expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe('no-store') + }) + + it('cookie 认证实例携带 credentials: include,none 实例不携带', async () => { + const fetchMock = mockFetch(jsonResponse({})) + await makeClient({ auth: 'cookie' }).get('/a') + await makeClient({ auth: 'none' }).get('/b') + + expect((fetchMock.mock.calls[0][1] as RequestInit).credentials).toBe('include') + expect((fetchMock.mock.calls[1][1] as RequestInit).credentials).toBeUndefined() + }) + }) + + describe('错误处理', () => { + it('HTTP 失败时抛出 ApiError:message 经 formatApiError 格式化,携带 status 与 detail', async () => { + mockFetch(jsonResponse({ detail: '资源不存在' }, { status: 404 })) + const client = makeClient() + + const error = await expectApiError(client.get('/api/x', { errorMessage: '获取失败' })) + + expect(error).toBeInstanceOf(ApiError) + expect(error.message).toBe('资源不存在') + expect(error.status).toBe(404) + expect(error.detail).toEqual({ detail: '资源不存在' }) + }) + + it('HTTP 失败且错误体无可用信息时使用 errorMessage 文案', async () => { + mockFetch(jsonResponse({}, { status: 500 })) + const client = makeClient() + + const error = await expectApiError(client.get('/api/x', { errorMessage: '获取聊天列表失败' })) + + expect(error.message).toBe('获取聊天列表失败') + }) + + it('FastAPI 校验错误数组被格式化为可读文本', async () => { + mockFetch( + jsonResponse( + { detail: [{ loc: ['query', 'page'], msg: 'Input should be a valid integer' }] }, + { status: 422 } + ) + ) + const client = makeClient() + + const error = await expectApiError(client.get('/api/x')) + + expect(error.message).toBe('query.page: Input should be a valid integer') + }) + + it('401 时调用 onUnauthorized 并抛出认证错误', async () => { + mockFetch(makeResponse('', { status: 401 })) + const onUnauthorized = vi.fn() + const client = makeClient({ auth: 'cookie', onUnauthorized }) + + const error = await expectApiError(client.get('/api/x')) + + expect(onUnauthorized).toHaveBeenCalledOnce() + expect(error).toBeInstanceOf(ApiError) + expect(error.status).toBe(401) + }) + + it('cookie 实例未配置 onUnauthorized 时,401 走普通错误路径并透传后端信息', async () => { + mockFetch(jsonResponse({ detail: 'Token 无效' }, { status: 401 })) + const client = makeClient({ auth: 'cookie' }) + + const error = await expectApiError(client.post('/api/webui/auth/verify')) + + expect(error.message).toBe('Token 无效') + expect(error.status).toBe(401) + }) + + it('auth: none 实例遇到 401 不触发 onUnauthorized,走普通错误路径', async () => { + mockFetch(jsonResponse({ detail: '需要登录' }, { status: 401 })) + const onUnauthorized = vi.fn() + const client = makeClient({ auth: 'none', onUnauthorized }) + + const error = await expectApiError(client.get('/api/x')) + + expect(onUnauthorized).not.toHaveBeenCalled() + expect(error.message).toBe('需要登录') + }) + + it('网络层失败包装为 ApiError 且不带 status', async () => { + mockFetch(new TypeError('Failed to fetch')) + const client = makeClient() + + const error = await expectApiError(client.get('/api/x')) + + expect(error).toBeInstanceOf(ApiError) + expect(error.message).toContain('网络请求失败') + expect(error.message).toContain('Failed to fetch') + expect(error.status).toBeUndefined() + }) + }) + + describe('路由未命中诊断', () => { + it('成功状态但响应体是 HTML 页面时报出诊断信息与请求地址', async () => { + mockFetch(makeResponse('app')) + const client = makeClient() + + const error = await expectApiError(client.get('/api/webui/memory/graph')) + + expect(error).toBeInstanceOf(ApiError) + expect(error.message).toContain('未命中后端 API 路由') + expect(error.message).toContain('/api/webui/memory/graph') + }) + + it('404 且响应体是 HTML 页面时同样报出诊断信息', async () => { + mockFetch(makeResponse('Not Found', { status: 404 })) + const client = makeClient() + + const error = await expectApiError(client.get('/api/x')) + + expect(error.message).toContain('未命中后端 API 路由') + expect(error.status).toBe(404) + }) + }) + + describe('响应解析', () => { + it('默认解析 JSON', async () => { + mockFetch(jsonResponse({ value: 42 })) + const client = makeClient() + + await expect(client.get<{ value: number }>('/api/x')).resolves.toEqual({ value: 42 }) + }) + + it('parse: text 返回原始文本', async () => { + mockFetch(makeResponse('plain content')) + const client = makeClient() + + await expect(client.get('/api/x', { parse: 'text' })).resolves.toBe('plain content') + }) + + it('parse: response 返回原始 Response,body 未被消费', async () => { + mockFetch(jsonResponse({ value: 1 })) + const client = makeClient() + + const result = await client.get('/api/x', { parse: 'response' }) + + expect(result).toBeInstanceOf(Response) + await expect(result.json()).resolves.toEqual({ value: 1 }) + }) + + it('空响应体在 JSON 模式下显式报错', async () => { + mockFetch(makeResponse('')) + const client = makeClient() + + await expect(client.get('/api/x')).rejects.toThrow('接口返回了空响应') + }) + + it('非法 JSON 在 JSON 模式下显式报错', async () => { + mockFetch(makeResponse('not-json')) + const client = makeClient() + + await expect(client.get('/api/x')).rejects.toThrow('接口响应不是合法 JSON') + }) + }) +}) + +describe('toApiResponse', () => { + it('成功时返回 { success: true, data }', async () => { + await expect(toApiResponse(async () => ({ id: 1 }))).resolves.toEqual({ + success: true, + data: { id: 1 }, + }) + }) + + it('ApiError 收敛为 { success: false, error }', async () => { + await expect( + toApiResponse(async () => { + throw new ApiError('获取失败', { status: 500 }) + }) + ).resolves.toEqual({ success: false, error: '获取失败' }) + }) + + it('非 ApiError 异常原样抛出,不做掩盖', async () => { + await expect( + toApiResponse(async () => { + throw new TypeError('编程错误') + }) + ).rejects.toThrow('编程错误') + }) +}) diff --git a/dashboard/src/lib/http/client.ts b/dashboard/src/lib/http/client.ts new file mode 100644 index 000000000..47302db31 --- /dev/null +++ b/dashboard/src/lib/http/client.ts @@ -0,0 +1,197 @@ +/** + * 请求客户端(ApiClient)深模块。 + * + * 收编了此前散落在各 *-api.ts 中的全部请求样板: + * - base URL 解析(Electron 动态后端 / 浏览器同源) + * - Cookie 认证与 401 处理(通过 onUnauthorized 注入,便于测试与多实例差异化) + * - JSON / FormData 请求体编码 + * - 响应解析与错误格式化(formatApiError) + * - 路由未命中诊断:响应体是前端 HTML 页面时报出明确错误,而不是静默重试 + * + * 所有失败统一抛出 ApiError(见 errors.ts),调用方不再手写 response.ok 分支。 + */ +import { formatApiError } from '@/lib/api-error' + +import { ApiError } from './errors' + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + +/** query 参数值:undefined / null 会被跳过,布尔值序列化为 'true' / 'false' */ +export type QueryValue = string | number | boolean | null | undefined + +export interface RequestOptions { + /** 拼接到 URL 的 query 参数;数组值会展开为多个同名参数 */ + query?: Record + /** 请求体:FormData 原样发送(不设 Content-Type),其余值 JSON 序列化 */ + body?: unknown + /** 额外请求头,会覆盖默认头 */ + headers?: HeadersInit + signal?: AbortSignal + /** + * 响应解析方式,默认 'json'。 + * 'response' 返回原始 Response(仅在 HTTP 成功时;失败仍抛 ApiError)。 + */ + parse?: 'json' | 'text' | 'blob' | 'response' + /** 透传 fetch 的缓存模式(如配置读取需要 'no-store' 跳过 HTTP 缓存) */ + cache?: RequestCache + /** 该请求的业务上下文错误文案,后端未给出可用错误信息时作为 ApiError.message */ + errorMessage?: string +} + +export interface ApiClientOptions { + /** 解析请求的 base URL;浏览器同源部署返回空字符串即可 */ + resolveBaseUrl: () => string | Promise + /** 认证方式:'cookie' 携带 HttpOnly Cookie 并处理 401;'none' 不携带凭据 */ + auth?: 'cookie' | 'none' + /** 401 未授权时的回调(如跳转登录页);仅 auth: 'cookie' 时生效 */ + onUnauthorized?: () => void +} + +export interface ApiClient { + request(method: HttpMethod, path: string, options?: RequestOptions): Promise + get(path: string, options?: RequestOptions): Promise + post(path: string, options?: RequestOptions): Promise + put(path: string, options?: RequestOptions): Promise + patch(path: string, options?: RequestOptions): Promise + delete(path: string, options?: RequestOptions): Promise +} + +/** 把 query 对象序列化为 URLSearchParams,跳过 undefined / null */ +function buildSearchParams(query: Record): URLSearchParams { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + const values = Array.isArray(value) ? value : [value] + for (const item of values) { + if (item === undefined || item === null) { + continue + } + params.append(key, typeof item === 'boolean' ? String(item) : String(item)) + } + } + return params +} + +/** 判断响应体是否为前端 HTML 页面(路由未命中后端 API 的典型症状) */ +function isHtmlResponse(rawText: string): boolean { + const normalizedText = rawText.trimStart().toLowerCase() + return normalizedText.startsWith('( + method: HttpMethod, + path: string, + options: RequestOptions = {} + ): Promise { + const { query, body, headers, signal, parse = 'json', errorMessage, cache } = options + + // 拼接完整 URL:base + path + query + const base = await resolveBaseUrl() + let url = `${base}${path}` + if (query) { + const params = buildSearchParams(query) + const queryString = params.toString() + if (queryString) { + url += (url.includes('?') ? '&' : '?') + queryString + } + } + + // 构建请求体与请求头:FormData 交给浏览器设置 multipart 边界,其余 JSON 序列化 + const isFormData = body instanceof FormData + const requestHeaders: HeadersInit = isFormData + ? { ...headers } + : { 'Content-Type': 'application/json', ...headers } + + let response: Response + try { + response = await fetch(url, { + method, + headers: requestHeaders, + body: isFormData ? body : body === undefined ? undefined : JSON.stringify(body), + credentials: auth === 'cookie' ? 'include' : undefined, + signal, + cache, + }) + } catch (error) { + // 请求未到达服务器(断网、DNS、CORS 等),统一包装但不掩盖原因 + const reason = error instanceof Error ? error.message : String(error) + throw new ApiError(`网络请求失败:${reason}`, { cause: error }) + } + + // 401 拦截与 onUnauthorized 回调绑定:配置了回调的实例(如主后端)跳转登录页并抛固定文案; + // 未配置回调的实例(如登录流程的 authApi)让 401 走普通错误路径,透传后端的真实错误信息 + if (response.status === 401 && auth === 'cookie' && onUnauthorized) { + onUnauthorized() + throw new ApiError('认证失败,请重新登录', { status: 401 }) + } + + // HTTP 成功且要求原始形态时直接返回 + if (response.ok && parse === 'response') { + return response as T + } + if (response.ok && parse === 'blob') { + return (await response.blob()) as T + } + + const rawText = await response.text() + const htmlBody = isHtmlResponse(rawText) + + if (!response.ok) { + const fallback = errorMessage ?? `请求失败(HTTP ${response.status})` + let detail: unknown = rawText + let message: string + try { + detail = JSON.parse(rawText) + message = formatApiError(detail, fallback) + } catch { + message = htmlBody ? htmlRouteDiagnostic(url) : response.statusText || fallback + } + throw new ApiError(message, { status: response.status, detail }) + } + + if (parse === 'text') { + return rawText as T + } + + // parse === 'json':HTML / 空响应 / 非法 JSON 都是异常,必须显式暴露 + if (htmlBody) { + throw new ApiError(htmlRouteDiagnostic(url), { status: response.status, detail: rawText }) + } + if (!rawText) { + throw new ApiError('接口返回了空响应', { status: response.status }) + } + try { + return JSON.parse(rawText) as T + } catch { + throw new ApiError('接口响应不是合法 JSON', { status: response.status, detail: rawText }) + } + } + + return { + request, + get: (path, options) => request('GET', path, options), + post: (path, options) => request('POST', path, options), + put: (path, options) => request('PUT', path, options), + patch: (path, options) => request('PATCH', path, options), + delete: (path, options) => request('DELETE', path, options), + } +} diff --git a/dashboard/src/lib/http/compat.ts b/dashboard/src/lib/http/compat.ts new file mode 100644 index 000000000..7d687e307 --- /dev/null +++ b/dashboard/src/lib/http/compat.ts @@ -0,0 +1,24 @@ +/** + * 迁移期兼容层:把 throw ApiError 契约包装回 ApiResponse 判别联合。 + * + * 仅供尚未切换到 throw 契约的旧调用方(页面层)使用; + * 待数据获取 hook 落地、页面层统一消费 ApiError 后整体移除。 + */ +import type { ApiResponse } from '@/types/api' + +import { ApiError } from './errors' + +/** + * 执行一个请求流程,把 ApiError 收敛为 { success: false, error }。 + * 非 ApiError 的异常(编程错误)原样抛出,不做掩盖。 + */ +export async function toApiResponse(run: () => Promise): Promise> { + try { + return { success: true, data: await run() } + } catch (error) { + if (error instanceof ApiError) { + return { success: false, error: error.message } + } + throw error + } +} diff --git a/dashboard/src/lib/http/envelope.ts b/dashboard/src/lib/http/envelope.ts new file mode 100644 index 000000000..f0b88fa14 --- /dev/null +++ b/dashboard/src/lib/http/envelope.ts @@ -0,0 +1,21 @@ +/** + * 主后端业务级响应包络。 + * + * 多数主后端 endpoint 在 HTTP 200 的响应体里再带一层 success 标记 + * ({ success: boolean, message?: string, data?: ... }), + * success 为 false 表示业务级失败,需要与 HTTP 层失败同样以 ApiError 暴露。 + */ +import { ApiError } from './errors' + +export interface SuccessEnvelope { + success: boolean + message?: string +} + +/** 校验响应体中的业务级 success 标记,失败时抛出 ApiError(message 优先取后端给出的 message) */ +export function requireSuccess(data: T, fallback: string): T { + if (!data.success) { + throw new ApiError(data.message || fallback, { detail: data }) + } + return data +} diff --git a/dashboard/src/lib/http/errors.ts b/dashboard/src/lib/http/errors.ts new file mode 100644 index 000000000..b5a298a31 --- /dev/null +++ b/dashboard/src/lib/http/errors.ts @@ -0,0 +1,24 @@ +/** + * 请求客户端统一的错误类型。 + * + * 请求层所有失败(HTTP 错误、解析失败、网络异常、认证失效)都以 ApiError 抛出: + * - message 已经过 formatApiError 格式化,可直接用于 toast / 页面渲染; + * - status 是 HTTP 状态码,请求未到达服务器(网络层失败)时为 undefined; + * - detail 保留后端返回的原始错误体,便于调试与精细化处理。 + */ +export class ApiError extends Error { + /** HTTP 状态码;网络层失败(请求未到达服务器)时为 undefined */ + readonly status?: number + /** 后端返回的原始错误体(JSON 解析结果或原始文本) */ + readonly detail?: unknown + + constructor( + message: string, + options: { status?: number; detail?: unknown; cause?: unknown } = {} + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }) + this.name = 'ApiError' + this.status = options.status + this.detail = options.detail + } +} diff --git a/dashboard/src/lib/http/index.ts b/dashboard/src/lib/http/index.ts new file mode 100644 index 000000000..10d9327cc --- /dev/null +++ b/dashboard/src/lib/http/index.ts @@ -0,0 +1,13 @@ +/** + * 请求客户端(ApiClient)统一出口。 + * + * 业务 API 模块应从这里导入 backendApi / statsApi 与 ApiError, + * 不要再直接使用 fetch / fetchWithAuth 手写请求样板。 + */ +export { toApiResponse } from './compat' +export { createApiClient } from './client' +export { ApiError } from './errors' +export { authApi, backendApi, statsApi, STATS_SERVICE_BASE_URL } from './instances' +export { requireSuccess } from './envelope' +export type { ApiClient, ApiClientOptions, HttpMethod, QueryValue, RequestOptions } from './client' +export type { SuccessEnvelope } from './envelope' diff --git a/dashboard/src/lib/http/instances.ts b/dashboard/src/lib/http/instances.ts new file mode 100644 index 000000000..976ea9118 --- /dev/null +++ b/dashboard/src/lib/http/instances.ts @@ -0,0 +1,36 @@ +/** + * 请求客户端的两个适配器实例。 + * + * - backendApi:主后端(MaiBot 本体 HTTP API),Cookie 认证,401 跳转登录页 + * - statsApi:统计服务(Cloudflare Workers 上的问卷/插件统计),无认证 + */ +import { getApiBaseUrl } from '@/lib/api-base' + +import { createApiClient } from './client' + +/** 统计服务地址(Cloudflare Workers) */ +export const STATS_SERVICE_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev' + +/** 主后端实例:浏览器同源 / Electron 动态后端 URL */ +export const backendApi = createApiClient({ + resolveBaseUrl: getApiBaseUrl, + auth: 'cookie', + onUnauthorized: () => { + window.location.href = '/auth' + }, +}) + +/** 统计服务实例:外部服务,不携带凭据 */ +export const statsApi = createApiClient({ + resolveBaseUrl: () => STATS_SERVICE_BASE_URL, + auth: 'none', +}) + +/** + * 认证流程实例:携带 Cookie 但不配置 onUnauthorized—— + * 登录验证、认证状态探测中 401 是正常业务结果,必须透传后端信息而不是跳转登录页。 + */ +export const authApi = createApiClient({ + resolveBaseUrl: getApiBaseUrl, + auth: 'cookie', +}) diff --git a/dashboard/src/lib/jargon-api.ts b/dashboard/src/lib/jargon-api.ts index 953fa1bfc..599270a5d 100644 --- a/dashboard/src/lib/jargon-api.ts +++ b/dashboard/src/lib/jargon-api.ts @@ -1,17 +1,20 @@ /** * 黑话(俚语)管理 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint 与业务错误文案。请求失败时抛出 ApiError(throw 契约)。 */ -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import type { - JargonListResponse, - JargonDetailResponse, + JargonChatListResponse, JargonCreateRequest, JargonCreateResponse, - JargonUpdateRequest, - JargonUpdateResponse, JargonDeleteResponse, + JargonDetailResponse, + JargonListResponse, JargonStatsResponse, - JargonChatListResponse, + JargonUpdateRequest, + JargonUpdateResponse, } from '@/types/jargon' const API_BASE = '/api/webui/jargon' @@ -20,19 +23,10 @@ const API_BASE = '/api/webui/jargon' * 获取聊天列表(有黑话记录的聊天) */ export async function getJargonChatList(params: { include_empty?: boolean } = {}): Promise { - const queryParams = new URLSearchParams() - if (params.include_empty !== undefined) { - queryParams.append('include_empty', params.include_empty.toString()) - } - const queryString = queryParams.toString() - const response = await fetchWithAuth(`${API_BASE}/chats${queryString ? `?${queryString}` : ''}`, {}) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取聊天列表失败') - } - - return response.json() + return backendApi.get(`${API_BASE}/chats`, { + query: { include_empty: params.include_empty }, + errorMessage: '获取聊天列表失败', + }) } /** @@ -46,41 +40,26 @@ export async function getJargonList(params: { is_jargon?: boolean | null is_global?: boolean }): Promise { - const queryParams = new URLSearchParams() - - if (params.page) queryParams.append('page', params.page.toString()) - if (params.page_size) queryParams.append('page_size', params.page_size.toString()) - if (params.search) queryParams.append('search', params.search) - if (params.session_id) queryParams.append('session_id', params.session_id) - if (params.is_jargon !== undefined && params.is_jargon !== null) { - queryParams.append('is_jargon', params.is_jargon.toString()) - } - if (params.is_global !== undefined) { - queryParams.append('is_global', params.is_global.toString()) - } - - const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {}) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取黑话列表失败') - } - - return response.json() + return backendApi.get(`${API_BASE}/list`, { + query: { + page: params.page || undefined, + page_size: params.page_size || undefined, + search: params.search || undefined, + session_id: params.session_id || undefined, + is_jargon: params.is_jargon, + is_global: params.is_global, + }, + errorMessage: '获取黑话列表失败', + }) } /** * 获取黑话详细信息 */ export async function getJargonDetail(jargonId: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {}) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取黑话详情失败') - } - - return response.json() + return backendApi.get(`${API_BASE}/${jargonId}`, { + errorMessage: '获取黑话详情失败', + }) } /** @@ -89,17 +68,10 @@ export async function getJargonDetail(jargonId: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/`, { - method: 'POST', - body: JSON.stringify(data), + return backendApi.post(`${API_BASE}/`, { + body: data, + errorMessage: '创建黑话失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '创建黑话失败') - } - - return response.json() } /** @@ -109,64 +81,38 @@ export async function updateJargon( jargonId: number, data: JargonUpdateRequest ): Promise { - const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, { - method: 'PATCH', - body: JSON.stringify(data), + return backendApi.patch(`${API_BASE}/${jargonId}`, { + body: data, + errorMessage: '更新黑话失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '更新黑话失败') - } - - return response.json() } /** * 删除黑话 */ export async function deleteJargon(jargonId: number): Promise { - const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, { - method: 'DELETE', + return backendApi.delete(`${API_BASE}/${jargonId}`, { + errorMessage: '删除黑话失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '删除黑话失败') - } - - return response.json() } /** * 批量删除黑话 */ export async function batchDeleteJargons(jargonIds: number[]): Promise { - const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { - method: 'POST', - body: JSON.stringify({ ids: jargonIds }), + return backendApi.post(`${API_BASE}/batch/delete`, { + body: { ids: jargonIds }, + errorMessage: '批量删除黑话失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '批量删除黑话失败') - } - - return response.json() } /** * 获取黑话统计数据 */ export async function getJargonStats(): Promise { - const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {}) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取黑话统计失败') - } - - return response.json() + return backendApi.get(`${API_BASE}/stats/summary`, { + errorMessage: '获取黑话统计失败', + }) } /** @@ -176,18 +122,8 @@ export async function batchSetJargonStatus( jargonIds: number[], isJargon: boolean ): Promise { - const queryParams = new URLSearchParams() - jargonIds.forEach(id => queryParams.append('ids', id.toString())) - queryParams.append('is_jargon', isJargon.toString()) - - const response = await fetchWithAuth(`${API_BASE}/batch/set-jargon?${queryParams}`, { - method: 'POST', + return backendApi.post(`${API_BASE}/batch/set-jargon`, { + query: { ids: jargonIds, is_jargon: isJargon }, + errorMessage: '批量设置黑话状态失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '批量设置黑话状态失败') - } - - return response.json() } diff --git a/dashboard/src/lib/knowledge-api.ts b/dashboard/src/lib/knowledge-api.ts index ff679191f..c65c115ad 100644 --- a/dashboard/src/lib/knowledge-api.ts +++ b/dashboard/src/lib/knowledge-api.ts @@ -1,17 +1,13 @@ /** * 知识库 API + * + * 请求经由主后端实例 backendApi:自动携带 Cookie 认证、处理 Electron 后端地址, + * 失败统一抛出 ApiError。 */ -import { getApiBaseUrl } from './api-base' -import { isElectron } from './runtime' +import { backendApi } from '@/lib/http' -async function getKnowledgeApiBase(): Promise { - if (isElectron()) { - const base = await getApiBaseUrl() - return base ? `${base}/api/webui` : '/api/webui' - } - return import.meta.env.VITE_API_BASE_URL || '/api/webui' -} +const API_BASE = '/api/webui/knowledge' export interface KnowledgeNode { id: string @@ -44,35 +40,27 @@ export interface KnowledgeStats { * 获取知识图谱数据 */ export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | 'entity' | 'paragraph' = 'all'): Promise { - const url = `${await getKnowledgeApiBase()}/knowledge/graph?limit=${limit}&node_type=${nodeType}` - - const response = await fetch(url) - - if (!response.ok) { - throw new Error(`获取知识图谱失败: ${response.status}`) - } - - return response.json() + return backendApi.get(`${API_BASE}/graph`, { + query: { limit, node_type: nodeType }, + errorMessage: '获取知识图谱失败', + }) } /** * 获取知识图谱统计信息 */ export async function getKnowledgeStats(): Promise { - const response = await fetch(`${await getKnowledgeApiBase()}/knowledge/stats`) - if (!response.ok) { - throw new Error('获取知识图谱统计信息失败') - } - return response.json() + return backendApi.get(`${API_BASE}/stats`, { + errorMessage: '获取知识图谱统计信息失败', + }) } /** * 搜索知识节点 */ export async function searchKnowledgeNode(query: string): Promise { - const response = await fetch(`${await getKnowledgeApiBase()}/knowledge/search?query=${encodeURIComponent(query)}`) - if (!response.ok) { - throw new Error('搜索知识节点失败') - } - return response.json() + return backendApi.get(`${API_BASE}/search`, { + query: { query }, + errorMessage: '搜索知识节点失败', + }) } diff --git a/dashboard/src/lib/log-websocket.ts b/dashboard/src/lib/log-websocket.ts index c1ff9b2ec..2563c0359 100644 --- a/dashboard/src/lib/log-websocket.ts +++ b/dashboard/src/lib/log-websocket.ts @@ -3,7 +3,7 @@ * 确保整个应用只通过统一连接层订阅日志流 */ -import { checkAuthStatus } from './fetch-with-auth' +import { checkAuthStatus } from './auth' import { getSetting } from './settings-manager' import { unifiedWsClient } from './unified-ws' diff --git a/dashboard/src/lib/memory-api.ts b/dashboard/src/lib/memory-api.ts index c7623e43d..5806b9b16 100644 --- a/dashboard/src/lib/memory-api.ts +++ b/dashboard/src/lib/memory-api.ts @@ -1,109 +1,24 @@ import type { PluginConfigSchema } from '@/lib/plugin-api' -import { getApiBaseUrl } from './api-base' -import { isElectron } from './runtime' +import { backendApi } from '@/lib/http' +import type { HttpMethod } from '@/lib/http' -async function getMemoryApiBase(): Promise { - if (isElectron()) { - const base = await getApiBaseUrl() - return normalizeMemoryApiBase(base) - } - return normalizeMemoryApiBase(import.meta.env.VITE_API_BASE_URL) -} - -function normalizeMemoryApiBase(rawBase?: string | null): string { - const base = String(rawBase ?? '').replace(/\/+$/, '') - if (!base) { - return '/api/webui/memory' - } - if (base.endsWith('/api/webui/memory')) { - return base - } - if (base.endsWith('/api/webui')) { - return `${base}/memory` - } - return `${base}/api/webui/memory` -} - -function withMemoryRequestDefaults(init?: RequestInit): RequestInit { - return { - ...init, - credentials: init?.credentials ?? 'include', - } -} - -function isHtmlResponse(rawText: string): boolean { - const normalizedText = rawText.trimStart().toLowerCase() - return normalizedText.startsWith(' base !== primaryBase) -} - -async function requestJson(path: string, init?: RequestInit): Promise { - const primaryBase = await getMemoryApiBase() - const urls = [ - `${primaryBase}${path}`, - ...getLocalMemoryApiFallbackBases(primaryBase).map((base) => `${base}${path}`), - ] - const requestInit = withMemoryRequestDefaults(init) - - for (let index = 0; index < urls.length; index += 1) { - const url = urls[index] - const response = await fetch(url, requestInit) - const rawText = await response.text() - const htmlResponse = isHtmlResponse(rawText) - const canRetry = index < urls.length - 1 - - if ((htmlResponse || response.status === 404) && canRetry) { - continue - } - - if (!response.ok) { - let detail = `${response.status}` - try { - const payload = JSON.parse(rawText) - detail = String(payload?.detail ?? payload?.error ?? detail) - } catch { - if (htmlResponse) { - detail = `接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}` - } - } - throw new Error(detail) - } - - try { - return JSON.parse(rawText) as T - } catch { - if (htmlResponse) { - throw new Error(`接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`) - } - throw new Error(rawText ? '接口响应不是合法 JSON' : '接口返回了空响应') - } - } - - throw new Error('接口请求失败') +/** + * 记忆 API 统一入口:拼接记忆模块路径前缀,请求经由主后端实例 backendApi。 + * 失败统一抛出 ApiError(含路由未命中诊断),不再静默重试本地兜底地址。 + */ +function requestJson(path: string, options: MemoryRequestOptions = {}): Promise { + return backendApi.request(options.method ?? 'GET', `${API_BASE}${path}`, { + body: options.body, + }) } export interface MemoryGraphNodePayload { @@ -959,8 +874,7 @@ export async function previewMemoryDelete( ): Promise { return requestJson('/delete/preview', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -969,8 +883,7 @@ export async function executeMemoryDelete( ): Promise { return requestJson('/delete/execute', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -983,8 +896,7 @@ export async function restoreMemoryDelete(payload: { }): Promise> { return requestJson('/delete/restore', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1043,8 +955,7 @@ export async function rollbackMemoryFeedbackCorrection( ): Promise { return requestJson(`/feedback-corrections/${taskId}/rollback`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1114,8 +1025,7 @@ export async function rebuildMemoryEpisodes(payload: { }): Promise { return requestJson('/episodes/rebuild', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1129,8 +1039,7 @@ export async function processMemoryEpisodePending(payload: { }): Promise { return requestJson('/episodes/process-pending', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1182,8 +1091,7 @@ export async function setMemoryProfileOverride(payload: { }): Promise { return requestJson('/profiles/override', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1216,15 +1124,14 @@ export async function correctMemoryProfileEvidence(payload: { }): Promise { return requestJson(`/profiles/${encodeURIComponent(payload.person_id)}/evidence/correct`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ + body: { evidence_type: payload.evidence_type, hash: payload.hash, requested_by: payload.requested_by ?? 'knowledge_base', reason: payload.reason ?? 'profile_evidence_correction', refresh: payload.refresh ?? true, limit: payload.limit ?? 12, - }), + }, }) } @@ -1235,8 +1142,7 @@ export async function getMemoryRecycleBin(limit: number = 50): Promise { return requestJson(path, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1273,8 +1179,7 @@ export async function rebuildMemoryRuntimeVectors(payload: { } = {}): Promise { return requestJson('/runtime/vectors/rebuild', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1289,8 +1194,7 @@ export async function getMemoryConfig(): Promise { export async function updateMemoryConfig(config: Record): Promise<{ success: boolean; message?: string }> { return requestJson('/config', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config }), + body: { config }, }) } @@ -1301,8 +1205,7 @@ export async function getMemoryConfigRaw(): Promise { export async function updateMemoryConfigRaw(config: string): Promise<{ success: boolean; message?: string }> { return requestJson('/config/raw', { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config }), + body: { config }, }) } @@ -1329,8 +1232,7 @@ export async function resolveMemoryImportPath(payload: { }): Promise { return requestJson('/import/resolve-path', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1370,48 +1272,42 @@ export async function createMemoryUploadImport(files: File[], payload: Record): Promise { return requestJson('/import/paste', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } export async function createMemoryRawScanImport(payload: Record): Promise { return requestJson('/import/raw-scan', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } export async function createMemoryLpmmOpenieImport(payload: Record): Promise { return requestJson('/import/lpmm-openie', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } export async function createMemoryLpmmConvertImport(payload: Record): Promise { return requestJson('/import/lpmm-convert', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } export async function createMemoryTemporalBackfillImport(payload: Record): Promise { return requestJson('/import/temporal-backfill', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } export async function createMemoryMaibotMigrationImport(payload: Record): Promise { return requestJson('/import/maibot-migration', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1429,8 +1325,7 @@ export async function retryMemoryImportTask( ): Promise { return requestJson(`/import/tasks/${encodeURIComponent(taskId)}/retry`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } @@ -1445,8 +1340,7 @@ export async function getMemoryTuningTasks(limit: number = 20): Promise): Promise<{ success: boolean; task?: MemoryTaskPayload }> { return requestJson('/retrieval_tuning/tasks', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, }) } diff --git a/dashboard/src/lib/pack-api.ts b/dashboard/src/lib/pack-api.ts index bf26c5db7..e13fd01a4 100644 --- a/dashboard/src/lib/pack-api.ts +++ b/dashboard/src/lib/pack-api.ts @@ -1,10 +1,12 @@ /** * 模型配置 Pack API - * - * 与 Cloudflare Workers Pack 服务交互 + * + * 与 Cloudflare Workers Pack 服务(statsApi 实例,与统计服务同源) + * 和主后端模型配置接口(backendApi 实例)交互。 + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案与 Pack 服务包络的解包规则。 */ - -import { fetchWithAuth } from './fetch-with-auth' +import { ApiError, backendApi, statsApi } from '@/lib/http' // ============ 类型定义 ============ @@ -135,10 +137,30 @@ export interface ApplyPackConflicts { }> } -// ============ API 配置 ============ +/** + * Pack 服务的业务包络:失败时以 error 字段(而非 message)给出原因, + * 因此不使用 requireSuccess,逐函数手动校验。 + */ +interface PackEnvelope { + success: boolean + error?: string +} + +/** 本地模型配置中的提供商条目(本地配置含 api_key,分享 Pack 时会被剥离) */ +type LocalProviderConfig = PackProvider & { api_key?: string } -// Pack 服务基础 URL(Cloudflare Workers) -const PACK_SERVICE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev' +/** /api/webui/config/model 返回的本地模型配置(仅声明本文件用到的字段) */ +interface LocalModelConfig { + api_providers: LocalProviderConfig[] + models: PackModel[] + model_task_config: Record +} + +/** 模型配置接口响应体:{ success, config } 包络 */ +interface ModelConfigResponse { + success?: boolean + config?: LocalModelConfig +} // ============ API 函数 ============ @@ -153,32 +175,28 @@ export async function listPacks(params?: { sort_by?: 'created_at' | 'downloads' | 'likes' sort_order?: 'asc' | 'desc' }): Promise { - const searchParams = new URLSearchParams() - if (params?.status) searchParams.set('status', params.status) - if (params?.page) searchParams.set('page', params.page.toString()) - if (params?.page_size) searchParams.set('page_size', params.page_size.toString()) - if (params?.search) searchParams.set('search', params.search) - if (params?.sort_by) searchParams.set('sort_by', params.sort_by) - if (params?.sort_order) searchParams.set('sort_order', params.sort_order) - - const response = await fetch(`${PACK_SERVICE_URL}/pack?${searchParams.toString()}`) - if (!response.ok) { - throw new Error(`获取 Pack 列表失败: ${response.status}`) - } - return response.json() + return statsApi.get('/pack', { + query: { + status: params?.status, + page: params?.page || undefined, + page_size: params?.page_size || undefined, + search: params?.search || undefined, + sort_by: params?.sort_by, + sort_order: params?.sort_order, + }, + errorMessage: '获取 Pack 列表失败', + }) } /** * 获取单个 Pack 详情 */ export async function getPack(packId: string): Promise { - const response = await fetch(`${PACK_SERVICE_URL}/pack/${packId}`) - if (!response.ok) { - throw new Error(`获取 Pack 失败: ${response.status}`) - } - const data = await response.json() + const data = await statsApi.get(`/pack/${packId}`, { + errorMessage: '获取 Pack 失败', + }) if (!data.success) { - throw new Error(data.error || '获取 Pack 失败') + throw new ApiError(data.error || '获取 Pack 失败', { detail: data }) } return data.pack } @@ -195,15 +213,12 @@ export async function createPack(pack: { models: PackModel[] task_config: PackTaskConfigs }): Promise<{ pack_id: string; message: string }> { - const response = await fetch(`${PACK_SERVICE_URL}/pack`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(pack), + const data = await statsApi.post('/pack', { + body: pack, + errorMessage: '创建 Pack 失败', }) - - const data = await response.json() if (!data.success) { - throw new Error(data.error || '创建 Pack 失败') + throw new ApiError(data.error || '创建 Pack 失败', { detail: data }) } return data } @@ -212,26 +227,31 @@ export async function createPack(pack: { * 记录 Pack 下载 */ export async function recordPackDownload(packId: string, userId?: string): Promise { - await fetch(`${PACK_SERVICE_URL}/pack/download`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pack_id: packId, user_id: userId }), - }) + // 下载计数为尽力而为的统计:原实现不检查 response.ok 也不读取响应体, + // HTTP 层失败不影响调用方;网络层失败(status 为 undefined)保持抛出。 + try { + await statsApi.post('/pack/download', { + body: { pack_id: packId, user_id: userId }, + parse: 'response', + errorMessage: '记录 Pack 下载失败', + }) + } catch (error) { + if (!(error instanceof ApiError) || error.status === undefined) { + throw error + } + } } /** * 点赞/取消点赞 Pack */ export async function togglePackLike(packId: string, userId: string): Promise<{ likes: number; liked: boolean }> { - const response = await fetch(`${PACK_SERVICE_URL}/pack/like`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pack_id: packId, user_id: userId }), + const data = await statsApi.post('/pack/like', { + body: { pack_id: packId, user_id: userId }, + errorMessage: '点赞失败', }) - - const data = await response.json() if (!data.success) { - throw new Error(data.error || '点赞失败') + throw new ApiError(data.error || '点赞失败', { detail: data }) } return { likes: data.likes, liked: data.liked } } @@ -240,10 +260,10 @@ export async function togglePackLike(packId: string, userId: string): Promise<{ * 检查是否已点赞 */ export async function checkPackLike(packId: string, userId: string): Promise { - const response = await fetch( - `${PACK_SERVICE_URL}/pack/like/check?pack_id=${packId}&user_id=${userId}` - ) - const data = await response.json() + const data = await statsApi.get<{ liked?: boolean }>('/pack/like/check', { + query: { pack_id: packId, user_id: userId }, + errorMessage: '查询 Pack 点赞状态失败', + }) return data.liked || false } @@ -256,19 +276,17 @@ export async function detectPackConflicts( pack: ModelPack ): Promise { // 获取当前配置 - const response = await fetchWithAuth('/api/webui/config/model') - if (!response.ok) { - throw new Error('获取当前模型配置失败') - } - const responseData = await response.json() - const currentConfig = responseData.config || responseData - + const responseData = await backendApi.get('/api/webui/config/model', { + errorMessage: '获取当前模型配置失败', + }) + const currentConfig = (responseData.config || responseData) as LocalModelConfig + const conflicts: ApplyPackConflicts = { existing_providers: [], new_providers: [], conflicting_models: [], } - + // 检测提供商冲突 const localProviders = currentConfig.api_providers || [] for (const packProvider of pack.providers) { @@ -280,7 +298,7 @@ export async function detectPackConflicts( return localNormalized === packNormalized } ) - + if (matchedProviders.length > 0) { conflicts.existing_providers.push({ pack_provider: packProvider, @@ -293,7 +311,7 @@ export async function detectPackConflicts( conflicts.new_providers.push(packProvider) } } - + // 检测模型名称冲突 const localModels = currentConfig.models || [] for (const packModel of pack.models) { @@ -307,7 +325,7 @@ export async function detectPackConflicts( }) } } - + return conflicts } @@ -321,42 +339,40 @@ export async function applyPack( newProviderApiKeys: Record, // provider_name -> api_key ): Promise { // 获取当前配置 - const response = await fetchWithAuth('/api/webui/config/model') - if (!response.ok) { - throw new Error('获取当前模型配置失败') - } - const responseData = await response.json() - const currentConfig = responseData.config || responseData - + const responseData = await backendApi.get('/api/webui/config/model', { + errorMessage: '获取当前模型配置失败', + }) + const currentConfig = (responseData.config || responseData) as LocalModelConfig + // 1. 处理提供商 if (options.apply_providers) { - const providersToApply = options.selected_providers + const providersToApply = options.selected_providers ? pack.providers.filter(p => options.selected_providers!.includes(p.name)) : pack.providers - + for (const packProvider of providersToApply) { // 检查是否映射到已有提供商 if (providerMapping[packProvider.name]) { // 使用已有提供商,不需要添加 continue } - + // 添加新提供商 const apiKey = newProviderApiKeys[packProvider.name] if (!apiKey) { throw new Error(`提供商 "${packProvider.name}" 缺少 API Key`) } - + const newProvider = { ...packProvider, api_key: apiKey, } - + // 检查是否已存在同名提供商 const existingIndex = currentConfig.api_providers.findIndex( (p: { name: string }) => p.name === packProvider.name ) - + if (existingIndex >= 0) { // 覆盖 currentConfig.api_providers[existingIndex] = newProvider @@ -366,27 +382,27 @@ export async function applyPack( } } } - + // 2. 处理模型 if (options.apply_models) { const modelsToApply = options.selected_models ? pack.models.filter(m => options.selected_models!.includes(m.name)) : pack.models - + for (const packModel of modelsToApply) { // 映射提供商名称 const actualProvider = providerMapping[packModel.api_provider] || packModel.api_provider - + const newModel = { ...packModel, api_provider: actualProvider, } - + // 检查是否已存在同名模型 const existingIndex = currentConfig.models.findIndex( (m: { name: string }) => m.name === packModel.name ) - + if (existingIndex >= 0) { // 覆盖 currentConfig.models[existingIndex] = newModel @@ -396,15 +412,15 @@ export async function applyPack( } } } - + // 3. 处理任务配置 if (options.apply_task_config) { const taskKeys = options.selected_tasks || Object.keys(pack.task_config) - + for (const taskKey of taskKeys) { const packTaskConfig = pack.task_config[taskKey as keyof PackTaskConfigs] if (!packTaskConfig) continue - + // 映射模型名称(如果模型名称被跳过,则从任务列表中移除) const appliedModelNames = new Set( options.selected_models || pack.models.map(m => m.name) @@ -412,14 +428,14 @@ export async function applyPack( const filteredModelList = packTaskConfig.model_list.filter( name => appliedModelNames.has(name) ) - + if (filteredModelList.length === 0) continue - + const newTaskConfig = { ...packTaskConfig, model_list: filteredModelList, } - + if (options.task_mode === 'replace') { // 替换模式 currentConfig.model_task_config[taskKey] = newTaskConfig @@ -442,17 +458,13 @@ export async function applyPack( } } } - - // 保存配置 - const saveResponse = await fetchWithAuth('/api/webui/config/model', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(currentConfig), + + // 保存配置(原实现不读取保存接口的响应体,保持 parse: 'response') + await backendApi.post('/api/webui/config/model', { + body: currentConfig, + parse: 'response', + errorMessage: '保存配置失败', }) - - if (!saveResponse.ok) { - throw new Error('保存配置失败') - } } /** @@ -472,22 +484,20 @@ export async function exportCurrentConfigAsPack(params: { task_config: PackTaskConfigs }> { // 获取当前配置 - const response = await fetchWithAuth('/api/webui/config/model') - if (!response.ok) { - throw new Error('获取当前模型配置失败') - } - const responseData = await response.json() - + const responseData = await backendApi.get('/api/webui/config/model', { + errorMessage: '获取当前模型配置失败', + }) + // API 返回的格式是 { success: true, config: {...} } if (!responseData.success || !responseData.config) { - throw new Error('获取配置失败') + throw new ApiError('获取配置失败', { detail: responseData }) } - + const currentConfig = responseData.config - + // 过滤提供商(移除 api_key) let providers: PackProvider[] = (currentConfig.api_providers || []).map( - (p: { name: string; base_url: string; client_type: string; max_retry?: number; timeout?: number; retry_interval?: number }) => ({ + (p) => ({ name: p.name, base_url: p.base_url, client_type: p.client_type, @@ -496,28 +506,28 @@ export async function exportCurrentConfigAsPack(params: { retry_interval: p.retry_interval, }) ) - + if (params.selectedProviders) { providers = providers.filter(p => params.selectedProviders!.includes(p.name)) } - + // 过滤模型 let models: PackModel[] = currentConfig.models || [] if (params.selectedModels) { models = models.filter(m => params.selectedModels!.includes(m.name)) } - + // 过滤任务配置 const task_config: PackTaskConfigs = {} const allTasks = currentConfig.model_task_config || {} const taskKeys = params.selectedTasks || Object.keys(allTasks) - + for (const key of taskKeys) { if (allTasks[key]) { task_config[key as keyof PackTaskConfigs] = allTasks[key] } } - + return { providers, models, task_config } } diff --git a/dashboard/src/lib/person-api.ts b/dashboard/src/lib/person-api.ts index 2a272ff88..3c8ac216b 100644 --- a/dashboard/src/lib/person-api.ts +++ b/dashboard/src/lib/person-api.ts @@ -1,17 +1,20 @@ /** * 人物信息管理 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案与响应体 success 标记的解包规则。 + * 已切换为 throw 契约:成功直接返回数据,失败抛出 ApiError(配合 TanStack Query 使用)。 */ -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' -import type { ApiResponse } from '@/types/api' +import { ApiError, backendApi, requireSuccess } from '@/lib/http' import type { - PersonListResponse, - PersonDetailResponse, - PersonUpdateRequest, - PersonUpdateResponse, PersonDeleteResponse, - PersonStatsResponse, + PersonDetailResponse, PersonInfo, + PersonListResponse, PersonStats, + PersonStatsResponse, + PersonUpdateRequest, + PersonUpdateResponse, } from '@/types/person' const API_BASE = '/api/webui/person' @@ -35,102 +38,34 @@ export async function getPersonList(params: { search?: string is_known?: boolean platform?: string -}): Promise> { - const queryParams = new URLSearchParams() - - if (params.page) queryParams.append('page', params.page.toString()) - if (params.page_size) queryParams.append('page_size', params.page_size.toString()) - if (params.search) queryParams.append('search', params.search) - if (params.is_known !== undefined) queryParams.append('is_known', params.is_known.toString()) - if (params.platform) queryParams.append('platform', params.platform) - - const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, { - headers: getAuthHeaders(), +}): Promise { + const data = await backendApi.get(`${API_BASE}/list`, { + query: { + page: params.page || undefined, + page_size: params.page_size || undefined, + search: params.search || undefined, + is_known: params.is_known, + platform: params.platform || undefined, + }, + errorMessage: '获取人物列表失败', }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: errorData.detail || errorData.message || '获取人物列表失败', - } - } catch { - return { - success: false, - error: response.statusText || '获取人物列表失败', - } - } - } - - try { - const data: PersonListResponse = await response.json() - if (data.success) { - return { - success: true, - data: { - data: data.data, - total: data.total, - page: data.page, - page_size: data.page_size, - }, - } - } else { - return { - success: false, - error: '获取人物列表失败', - } - } - } catch { - return { - success: false, - error: 'Failed to parse response', - } + const checked = requireSuccess(data, '获取人物列表失败') + return { + data: checked.data, + total: checked.total, + page: checked.page, + page_size: checked.page_size, } } /** * 获取人物详细信息 */ -export async function getPersonDetail(personId: string): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${personId}`, { - headers: getAuthHeaders(), +export async function getPersonDetail(personId: string): Promise { + const data = await backendApi.get(`${API_BASE}/${personId}`, { + errorMessage: '获取人物详情失败', }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: errorData.detail || errorData.message || '获取人物详情失败', - } - } catch { - return { - success: false, - error: response.statusText || '获取人物详情失败', - } - } - } - - try { - const data: PersonDetailResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } else { - return { - success: false, - error: '获取人物详情失败', - } - } - } catch { - return { - success: false, - error: 'Failed to parse response', - } - } + return requireSuccess(data, '获取人物详情失败').data } /** @@ -139,192 +74,62 @@ export async function getPersonDetail(personId: string): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${personId}`, { - method: 'PATCH', - headers: getAuthHeaders(), - body: JSON.stringify(data), +): Promise { + const responseData = await backendApi.patch(`${API_BASE}/${personId}`, { + body: data, + errorMessage: '更新人物信息失败', }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: errorData.detail || errorData.message || '更新人物信息失败', - } - } catch { - return { - success: false, - error: response.statusText || '更新人物信息失败', - } - } - } - - try { - const data: PersonUpdateResponse = await response.json() - if (data.success && data.data) { - return { - success: true, - data: data.data, - } - } else { - return { - success: false, - error: data.message || '更新人物信息失败', - } - } - } catch { - return { - success: false, - error: 'Failed to parse response', - } + const checked = requireSuccess(responseData, '更新人物信息失败') + if (!checked.data) { + throw new ApiError(checked.message || '更新人物信息失败', { detail: checked }) } + return checked.data } /** * 删除人物信息 */ -export async function deletePerson(personId: string): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${personId}`, { - method: 'DELETE', - headers: getAuthHeaders(), +export async function deletePerson(personId: string): Promise { + const data = await backendApi.delete(`${API_BASE}/${personId}`, { + errorMessage: '删除人物信息失败', }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: errorData.detail || errorData.message || '删除人物信息失败', - } - } catch { - return { - success: false, - error: response.statusText || '删除人物信息失败', - } - } - } - - try { - const data: PersonDeleteResponse = await response.json() - if (data.success) { - return { - success: true, - data: undefined as unknown as void, - } - } else { - return { - success: false, - error: data.message || '删除人物信息失败', - } - } - } catch { - return { - success: false, - error: 'Failed to parse response', - } - } + requireSuccess(data, '删除人物信息失败') } /** * 获取人物统计数据 */ -export async function getPersonStats(): Promise> { - const response = await fetchWithAuth(`${API_BASE}/stats/summary`, { - headers: getAuthHeaders(), +export async function getPersonStats(): Promise { + const data = await backendApi.get(`${API_BASE}/stats/summary`, { + errorMessage: '获取统计数据失败', }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: errorData.detail || errorData.message || '获取统计数据失败', - } - } catch { - return { - success: false, - error: response.statusText || '获取统计数据失败', - } - } - } - - try { - const data: PersonStatsResponse = await response.json() - if (data.success) { - return { - success: true, - data: data.data, - } - } else { - return { - success: false, - error: '获取统计数据失败', - } - } - } catch { - return { - success: false, - error: 'Failed to parse response', - } - } + return requireSuccess(data, '获取统计数据失败').data } /** * 批量删除人物信息 */ -export async function batchDeletePersons( - personIds: string[] -): Promise> { - const response = await fetchWithAuth(`${API_BASE}/batch/delete`, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ person_ids: personIds }), +}> { + const data = await backendApi.post<{ + success: boolean + message: string + deleted_count: number + failed_count: number + failed_ids: string[] + }>(`${API_BASE}/batch/delete`, { + body: { person_ids: personIds }, + errorMessage: '批量删除失败', }) - - if (!response.ok) { - try { - const errorData = await response.json() - return { - success: false, - error: errorData.detail || errorData.message || '批量删除失败', - } - } catch { - return { - success: false, - error: response.statusText || '批量删除失败', - } - } - } - - try { - const data = await response.json() - if (data.success) { - return { - success: true, - data: { - message: data.message, - deleted_count: data.deleted_count, - failed_count: data.failed_count, - failed_ids: data.failed_ids, - }, - } - } else { - return { - success: false, - error: data.message || '批量删除失败', - } - } - } catch { - return { - success: false, - error: 'Failed to parse response', - } + const checked = requireSuccess(data, '批量删除失败') + return { + message: checked.message, + deleted_count: checked.deleted_count, + failed_count: checked.failed_count, + failed_ids: checked.failed_ids, } } diff --git a/dashboard/src/lib/planner-api.ts b/dashboard/src/lib/planner-api.ts index 0da67f73d..5c653bcc8 100644 --- a/dashboard/src/lib/planner-api.ts +++ b/dashboard/src/lib/planner-api.ts @@ -1,4 +1,10 @@ -import { fetchWithAuth } from './fetch-with-auth' +/** + * 规划器 / 回复器监控 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint 与响应类型。请求失败时抛出 ApiError(throw 契约)。 + */ +import { backendApi } from '@/lib/http' // ========== 新的优化接口 ========== @@ -55,31 +61,32 @@ export interface PaginatedChatLogs { * 获取规划器总览 - 轻量级,只统计文件数量 */ export async function getPlannerOverview(): Promise { - const response = await fetchWithAuth('/api/planner/overview') - return response.json() + return backendApi.get('/api/planner/overview', { + errorMessage: '获取规划器总览失败', + }) } /** * 获取指定聊天的规划日志列表(分页) */ export async function getChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise { - const params = new URLSearchParams({ - page: page.toString(), - page_size: pageSize.toString() + return backendApi.get(`/api/planner/chat/${chatId}/logs`, { + query: { + page, + page_size: pageSize, + search: search || undefined, + }, + errorMessage: '获取规划日志列表失败', }) - if (search) { - params.append('search', search) - } - const response = await fetchWithAuth(`/api/planner/chat/${chatId}/logs?${params}`) - return response.json() } /** * 获取规划日志详情 - 按需加载 */ export async function getLogDetail(chatId: string, filename: string): Promise { - const response = await fetchWithAuth(`/api/planner/log/${chatId}/${filename}`) - return response.json() + return backendApi.get(`/api/planner/log/${chatId}/${filename}`, { + errorMessage: '获取规划日志详情失败', + }) } // ========== 兼容旧接口 ========== @@ -100,18 +107,22 @@ export interface PaginatedPlanLogs { } export async function getPlannerStats(): Promise { - const response = await fetchWithAuth('/api/planner/stats') - return response.json() + return backendApi.get('/api/planner/stats', { + errorMessage: '获取规划器统计失败', + }) } export async function getAllLogs(page = 1, pageSize = 20): Promise { - const response = await fetchWithAuth(`/api/planner/all-logs?page=${page}&page_size=${pageSize}`) - return response.json() + return backendApi.get('/api/planner/all-logs', { + query: { page, page_size: pageSize }, + errorMessage: '获取规划日志失败', + }) } export async function getChatList(): Promise { - const response = await fetchWithAuth('/api/planner/chats') - return response.json() + return backendApi.get('/api/planner/chats', { + errorMessage: '获取聊天列表失败', + }) } // ========== 回复器接口 ========== @@ -173,29 +184,30 @@ export interface PaginatedReplyLogs { * 获取回复器总览 - 轻量级,只统计文件数量 */ export async function getReplierOverview(): Promise { - const response = await fetchWithAuth('/api/replier/overview') - return response.json() + return backendApi.get('/api/replier/overview', { + errorMessage: '获取回复器总览失败', + }) } /** * 获取指定聊天的回复日志列表(分页) */ export async function getReplyChatLogs(chatId: string, page = 1, pageSize = 20, search?: string): Promise { - const params = new URLSearchParams({ - page: page.toString(), - page_size: pageSize.toString() + return backendApi.get(`/api/replier/chat/${chatId}/logs`, { + query: { + page, + page_size: pageSize, + search: search || undefined, + }, + errorMessage: '获取回复日志列表失败', }) - if (search) { - params.append('search', search) - } - const response = await fetchWithAuth(`/api/replier/chat/${chatId}/logs?${params}`) - return response.json() } /** * 获取回复日志详情 - 按需加载 */ export async function getReplyLogDetail(chatId: string, filename: string): Promise { - const response = await fetchWithAuth(`/api/replier/log/${chatId}/${filename}`) - return response.json() + return backendApi.get(`/api/replier/log/${chatId}/${filename}`, { + errorMessage: '获取回复日志详情失败', + }) } diff --git a/dashboard/src/lib/plugin-api/config.ts b/dashboard/src/lib/plugin-api/config.ts index 0b37de72c..19d793d1b 100644 --- a/dashboard/src/lib/plugin-api/config.ts +++ b/dashboard/src/lib/plugin-api/config.ts @@ -1,92 +1,66 @@ +/** + * 插件配置 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案与响应体 success 标记的解包规则。 + * 公开函数暂保持 ApiResponse 契约(经 toApiResponse 包装),待页面层统一切换 throw 契约后移除。 + */ +import { ApiError, backendApi, requireSuccess, toApiResponse } from '@/lib/http' import type { ApiResponse } from '@/types/api' -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' -import { parseResponse } from '@/lib/api-helpers' - import type { PluginConfigSchema } from './types' +const API_BASE = '/api/webui/plugins/config' + /** * 获取插件配置 Schema */ export async function getPluginConfigSchema(pluginId: string): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/schema`, { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; schema?: PluginConfigSchema; message?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.schema) { - return { - success: false, - error: result.message || '获取配置 Schema 失败' + return toApiResponse(async () => { + const data = await backendApi.get<{ success: boolean; schema?: PluginConfigSchema; message?: string }>( + `${API_BASE}/${pluginId}/schema`, + { errorMessage: '获取配置 Schema 失败' } + ) + const checked = requireSuccess(data, '获取配置 Schema 失败') + if (!checked.schema) { + throw new ApiError(checked.message || '获取配置 Schema 失败', { detail: checked }) } - } - - return { - success: true, - data: result.schema - } + return checked.schema + }) } /** * 获取插件当前配置值 */ export async function getPluginConfig(pluginId: string): Promise>> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; config?: Record; message?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.config) { - return { - success: false, - error: result.message || '获取配置失败' + return toApiResponse(async () => { + const data = await backendApi.get<{ success: boolean; config?: Record; message?: string }>( + `${API_BASE}/${pluginId}`, + { errorMessage: '获取配置失败' } + ) + const checked = requireSuccess(data, '获取配置失败') + if (!checked.config) { + throw new ApiError(checked.message || '获取配置失败', { detail: checked }) } - } - - return { - success: true, - data: result.config - } + return checked.config + }) } /** * 获取插件原始 TOML 配置 */ export async function getPluginConfigRaw(pluginId: string): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; config?: string; message?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.config) { - return { - success: false, - error: result.message || '获取配置失败' + return toApiResponse(async () => { + const data = await backendApi.get<{ success: boolean; config?: string; message?: string }>( + `${API_BASE}/${pluginId}/raw`, + { errorMessage: '获取配置失败' } + ) + const checked = requireSuccess(data, '获取配置失败') + if (!checked.config) { + throw new ApiError(checked.message || '获取配置失败', { detail: checked }) } - } - - return { - success: true, - data: result.config - } + return checked.config + }) } /** @@ -96,13 +70,12 @@ export async function updatePluginConfig( pluginId: string, config: Record ): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ config }) - }) - - return await parseResponse<{ success: boolean; message: string; note?: string }>(response) + return toApiResponse(() => + backendApi.put<{ success: boolean; message: string; note?: string }>(`${API_BASE}/${pluginId}`, { + body: { config }, + errorMessage: '更新插件配置失败', + }) + ) } /** @@ -112,13 +85,12 @@ export async function updatePluginConfigRaw( pluginId: string, configToml: string ): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/raw`, { - method: 'PUT', - headers: getAuthHeaders(), - body: JSON.stringify({ config: configToml }) - }) - - return await parseResponse<{ success: boolean; message: string; note?: string }>(response) + return toApiResponse(() => + backendApi.put<{ success: boolean; message: string; note?: string }>(`${API_BASE}/${pluginId}/raw`, { + body: { config: configToml }, + errorMessage: '更新插件配置失败', + }) + ) } /** @@ -127,12 +99,11 @@ export async function updatePluginConfigRaw( export async function resetPluginConfig( pluginId: string ): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/reset`, { - method: 'POST', - headers: getAuthHeaders() - }) - - return await parseResponse<{ success: boolean; message: string; backup?: string }>(response) + return toApiResponse(() => + backendApi.post<{ success: boolean; message: string; backup?: string }>(`${API_BASE}/${pluginId}/reset`, { + errorMessage: '重置插件配置失败', + }) + ) } /** @@ -141,10 +112,10 @@ export async function resetPluginConfig( export async function togglePlugin( pluginId: string ): Promise> { - const response = await fetchWithAuth(`/api/webui/plugins/config/${pluginId}/toggle`, { - method: 'POST', - headers: getAuthHeaders() - }) - - return await parseResponse<{ success: boolean; enabled: boolean; message: string; note?: string }>(response) + return toApiResponse(() => + backendApi.post<{ success: boolean; enabled: boolean; message: string; note?: string }>( + `${API_BASE}/${pluginId}/toggle`, + { errorMessage: '切换插件状态失败' } + ) + ) } diff --git a/dashboard/src/lib/plugin-api/install-flow.ts b/dashboard/src/lib/plugin-api/install-flow.ts index 2a875135d..382a5abbf 100644 --- a/dashboard/src/lib/plugin-api/install-flow.ts +++ b/dashboard/src/lib/plugin-api/install-flow.ts @@ -1,8 +1,12 @@ +/** + * 插件安装 / 卸载 / 更新流程 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint 与业务错误文案。 + */ +import { backendApi, toApiResponse } from '@/lib/http' import type { ApiResponse } from '@/types/api' -import { fetchWithAuth } from '@/lib/fetch-with-auth' -import { parseResponse } from '@/lib/api-helpers' - type UpdatePluginResult = { success: boolean message: string @@ -16,44 +20,44 @@ type UpdatePluginResult = { * 安装插件 */ export async function installPlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/install', { - method: 'POST', - body: JSON.stringify({ - plugin_id: pluginId, - repository_url: repositoryUrl, - branch: branch + return toApiResponse(() => + backendApi.post<{ success: boolean; message: string }>('/api/webui/plugins/install', { + body: { + plugin_id: pluginId, + repository_url: repositoryUrl, + branch: branch + }, + errorMessage: '安装插件失败', }) - }) - - return await parseResponse<{ success: boolean; message: string }>(response) + ) } /** * 卸载插件 */ export async function uninstallPlugin(pluginId: string): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/uninstall', { - method: 'POST', - body: JSON.stringify({ - plugin_id: pluginId + return toApiResponse(() => + backendApi.post<{ success: boolean; message: string }>('/api/webui/plugins/uninstall', { + body: { + plugin_id: pluginId + }, + errorMessage: '卸载插件失败', }) - }) - - return await parseResponse<{ success: boolean; message: string }>(response) + ) } /** * 更新插件 */ export async function updatePlugin(pluginId: string, repositoryUrl: string, branch: string = 'main'): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/update', { - method: 'POST', - body: JSON.stringify({ - plugin_id: pluginId, - repository_url: repositoryUrl, - branch: branch + return toApiResponse(() => + backendApi.post('/api/webui/plugins/update', { + body: { + plugin_id: pluginId, + repository_url: repositoryUrl, + branch: branch + }, + errorMessage: '更新插件失败', }) - }) - - return await parseResponse(response) + ) } diff --git a/dashboard/src/lib/plugin-api/installed.ts b/dashboard/src/lib/plugin-api/installed.ts index ebd96ba2e..3cbbe3b9f 100644 --- a/dashboard/src/lib/plugin-api/installed.ts +++ b/dashboard/src/lib/plugin-api/installed.ts @@ -1,8 +1,12 @@ +/** + * 已安装插件 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案与响应体 success 标记的解包规则。 + */ +import { ApiError, backendApi } from '@/lib/http' import type { ApiResponse } from '@/types/api' -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' -import { parseResponse } from '@/lib/api-helpers' - import type { InstalledPlugin, LegacyInstalledPlugin } from './types' const INSTALLED_PLUGINS_CACHE_TTL = 1500 @@ -12,32 +16,37 @@ let installedPluginsRequest: Promise> | null = nu /** * 获取已安装插件列表 + * + * 保持原有行为:HTTP 错误 / 响应解析失败 / 业务级失败都返回空列表而不是错误; + * 网络层失败与认证失效(401)仍向上抛出。 */ async function fetchInstalledPluginsUncached(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/installed', { - headers: getAuthHeaders() - }) - - const apiResult = await parseResponse<{ success: boolean; plugins?: InstalledPlugin[]; message?: string }>(response) - - if (!apiResult.success) { - return { - success: true, - data: [] + let data: { success: boolean; plugins?: InstalledPlugin[]; message?: string } + try { + data = await backendApi.get<{ success: boolean; plugins?: InstalledPlugin[]; message?: string }>( + '/api/webui/plugins/installed', + { errorMessage: '获取已安装插件列表失败' } + ) + } catch (error) { + if (error instanceof ApiError && error.status !== undefined && error.status !== 401) { + return { + success: true, + data: [] + } } + throw error } - - const result = apiResult.data - if (!result.success) { + + if (!data.success) { return { success: true, data: [] } } - + return { success: true, - data: result.plugins || [] + data: data.plugins || [] } } @@ -77,7 +86,7 @@ export function checkPluginInstalled(pluginId: string, installedPlugins: Install export function getInstalledPluginVersion(pluginId: string, installedPlugins: (InstalledPlugin | LegacyInstalledPlugin)[]): string | undefined { const plugin = installedPlugins.find(p => p.id === pluginId) if (!plugin) return undefined - + // 兼容两种格式:新格式有 manifest,旧格式直接有 version if ('manifest' in plugin && plugin.manifest) { return plugin.manifest.version diff --git a/dashboard/src/lib/plugin-api/marketplace.ts b/dashboard/src/lib/plugin-api/marketplace.ts index ea8ab8eb1..ad05402ac 100644 --- a/dashboard/src/lib/plugin-api/marketplace.ts +++ b/dashboard/src/lib/plugin-api/marketplace.ts @@ -1,8 +1,7 @@ import type { ApiResponse } from '@/types/api' import type { PluginInfo, PluginType } from '@/types/plugin' -import { fetchWithAuth } from '@/lib/fetch-with-auth' -import { parseResponse } from '@/lib/api-helpers' +import { ApiError, backendApi, toApiResponse } from '@/lib/http' import { pluginProgressClient } from '@/lib/plugin-progress-client' import type { GitStatus, MaimaiVersion } from './types' @@ -223,75 +222,68 @@ export function getCachedPluginList(): PluginInfo[] | null { * 从远程获取插件列表(通过后端代理避免 CORS) */ async function fetchPluginListUncached(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { - method: 'POST', - body: JSON.stringify({ - owner: PLUGIN_REPO_OWNER, - repo: PLUGIN_REPO_NAME, - branch: PLUGIN_REPO_BRANCH, - file_path: PLUGIN_DETAILS_FILE - }) - }) - - const apiResult = await parseResponse<{ success: boolean; data: string; error?: string }>(response) - - if (!apiResult.success) { - return apiResult - } - - const result = apiResult.data - if (!result.success || !result.data) { - return { - success: false, - error: result.error || '获取插件列表失败' - } - } - - const data: PluginApiResponse[] = JSON.parse(result.data) - - const pluginList = data - .filter(item => { - if (!item?.manifest) { - console.warn('跳过无效插件数据:', item) - return false - } - const pluginId = item.manifest.id || item.id - if (!pluginId) { - console.warn('跳过缺少 ID 的插件:', item) - return false - } - if (!item.manifest.name || !item.manifest.version) { - console.warn('跳过缺少必需字段的插件:', item.id) - return false + return toApiResponse(async () => { + const result = await backendApi.post<{ success: boolean; data: string; error?: string }>( + '/api/webui/plugins/fetch-raw', + { + body: { + owner: PLUGIN_REPO_OWNER, + repo: PLUGIN_REPO_NAME, + branch: PLUGIN_REPO_BRANCH, + file_path: PLUGIN_DETAILS_FILE + }, + errorMessage: '获取插件列表失败', } - return true - }) - .map((item, index) => { - const manifestId = item.manifest.id?.trim() - const marketplaceId = item.id?.trim() - const pluginId = manifestId || marketplaceId! + ) - return { - id: pluginId, - marketplace_id: marketplaceId, - marketplace_order: index, - stats_ids: uniqueNonEmptyValues([manifestId]), - manifest: normalizePluginManifest({ ...item.manifest, id: pluginId }), - assets: normalizePluginAssets(item.assets), - downloads: 0, - rating: 0, - review_count: 0, - installed: false, - source: 'market' as const, - published_at: normalizeDateString(item.published_at ?? item.created_at ?? item.added_at), - updated_at: normalizeDateString(item.updated_at ?? item.modified_at), - } - }) - - return { - success: true, - data: pluginList - } + // 业务级失败:该 endpoint 的错误字段是 error 而非 message,不走 requireSuccess + if (!result.success || !result.data) { + throw new ApiError(result.error || '获取插件列表失败', { detail: result }) + } + + const data: PluginApiResponse[] = JSON.parse(result.data) + + const pluginList = data + .filter(item => { + if (!item?.manifest) { + console.warn('跳过无效插件数据:', item) + return false + } + const pluginId = item.manifest.id || item.id + if (!pluginId) { + console.warn('跳过缺少 ID 的插件:', item) + return false + } + if (!item.manifest.name || !item.manifest.version) { + console.warn('跳过缺少必需字段的插件:', item.id) + return false + } + return true + }) + .map((item, index) => { + const manifestId = item.manifest.id?.trim() + const marketplaceId = item.id?.trim() + const pluginId = manifestId || marketplaceId! + + return { + id: pluginId, + marketplace_id: marketplaceId, + marketplace_order: index, + stats_ids: uniqueNonEmptyValues([manifestId]), + manifest: normalizePluginManifest({ ...item.manifest, id: pluginId }), + assets: normalizePluginAssets(item.assets), + downloads: 0, + rating: 0, + review_count: 0, + installed: false, + source: 'market' as const, + published_at: normalizeDateString(item.published_at ?? item.created_at ?? item.added_at), + updated_at: normalizeDateString(item.updated_at ?? item.modified_at), + } + }) + + return pluginList + }) } export async function fetchPluginList(options: { forceRefresh?: boolean } = {}): Promise> { @@ -333,44 +325,50 @@ export async function fetchPluginList(options: { forceRefresh?: boolean } = {}): * 检查本机 Git 安装状态 */ export async function checkGitStatus(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/git-status') - - const apiResult = await parseResponse(response) - - if (!apiResult.success) { - return { - success: true, - data: { - installed: false, - error: '无法检测 Git 安装状态' + try { + const data = await backendApi.get('/api/webui/plugins/git-status', { + errorMessage: '无法检测 Git 安装状态', + }) + return { success: true, data } + } catch (error) { + // 保持原有行为:HTTP 错误 / 响应解析失败时按“无法检测”处理;网络层失败与认证失效(401)仍向上抛出 + if (error instanceof ApiError && error.status !== undefined && error.status !== 401) { + return { + success: true, + data: { + installed: false, + error: '无法检测 Git 安装状态' + } } } + throw error } - - return apiResult } /** * 获取麦麦版本信息 */ export async function getMaimaiVersion(): Promise> { - const response = await fetchWithAuth('/api/webui/plugins/version') - - const apiResult = await parseResponse(response) - - if (!apiResult.success) { - return { - success: true, - data: { - version: '0.0.0', - version_major: 0, - version_minor: 0, - version_patch: 0 + try { + const data = await backendApi.get('/api/webui/plugins/version', { + errorMessage: '获取麦麦版本信息失败', + }) + return { success: true, data } + } catch (error) { + // 保持原有行为:HTTP 错误 / 响应解析失败时回退为 0.0.0;网络层失败与认证失效(401)仍向上抛出 + if (error instanceof ApiError && error.status !== undefined && error.status !== 401) { + return { + success: true, + data: { + version: '0.0.0', + version_major: 0, + version_minor: 0, + version_patch: 0 + } } } + throw error } - - return apiResult } /** diff --git a/dashboard/src/lib/plugin-stats.ts b/dashboard/src/lib/plugin-stats.ts index d16cadfda..dee43e729 100644 --- a/dashboard/src/lib/plugin-stats.ts +++ b/dashboard/src/lib/plugin-stats.ts @@ -4,7 +4,7 @@ */ // 闁板秶鐤嗙紒鐔活吀閺堝秴濮?API 閸︽澘娼冮敍鍫熷閺堝鏁ら幋宄板彙娴滎偆娈戞禍鎴狀伂缂佺喕顓搁張宥呭閿? -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { ApiError, backendApi } from '@/lib/http' const STATS_API_BASE_URL = '/api/webui/plugins/stats-proxy' const PLUGIN_STATS_SUMMARY_CACHE_TTL = 5 * 60 * 1000 @@ -122,6 +122,12 @@ function getReadableError(data: unknown, fallback: string): string { return /[�閹缂鐠]/.test(error) ? fallback : error } +/** 从 ApiError 携带的后端原始错误体中提取 error 字段 */ +function getDetailError(error: ApiError): string | undefined { + const detailError = (error.detail as { error?: unknown } | null | undefined)?.error + return typeof detailError === 'string' ? detailError : undefined +} + function readPluginStatsSummaryStorageCache(): PluginStatsSummaryStorageCache | null { if (typeof localStorage === 'undefined') { return null @@ -213,14 +219,10 @@ export function getCachedPluginStatsSummary(): Record | */ export async function getPluginStats(pluginId: string): Promise { try { - const response = await fetchWithAuth(`${STATS_API_BASE_URL}/stats/${encodeURIComponent(pluginId)}`) - - if (!response.ok) { - console.error('Failed to fetch plugin stats:', response.statusText) - return null - } - - return normalizePluginStatsResponse(await response.json(), pluginId) + const data = await backendApi.get( + `${STATS_API_BASE_URL}/stats/${encodeURIComponent(pluginId)}` + ) + return normalizePluginStatsResponse(data, pluginId) } catch (error) { console.error('Error fetching plugin stats:', error) return null @@ -231,14 +233,7 @@ export async function getPluginStats(pluginId: string): Promise> { try { - const response = await fetchWithAuth(`${STATS_API_BASE_URL}/stats/summary`) - - if (!response.ok) { - console.error('Failed to fetch plugin stats summary:', response.statusText) - return {} - } - - const data = await response.json() as PluginStatsSummaryResponse + const data = await backendApi.get(`${STATS_API_BASE_URL}/stats/summary`) if (!data.success || !data.stats || typeof data.stats !== 'object') { return {} } @@ -260,18 +255,12 @@ export async function getPluginUserState( userId: string = getUserId() ): Promise { try { - const queryParams = new URLSearchParams({ - plugin_id: pluginId, - user_id: userId, - }) - const response = await fetchWithAuth(`${STATS_API_BASE_URL}/stats/user-state?${queryParams}`) - - if (!response.ok) { - console.error('Failed to fetch plugin user state:', response.statusText) - return null - } - - const data = await response.json() as Partial & { success?: boolean } + const data = await backendApi.get & { success?: boolean }>( + `${STATS_API_BASE_URL}/stats/user-state`, + { + query: { plugin_id: pluginId, user_id: userId }, + } + ) if (data.success === false) { return null } @@ -329,25 +318,11 @@ export async function getPluginStatsSummary( export async function likePlugin(pluginId: string, userId?: string): Promise { try { const finalUserId = userId || getUserId() - - const response = await fetchWithAuth(`${STATS_API_BASE_URL}/stats/like`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }), + + const data = await backendApi.post>(`${STATS_API_BASE_URL}/stats/like`, { + body: { plugin_id: pluginId, user_id: finalUserId }, }) - - const data = await response.json() - - if (response.status === 429) { - return { success: false, error: '点赞过于频繁,请稍后再试' } - } - - if (!response.ok) { - return { success: false, error: getReadableError(data, '点赞失败') } - } - + const result: VoteStatsResponse = { success: true, ...data } updateCachedPluginStats(pluginId, { likes: Number(result.likes ?? 0), @@ -355,6 +330,14 @@ export async function likePlugin(pluginId: string, userId?: string): Promise { try { const finalUserId = userId || getUserId() - - const response = await fetchWithAuth(`${STATS_API_BASE_URL}/stats/dislike`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ plugin_id: pluginId, user_id: finalUserId }), + + const data = await backendApi.post>(`${STATS_API_BASE_URL}/stats/dislike`, { + body: { plugin_id: pluginId, user_id: finalUserId }, }) - - const data = await response.json() - - if (response.status === 429) { - return { success: false, error: '操作过于频繁,请稍后再试' } - } - - if (!response.ok) { - return { success: false, error: getReadableError(data, '点踩失败') } - } - + const result: VoteStatsResponse = { success: true, ...data } updateCachedPluginStats(pluginId, { likes: Number(result.likes ?? 0), @@ -392,6 +361,14 @@ export async function dislikePlugin(pluginId: string, userId?: string): Promise< }) return result } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + return { success: false, error: '操作过于频繁,请稍后再试' } + } + if (error.status !== undefined) { + return { success: false, error: getReadableError(error.detail, '点踩失败') } + } + } console.error('Error disliking plugin:', error) return { success: false, error: '网络请求失败' } } @@ -416,7 +393,7 @@ export async function ratePlugin( if (hasRating && (rating < 1 || rating > 5)) { return { success: false, error: '评分必须在 1-5 之间' } } - + try { const finalUserId = userId || getUserId() const payload: { @@ -432,25 +409,11 @@ export async function ratePlugin( if (hasComment) { payload.comment = comment } - - const response = await fetchWithAuth(`${STATS_API_BASE_URL}/stats/rate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), + + const data = await backendApi.post>(`${STATS_API_BASE_URL}/stats/rate`, { + body: payload, }) - - const data = await response.json() - - if (response.status === 429) { - return { success: false, error: '每天最多评分 3 次' } - } - - if (!response.ok) { - return { success: false, error: data.error || '鐠囧嫬鍨庢径杈Е' } - } - + const result: RatingStatsResponse = { success: true, ...data } const updatedStats: Partial = {} if (result.rating !== undefined) { @@ -462,6 +425,14 @@ export async function ratePlugin( updateCachedPluginStats(pluginId, updatedStats) return result } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + return { success: false, error: '每天最多评分 3 次' } + } + if (error.status !== undefined) { + return { success: false, error: getDetailError(error) || '鐠囧嫬鍨庢径杈Е' } + } + } console.error('Error rating plugin:', error) return { success: false, error: '缂冩垹绮堕柨娆掝嚖' } } @@ -474,33 +445,28 @@ export async function recordPluginDownload(pluginId: string): Promise>(`${STATS_API_BASE_URL}/stats/download`, { + body: { plugin_id: pluginId, user_id: userId, fingerprint }, }) - - const data = await response.json() - - if (response.status === 429) { - // 娑撳娴囩紒鐔活吀鐞氼偊妾哄ù浣规闂堟瑩绮径杈Е閿涘奔绗夎ぐ鍗炴惙閻劍鍩涙担鎾荤崣 - console.warn('Download recording rate limited') - return { success: true } - } - - if (!response.ok) { - console.error('Failed to record download:', data.error) - return { success: false, error: data.error } - } - + const result: DownloadStatsResponse = { success: true, ...data } if (typeof result.downloads === 'number') { updateCachedPluginStats(pluginId, { downloads: result.downloads }) } return result } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + // 娑撳娴囩紒鐔活吀鐞氼偊妾哄ù浣规闂堟瑩绮径杈Е閿涘奔绗夎ぐ鍗炴惙閻劍鍩涙担鎾荤崣 + console.warn('Download recording rate limited') + return { success: true } + } + if (error.status !== undefined) { + const detailError = getDetailError(error) + console.error('Failed to record download:', detailError) + return { success: false, error: detailError } + } + } console.error('Error recording download:', error) return { success: false, error: '缂冩垹绮堕柨娆掝嚖' } } diff --git a/dashboard/src/lib/prompt-api.ts b/dashboard/src/lib/prompt-api.ts index f93c0b322..cd947eb5b 100644 --- a/dashboard/src/lib/prompt-api.ts +++ b/dashboard/src/lib/prompt-api.ts @@ -1,5 +1,4 @@ -import { parseResponse } from '@/lib/api-helpers' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi, toApiResponse } from '@/lib/http' import type { ApiResponse } from '@/types/api' const API_BASE = '/api/webui/config/prompts' @@ -29,26 +28,39 @@ export interface PromptFileContent { } export async function getPromptCatalog(): Promise> { - const response = await fetchWithAuth(API_BASE) - return parseResponse(response) + return toApiResponse(() => + backendApi.get(API_BASE, { + errorMessage: '获取 Prompt 文件列表失败', + }) + ) } export async function getPromptFile( language: string, filename: string ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`) - return parseResponse(response) + return toApiResponse(() => + backendApi.get( + `${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, + { + errorMessage: '获取 Prompt 文件失败', + } + ) + ) } export async function getDefaultPromptFile( language: string, filename: string ): Promise> { - const response = await fetchWithAuth( - `${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}/default` + return toApiResponse(() => + backendApi.get( + `${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}/default`, + { + errorMessage: '获取默认 Prompt 文件失败', + } + ) ) - return parseResponse(response) } export async function updatePromptFile( @@ -56,19 +68,27 @@ export async function updatePromptFile( filename: string, content: string ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, { - method: 'PUT', - body: JSON.stringify({ content }), - }) - return parseResponse(response) + return toApiResponse(() => + backendApi.put( + `${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, + { + body: { content }, + errorMessage: '保存 Prompt 文件失败', + } + ) + ) } export async function resetPromptFile( language: string, filename: string ): Promise> { - const response = await fetchWithAuth(`${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, { - method: 'DELETE', - }) - return parseResponse(response) + return toApiResponse(() => + backendApi.delete( + `${API_BASE}/${encodeURIComponent(language)}/${encodeURIComponent(filename)}`, + { + errorMessage: '重置 Prompt 文件失败', + } + ) + ) } diff --git a/dashboard/src/lib/prompt-generator-api.ts b/dashboard/src/lib/prompt-generator-api.ts index bb2c4bd5e..2a2a33c27 100644 --- a/dashboard/src/lib/prompt-generator-api.ts +++ b/dashboard/src/lib/prompt-generator-api.ts @@ -1,5 +1,4 @@ -import { parseResponse } from '@/lib/api-helpers' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi, toApiResponse } from '@/lib/http' import type { ApiResponse } from '@/types/api' const PROMPT_GENERATOR_METHOD_NOT_ALLOWED_MESSAGE = @@ -74,11 +73,13 @@ export interface PromptGeneratorResponse { export async function generatePromptPersona( payload: PromptGeneratorRequest ): Promise> { - const response = await fetchWithAuth('/api/webui/config/prompt-generator/generate', { - method: 'POST', - body: JSON.stringify(payload), - }) - return normalizePromptGeneratorError(await parseResponse(response)) + const result = await toApiResponse(() => + backendApi.post('/api/webui/config/prompt-generator/generate', { + body: payload, + errorMessage: '生成人设 Prompt 失败', + }) + ) + return normalizePromptGeneratorError(result) } export interface PromptGeneratorApplyResponse { @@ -91,9 +92,10 @@ export interface PromptGeneratorApplyResponse { export async function applyPromptGeneratorBlocks( blocks: PromptGeneratorConfigBlock[] ): Promise> { - const response = await fetchWithAuth('/api/webui/config/prompt-generator/apply', { - method: 'POST', - body: JSON.stringify({ blocks }), - }) - return parseResponse(response) + return toApiResponse(() => + backendApi.post('/api/webui/config/prompt-generator/apply', { + body: { blocks }, + errorMessage: '应用生成结果失败', + }) + ) } diff --git a/dashboard/src/lib/query.ts b/dashboard/src/lib/query.ts new file mode 100644 index 000000000..c58efcdd1 --- /dev/null +++ b/dashboard/src/lib/query.ts @@ -0,0 +1,55 @@ +/** + * 全局 QueryClient 配置(服务端状态的统一接缝)。 + * + * 约定(架构评审敲定): + * - 查询(读)失败不弹全局 toast,由页面用 error 状态做局部呈现; + * - 变更(写)失败默认弹全局 toast(对用户动作的反馈),单个 mutation 可用 + * meta: { suppressErrorToast: true } 关闭后自行处理,meta.errorTitle 可定制标题; + * - 不自动重试:错误应当及时完整暴露,而不是被重试拖延掩盖; + * - 窗口聚焦不自动重新拉取:管理后台多为编辑场景,防止意外刷新; + * - queryKey 以领域名开头分层(如 ['persons', 'list', 参数]),便于按前缀整体失效。 + */ +import { MutationCache, QueryClient } from '@tanstack/react-query' + +import { toast } from '@/hooks/use-toast' + +declare module '@tanstack/react-query' { + interface Register { + mutationMeta: { + /** 设为 true 时跳过全局错误 toast,由调用方自行处理错误 */ + suppressErrorToast?: boolean + /** 全局错误 toast 的标题,默认「操作失败」 */ + errorTitle?: string + } + } +} + +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: 30_000, + }, + mutations: { + retry: false, + }, + }, + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + if (mutation.meta?.suppressErrorToast) { + return + } + toast({ + title: mutation.meta?.errorTitle ?? '操作失败', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }) + }, + }), + }) +} + +/** 应用级单例,在 main.tsx 经 QueryClientProvider 注入 */ +export const queryClient = createQueryClient() diff --git a/dashboard/src/lib/reasoning-process-api.ts b/dashboard/src/lib/reasoning-process-api.ts index 614e6474d..ec47dbc4c 100644 --- a/dashboard/src/lib/reasoning-process-api.ts +++ b/dashboard/src/lib/reasoning-process-api.ts @@ -1,6 +1,5 @@ -import { parseResponse, throwIfError } from '@/lib/api-helpers' import { resolveApiPath } from '@/lib/api-base' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' const API_BASE = '/api/webui/reasoning-process' @@ -79,29 +78,34 @@ export type ReasoningPromptListParams = { export async function listReasoningPromptFiles( params: ReasoningPromptListParams ): Promise { - const queryParams = new URLSearchParams() - queryParams.set('stage', params.stage ?? 'planner') - queryParams.set('session', params.session ?? 'auto') - queryParams.set('search', params.search ?? '') - queryParams.set('page', String(params.page ?? 1)) - queryParams.set('page_size', String(params.pageSize ?? 50)) - - const response = await fetchWithAuth(`${API_BASE}/files?${queryParams}`, { cache: 'no-store' }) - return throwIfError(await parseResponse(response)) + return backendApi.get(`${API_BASE}/files`, { + query: { + stage: params.stage ?? 'planner', + session: params.session ?? 'auto', + search: params.search ?? '', + page: params.page ?? 1, + page_size: params.pageSize ?? 50, + }, + cache: 'no-store', + errorMessage: '加载推理过程失败', + }) } export async function listReasoningPromptStages(): Promise { - const response = await fetchWithAuth(`${API_BASE}/stages`, { cache: 'no-store' }) - return throwIfError(await parseResponse(response)) + return backendApi.get(`${API_BASE}/stages`, { + cache: 'no-store', + errorMessage: '加载推理过程类型失败', + }) } export async function getReasoningPromptFile( path: string ): Promise { - const response = await fetchWithAuth(`${API_BASE}/file?path=${encodeURIComponent(path)}`, { + return backendApi.get(`${API_BASE}/file`, { + query: { path }, cache: 'no-store', + errorMessage: '读取推理过程文件失败', }) - return throwIfError(await parseResponse(response)) } export async function getReasoningPromptHtmlUrl(path: string): Promise { diff --git a/dashboard/src/lib/survey-api.ts b/dashboard/src/lib/survey-api.ts index 111f548fd..1a3202997 100644 --- a/dashboard/src/lib/survey-api.ts +++ b/dashboard/src/lib/survey-api.ts @@ -1,28 +1,28 @@ /** * 问卷调查 API 客户端 * 用于与 Cloudflare Workers 问卷服务交互 + * + * 请求样板(解析、错误格式化)由 @/lib/http 的 statsApi 实例承担(外部统计服务,不携带凭据); + * 本文件只声明 endpoint、业务错误文案与按状态码(429 / 409)区分的提示语。 */ - -import type { - SurveySubmission, - StoredSubmission, +import { ApiError, statsApi } from '@/lib/http' +import type { + QuestionAnswer, + StoredSubmission, SurveyStats, - SurveySubmitResponse, SurveyStatsResponse, + SurveySubmission, + SurveySubmitResponse, UserSubmissionsResponse, - QuestionAnswer } from '@/types/survey' -// 配置统计服务 API 地址 -const STATS_API_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev' - /** * 生成或获取用户ID */ export function getUserId(): string { const storageKey = 'maibot_user_id' let userId = localStorage.getItem(storageKey) - + if (!userId) { // 生成新的用户ID: fp_{fingerprint}_{timestamp}_{random} const fingerprint = Math.random().toString(36).substring(2, 10) @@ -31,10 +31,16 @@ export function getUserId(): string { userId = `fp_${fingerprint}_${timestamp}_${random}` localStorage.setItem(storageKey, userId) } - + return userId } +/** 从 ApiError 携带的后端原始错误体中提取 error 字段 */ +function getDetailError(error: ApiError): string | undefined { + const detailError = (error.detail as { error?: unknown } | null | undefined)?.error + return typeof detailError === 'string' ? detailError : undefined +} + /** * 提交问卷 */ @@ -49,7 +55,7 @@ export async function submitSurvey( ): Promise { try { const userId = options?.userId || getUserId() - + const submission: SurveySubmission & { allowMultiple?: boolean } = { surveyId, surveyVersion, @@ -62,35 +68,32 @@ export async function submitSurvey( language: navigator.language } } - - const response = await fetch(`${STATS_API_BASE_URL}/survey/submit`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(submission), - }) - - const data = await response.json() - - if (response.status === 429) { - return { success: false, error: '提交过于频繁,请稍后再试' } - } - - if (response.status === 409) { - return { success: false, error: data.error || '你已经提交过这份问卷了' } - } - - if (!response.ok) { - return { success: false, error: data.error || '提交失败' } - } - - return { - success: true, + + const data = await statsApi.post<{ submissionId?: string; message?: string }>( + '/survey/submit', + { + body: submission, + errorMessage: '提交失败', + } + ) + + return { + success: true, submissionId: data.submissionId, - message: data.message + message: data.message } } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + return { success: false, error: '提交过于频繁,请稍后再试' } + } + if (error.status === 409) { + return { success: false, error: getDetailError(error) || '你已经提交过这份问卷了' } + } + if (error.status !== undefined) { + return { success: false, error: error.message } + } + } console.error('Error submitting survey:', error) return { success: false, error: '网络错误' } } @@ -101,16 +104,14 @@ export async function submitSurvey( */ export async function getSurveyStats(surveyId: string): Promise { try { - const response = await fetch(`${STATS_API_BASE_URL}/survey/stats/${surveyId}`) - - if (!response.ok) { - const data = await response.json() - return { success: false, error: data.error || '获取统计数据失败' } - } - - const data = await response.json() - return { success: true, stats: data.stats as SurveyStats } + const data = await statsApi.get<{ stats: SurveyStats }>(`/survey/stats/${surveyId}`, { + errorMessage: '获取统计数据失败', + }) + return { success: true, stats: data.stats } } catch (error) { + if (error instanceof ApiError && error.status !== undefined) { + return { success: false, error: error.message } + } console.error('Error fetching survey stats:', error) return { success: false, error: '网络错误' } } @@ -125,22 +126,19 @@ export async function getUserSubmissions( ): Promise { try { const finalUserId = userId || getUserId() - const params = new URLSearchParams({ user_id: finalUserId }) - - if (surveyId) { - params.append('survey_id', surveyId) - } - - const response = await fetch(`${STATS_API_BASE_URL}/survey/submissions?${params}`) - - if (!response.ok) { - const data = await response.json() - return { success: false, error: data.error || '获取提交记录失败' } - } - - const data = await response.json() - return { success: true, submissions: data.submissions as StoredSubmission[] } + + const data = await statsApi.get<{ submissions: StoredSubmission[] }>('/survey/submissions', { + query: { + user_id: finalUserId, + survey_id: surveyId || undefined, + }, + errorMessage: '获取提交记录失败', + }) + return { success: true, submissions: data.submissions } } catch (error) { + if (error instanceof ApiError && error.status !== undefined) { + return { success: false, error: error.message } + } console.error('Error fetching user submissions:', error) return { success: false, error: '网络错误' } } @@ -155,21 +153,19 @@ export async function checkUserSubmission( ): Promise<{ success: boolean; hasSubmitted?: boolean; error?: string }> { try { const finalUserId = userId || getUserId() - const params = new URLSearchParams({ - user_id: finalUserId, - survey_id: surveyId + + const data = await statsApi.get<{ hasSubmitted?: boolean }>('/survey/check', { + query: { + user_id: finalUserId, + survey_id: surveyId, + }, + errorMessage: '检查失败', }) - - const response = await fetch(`${STATS_API_BASE_URL}/survey/check?${params}`) - - if (!response.ok) { - const data = await response.json() - return { success: false, error: data.error || '检查失败' } - } - - const data = await response.json() return { success: true, hasSubmitted: data.hasSubmitted } } catch (error) { + if (error instanceof ApiError && error.status !== undefined) { + return { success: false, error: error.message } + } console.error('Error checking submission:', error) return { success: false, error: '网络错误' } } diff --git a/dashboard/src/lib/system-api.ts b/dashboard/src/lib/system-api.ts index 2c717a359..0bb7bcd3e 100644 --- a/dashboard/src/lib/system-api.ts +++ b/dashboard/src/lib/system-api.ts @@ -1,24 +1,20 @@ -import { fetchWithAuth, getAuthHeaders } from './fetch-with-auth' - /** * 系统控制 API + * + * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担; + * 本文件只声明 endpoint、业务错误文案与响应类型。 + * 公开函数保持 throw 契约:失败时抛出 ApiError,由调用方自行 catch + * (例如重启期间后端短暂不可达导致的预期失败)。 */ +import { backendApi } from '@/lib/http' /** * 重启麦麦主程序 */ export async function restartMaiBot(): Promise<{ success: boolean; message: string }> { - const response = await fetchWithAuth('/api/webui/system/restart', { - method: 'POST', - headers: getAuthHeaders(), + return backendApi.post<{ success: boolean; message: string }>('/api/webui/system/restart', { + errorMessage: '重启失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '重启失败') - } - - return await response.json() } /** @@ -30,17 +26,14 @@ export async function getMaiBotStatus(): Promise<{ version: string start_time: string }> { - const response = await fetchWithAuth('/api/webui/system/status', { - method: 'GET', - headers: getAuthHeaders(), + return backendApi.get<{ + running: boolean + uptime: number + version: string + start_time: string + }>('/api/webui/system/status', { + errorMessage: '获取状态失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取状态失败') - } - - return await response.json() } export interface CacheDirectoryStats { @@ -199,82 +192,41 @@ export function getLocalCacheImagePreviewUrl(target: LocalCacheImageTarget, rela } export async function getLocalCacheStats(): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache', { - method: 'GET', - headers: getAuthHeaders(), + return backendApi.get('/api/webui/system/local-cache', { + errorMessage: '获取本地缓存统计失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取本地缓存统计失败') - } - - return await response.json() } export async function getLocalCacheDatabaseStats(): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/database', { - method: 'GET', - headers: getAuthHeaders(), + return backendApi.get('/api/webui/system/local-cache/database', { + errorMessage: '获取数据库统计失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取数据库统计失败') - } - - return await response.json() } export async function vacuumLocalCacheDatabase(): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/database/vacuum', { - method: 'POST', - headers: getAuthHeaders(), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '数据库 VACUUM 失败') - } - - return await response.json() + return backendApi.post( + '/api/webui/system/local-cache/database/vacuum', + { + errorMessage: '数据库 VACUUM 失败', + } + ) } export async function getLocalCacheDataEntries(relativePath = ''): Promise { - const query = new URLSearchParams() - if (relativePath) { - query.set('relative_path', relativePath) - } - - const response = await fetchWithAuth(`/api/webui/system/local-cache/data-entries?${query.toString()}`, { - method: 'GET', - headers: getAuthHeaders(), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取 data 目录失败') - } - - return await response.json() + return backendApi.get( + '/api/webui/system/local-cache/data-entries', + { + query: { relative_path: relativePath || undefined }, + errorMessage: '获取 data 目录失败', + } + ) } export async function deleteLocalCacheDataEntry(relativePath: string): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/data-entries', { - method: 'DELETE', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ relative_path: relativePath }), + return backendApi.delete('/api/webui/system/local-cache/data-entries', { + body: { relative_path: relativePath }, + errorMessage: '删除 data 条目失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '删除 data 条目失败') - } - - return await response.json() } export async function cleanupLocalCache( @@ -286,27 +238,16 @@ export async function cleanupLocalCache( vacuum_after_cleanup?: boolean } = {} ): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/cleanup', { - method: 'POST', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + return backendApi.post('/api/webui/system/local-cache/cleanup', { + body: { target, tables, database_mode: options.database_mode ?? 'all', older_than_days: options.older_than_days ?? null, vacuum_after_cleanup: options.vacuum_after_cleanup ?? true, - }), + }, + errorMessage: '清理本地缓存失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '清理本地缓存失败') - } - - return await response.json() } export async function getLocalCacheImages(params: { @@ -316,50 +257,26 @@ export async function getLocalCacheImages(params: { start_date?: string end_date?: string }): Promise { - const query = new URLSearchParams({ - target: params.target, - page: (params.page ?? 1).toString(), - page_size: (params.page_size ?? 40).toString(), - }) - if (params.start_date) { - query.set('start_date', params.start_date) - } - if (params.end_date) { - query.set('end_date', params.end_date) - } - - const response = await fetchWithAuth(`/api/webui/system/local-cache/images?${query.toString()}`, { - method: 'GET', - headers: getAuthHeaders(), + return backendApi.get('/api/webui/system/local-cache/images', { + query: { + target: params.target, + page: params.page ?? 1, + page_size: params.page_size ?? 40, + start_date: params.start_date || undefined, + end_date: params.end_date || undefined, + }, + errorMessage: '获取本地缓存图片列表失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取本地缓存图片列表失败') - } - - return await response.json() } export async function deleteLocalCacheImage( target: LocalCacheImageTarget, relativePath: string ): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/images', { - method: 'DELETE', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ target, relative_path: relativePath }), + return backendApi.delete('/api/webui/system/local-cache/images', { + body: { target, relative_path: relativePath }, + errorMessage: '删除本地缓存图片失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '删除本地缓存图片失败') - } - - return await response.json() } export async function deleteLocalCacheImagesByDateRange( @@ -367,81 +284,46 @@ export async function deleteLocalCacheImagesByDateRange( startDate: string, endDate: string ): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/images/bulk', { - method: 'DELETE', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + return backendApi.delete('/api/webui/system/local-cache/images/bulk', { + body: { target, mode: 'date_range', start_date: startDate || null, end_date: endDate || null, - }), + }, + errorMessage: '按日期删除缓存失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '按日期删除缓存失败') - } - - return await response.json() } export async function deleteLocalCacheImagesOlderThanRecentDays( target: LocalCacheImageTarget, keepRecentDays: 1 | 7 | 30 ): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/images/bulk', { - method: 'DELETE', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ + return backendApi.delete('/api/webui/system/local-cache/images/bulk', { + body: { target, mode: 'older_than_recent_days', keep_recent_days: keepRecentDays, - }), + }, + errorMessage: '清理过期缓存失败', }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '清理过期缓存失败') - } - - return await response.json() } export async function getLocalCacheLogDirectories(): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/log-directories', { - method: 'GET', - headers: getAuthHeaders(), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '获取日志目录列表失败') - } - - return await response.json() + return backendApi.get( + '/api/webui/system/local-cache/log-directories', + { + errorMessage: '获取日志目录列表失败', + } + ) } export async function deleteLocalCacheLogDirectory(relativePath: string): Promise { - const response = await fetchWithAuth('/api/webui/system/local-cache/log-directories', { - method: 'DELETE', - headers: { - ...getAuthHeaders(), - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ relative_path: relativePath }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.detail || '清理日志目录失败') - } - - return await response.json() + return backendApi.delete( + '/api/webui/system/local-cache/log-directories', + { + body: { relative_path: relativePath }, + errorMessage: '清理日志目录失败', + } + ) } diff --git a/dashboard/src/lib/unified-ws.ts b/dashboard/src/lib/unified-ws.ts index 6438288d5..4d938288c 100644 --- a/dashboard/src/lib/unified-ws.ts +++ b/dashboard/src/lib/unified-ws.ts @@ -1,7 +1,7 @@ -import { fetchWithAuth } from './fetch-with-auth' import { getSetting } from './settings-manager' import { getWsBaseUrl } from '@/lib/api-base' +import { backendApi } from '@/lib/http' export type ConnectionStatus = 'idle' | 'connecting' | 'connected' @@ -61,20 +61,10 @@ function isEventEnvelope(message: WsServerEnvelope): message is WsEventEnvelope async function getWsToken(): Promise { try { - const response = await fetchWithAuth('/api/webui/ws-token', { - method: 'GET', - credentials: 'include', - }) - - if (!response.ok) { - return null - } - - const data = await response.json() + const data = await backendApi.get<{ success?: boolean; token?: string }>('/api/webui/ws-token') if (data.success && data.token) { - return data.token as string + return data.token } - return null } catch (error) { console.error('获取统一 WebSocket token 失败:', error) diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 8ffbb59ee..cc127c3d4 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode, useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClientProvider } from '@tanstack/react-query' import { RouterProvider } from '@tanstack/react-router' import './index.css' @@ -13,6 +14,7 @@ import { ErrorBoundary } from './components/error-boundary' import { BackendSetupWizard } from './components/electron/BackendSetupWizard' import { Toaster } from './components/ui/toaster' import { isElectron } from './lib/runtime' +import { queryClient } from './lib/query' import { router } from './router' function ElectronShell() { @@ -28,6 +30,7 @@ function ElectronShell() { createRoot(document.getElementById('root')!).render( + @@ -42,6 +45,7 @@ createRoot(document.getElementById('root')!).render( + ) diff --git a/dashboard/src/routes/auth.tsx b/dashboard/src/routes/auth.tsx index ba05a6f3b..88654c172 100644 --- a/dashboard/src/routes/auth.tsx +++ b/dashboard/src/routes/auth.tsx @@ -48,8 +48,8 @@ import { useTheme } from '@/components/use-theme' import { useAnimation } from '@/hooks/use-animation' -import { parseResponse } from '@/lib/api-helpers' -import { checkAuthStatus } from '@/lib/fetch-with-auth' +import { checkAuthStatus } from '@/lib/auth' +import { authApi } from '@/lib/http' import { cn } from '@/lib/utils' import { APP_FULL_NAME } from '@/lib/version' @@ -144,29 +144,16 @@ export function AuthPage() { setIsValidating(true) try { - // 向后端发送请求验证 token(后端会设置 HttpOnly Cookie) - const response = await fetch('/api/webui/auth/verify', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - credentials: 'include', // 确保接收并存储 Cookie - body: JSON.stringify({ token: trimmed }), - }) - - const result = await parseResponse<{ + // 向后端发送请求验证 token(后端会设置 HttpOnly Cookie)。 + // 走 authApi:401(token 错误)透传后端信息,不触发整页跳转。 + const data = await authApi.post<{ valid: boolean is_first_setup?: boolean message?: string - }>(response) - - if (!result.success) { - console.error('Token 验证失败:', result.error) - setError(result.error) - return false - } - - const data = result.data + }>('/api/webui/auth/verify', { + body: { token: trimmed }, + errorMessage: t('auth.verifyFailed'), + }) if (data.valid) { // Token 验证成功,Cookie 已由后端设置 diff --git a/dashboard/src/routes/chat/index.tsx b/dashboard/src/routes/chat/index.tsx index 6e964db17..17713499d 100644 --- a/dashboard/src/routes/chat/index.tsx +++ b/dashboard/src/routes/chat/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { useToast } from '@/hooks/use-toast' import { chatWsClient } from '@/lib/chat-ws-client' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { ApiError, backendApi } from '@/lib/http' import { ChatComposer } from './ChatComposer' import { ChatHeaderBar } from './ChatHeaderBar' @@ -189,36 +189,33 @@ export function ChatPage() { const fetchPlatforms = useCallback(async () => { setIsLoadingPlatforms(true) try { - const response = await fetchWithAuth('/api/chat/platforms') - if (response.ok) { - const contentType = response.headers.get('content-type') - if (contentType && contentType.includes('application/json')) { - const data = await response.json() - setPlatforms(data.platforms || []) - } else { - const text = await response.text() - console.error('[Chat] 获取平台列表失败: 非 JSON 响应:', text.substring(0, 200)) - toast({ - title: t('chat.toast.connectionFailed'), - description: t('chat.toast.backendUnavailable'), - variant: 'destructive', - }) - } - } else { - console.error('[Chat] 获取平台列表失败: HTTP', response.status) + const data = await backendApi.get<{ platforms?: PlatformInfo[] }>('/api/chat/platforms') + setPlatforms(data.platforms || []) + } catch (e) { + if (e instanceof ApiError && e.status !== undefined && (e.status < 200 || e.status >= 300)) { + // HTTP 层失败 + console.error('[Chat] 获取平台列表失败: HTTP', e.status) toast({ title: t('chat.toast.platformFailed'), - description: t('chat.toast.serverError', { status: response.status }), + description: t('chat.toast.serverError', { status: e.status }), + variant: 'destructive', + }) + } else if (e instanceof ApiError && e.status !== undefined) { + // HTTP 成功但响应不是合法 JSON(后端不可用,命中了前端页面等) + console.error('[Chat] 获取平台列表失败: 非 JSON 响应:', e.message) + toast({ + title: t('chat.toast.connectionFailed'), + description: t('chat.toast.backendUnavailable'), + variant: 'destructive', + }) + } else { + console.error('[Chat] 获取平台列表失败:', e) + toast({ + title: t('chat.toast.networkError'), + description: t('chat.toast.backendUnavailableShort'), variant: 'destructive', }) } - } catch (e) { - console.error('[Chat] 获取平台列表失败:', e) - toast({ - title: t('chat.toast.networkError'), - description: t('chat.toast.backendUnavailableShort'), - variant: 'destructive', - }) } finally { setIsLoadingPlatforms(false) } @@ -228,21 +225,14 @@ export function ChatPage() { const fetchPersons = useCallback(async (platform: string, search?: string) => { setIsLoadingPersons(true) try { - const params = new URLSearchParams() - if (platform) params.append('platform', platform) - if (search) params.append('search', search) - params.append('limit', '50') - - const response = await fetchWithAuth(`/api/chat/persons?${params.toString()}`) - if (response.ok) { - const contentType = response.headers.get('content-type') - if (contentType && contentType.includes('application/json')) { - const data = await response.json() - setPersons(data.persons || []) - } else { - console.error('[Chat] 获取用户列表失败: 后端返回非 JSON 响应') - } - } + const data = await backendApi.get<{ persons?: PersonInfo[] }>('/api/chat/persons', { + query: { + platform: platform || undefined, + search: search || undefined, + limit: 50, + }, + }) + setPersons(data.persons || []) } catch (e) { console.error('[Chat] 获取用户列表失败:', e) } finally { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index 14c07541f..7a489902b 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -40,7 +40,6 @@ import { XAxis, YAxis, } from 'recharts' -import axios from 'axios' import { ExpressionReviewer } from '@/components/expression-reviewer' import { RestartOverlay } from '@/components/restart-overlay' @@ -74,7 +73,7 @@ import { ThinkingIllustration } from '@/components/ui/thinking-illustration' import { ZoomableChart } from '@/components/ui/zoomable-chart' import { getBotConfigCached, getModelConfigCached } from '@/lib/config-api' import { getReviewStats } from '@/lib/expression-api' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import { getInstalledPlugins, getPluginConfigSchema, @@ -574,11 +573,15 @@ function IndexPageContent() { const fetchHitokoto = useCallback(async () => { try { setHitokotoLoading(true) - const response = await axios.get('https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=h&c=i&c=k') + const response = await fetch('https://v1.hitokoto.cn/?c=a&c=b&c=c&c=d&c=h&c=i&c=k') + if (!response.ok) { + throw new Error(`一言接口返回 HTTP ${response.status}`) + } + const data = await response.json() if (isMountedRef.current) { setHitokoto({ - hitokoto: response.data.hitokoto, - from: response.data.from || response.data.from_who || t('home.unknownSource') + hitokoto: data.hitokoto, + from: data.from || data.from_who || t('home.unknownSource') }) } } catch (error) { @@ -607,15 +610,10 @@ function IndexPageContent() { setIsBotStatusLoading(true) try { - const response = await fetchWithAuth('/api/webui/system/status') + const data = await backendApi.get('/api/webui/system/status') if (!isMountedRef.current) return - if (response.ok) { - const data = await response.json() - botStatusCache = { timestamp: Date.now(), data } - setBotStatus(data) - } else if (!botStatusCache) { - setBotStatus(null) - } + botStatusCache = { timestamp: Date.now(), data } + setBotStatus(data) } catch (error) { console.error('获取机器人状态失败:', error) if (isMountedRef.current && !botStatusCache) { @@ -962,13 +960,12 @@ function IndexPageContent() { } else { setLoading(true) } - const response = await fetchWithAuth(`/api/webui/statistics/dashboard?hours=${timeRange}`) + const data = await backendApi.get('/api/webui/statistics/dashboard', { + query: { hours: timeRange }, + }) if (!isMountedRef.current) return - if (response.ok) { - const data = await response.json() - dashboardDataCache.set(timeRange, { timestamp: Date.now(), data }) - setDashboardData(data) - } + dashboardDataCache.set(timeRange, { timestamp: Date.now(), data }) + setDashboardData(data) setLoading(false) setLoadingProgress(100) } catch (error) { diff --git a/dashboard/src/routes/person.tsx b/dashboard/src/routes/person.tsx index c7123f828..167197f9a 100644 --- a/dashboard/src/routes/person.tsx +++ b/dashboard/src/routes/person.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ChevronLeft, ChevronRight, @@ -73,9 +74,6 @@ import { cn } from '@/lib/utils' import type { PersonInfo, PersonUpdateRequest } from '@/types/person' export function PersonManagementPage() { - const [persons, setPersons] = useState([]) - const [loading, setLoading] = useState(true) - const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) const [search, setSearch] = useState('') @@ -85,66 +83,44 @@ export function PersonManagementPage() { const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const [deleteConfirmPerson, setDeleteConfirmPerson] = useState(null) - const [stats, setStats] = useState({ total: 0, known: 0, unknown: 0, platforms: {} as Record }) const [selectedPersons, setSelectedPersons] = useState>(new Set()) const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false) const [jumpToPage, setJumpToPage] = useState('') const { toast } = useToast() + const queryClient = useQueryClient() - // 加载人物列表 - const loadPersons = async () => { - try { - setLoading(true) - const result = await getPersonList({ + // 人物列表:查询参数即缓存键,翻页/搜索/筛选变化自动重新拉取 + const personListQuery = useQuery({ + queryKey: ['persons', 'list', { page, pageSize, search, filterKnown, filterPlatform }], + queryFn: () => + getPersonList({ page, page_size: pageSize, search: search || undefined, is_known: filterKnown, platform: filterPlatform, - }) - if (!result.success) { - throw new Error(result.error) - } - setPersons(result.data.data) - setTotal(result.data.total) - } catch (error) { - toast({ - title: '加载失败', - description: error instanceof Error ? error.message : '无法加载人物信息', - variant: 'destructive', - }) - } finally { - setLoading(false) - } - } - - // 加载统计数据 - const loadStats = async () => { - try { - const result = await getPersonStats() - if (result.success) { - setStats(result.data) - } - } catch (error) { - console.error('加载统计数据失败:', error) - } - } - - // 初始加载 - useEffect(() => { - loadPersons() - loadStats() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, pageSize, search, filterKnown, filterPlatform]) - - // 查看详情 + }), + }) + const persons = personListQuery.data?.data ?? [] + const total = personListQuery.data?.total ?? 0 + const loading = personListQuery.isPending + + // 统计卡片:失败时保持占位数值,不打断页面 + const statsQuery = useQuery({ + queryKey: ['persons', 'stats'], + queryFn: getPersonStats, + }) + const stats = + statsQuery.data ?? { total: 0, known: 0, unknown: 0, platforms: {} as Record } + + // 任何写操作成功后,按 'persons' 前缀整体失效(列表 + 统计) + const invalidatePersons = () => queryClient.invalidateQueries({ queryKey: ['persons'] }) + + // 查看详情(事件驱动的读取,失败用 toast 反馈用户动作) const handleViewDetail = async (person: PersonInfo) => { try { - const result = await getPersonDetail(person.person_id) - if (!result.success) { - throw new Error(result.error) - } - setSelectedPerson(result.data) + const detail = await getPersonDetail(person.person_id) + setSelectedPerson(detail) setIsDetailDialogOpen(true) } catch (error) { toast({ @@ -161,28 +137,19 @@ export function PersonManagementPage() { setIsEditDialogOpen(true) } - // 删除人物 - const handleDelete = async (person: PersonInfo) => { - try { - const result = await deletePerson(person.person_id) - if (!result.success) { - throw new Error(result.error) - } + // 删除人物(失败由全局 mutation 错误 toast 呈现) + const deleteMutation = useMutation({ + mutationFn: (person: PersonInfo) => deletePerson(person.person_id), + meta: { errorTitle: '删除失败' }, + onSuccess: (_data, person) => { toast({ title: '删除成功', description: `已删除人物信息: ${person.person_name || person.nickname || person.user_id}`, }) setDeleteConfirmPerson(null) - loadPersons() - loadStats() - } catch (error) { - toast({ - title: '删除失败', - description: error instanceof Error ? error.message : '无法删除人物信息', - variant: 'destructive', - }) - } - } + invalidatePersons() + }, + }) // 获取平台列表 const platforms = useMemo(() => { @@ -222,29 +189,20 @@ export function PersonManagementPage() { setBatchDeleteDialogOpen(true) } - // 批量删除确认 - const handleBatchDelete = async () => { - try { - const result = await batchDeletePersons(Array.from(selectedPersons)) - if (!result.success) { - throw new Error(result.error) - } + // 批量删除(失败由全局 mutation 错误 toast 呈现) + const batchDeleteMutation = useMutation({ + mutationFn: (personIds: string[]) => batchDeletePersons(personIds), + meta: { errorTitle: '批量删除失败' }, + onSuccess: (data) => { toast({ title: '批量删除完成', - description: result.data.message, + description: data.message, }) setSelectedPersons(new Set()) setBatchDeleteDialogOpen(false) - loadPersons() - loadStats() - } catch (error) { - toast({ - title: '批量删除失败', - description: error instanceof Error ? error.message : '批量删除失败', - variant: 'destructive', - }) - } - } + invalidatePersons() + }, + }) // 页面跳转 const handleJumpToPage = () => { @@ -443,6 +401,17 @@ export function PersonManagementPage() { + ) : personListQuery.isError ? ( + + +
+

{personListQuery.error.message}

+ +
+
+
) : persons.length === 0 ? ( @@ -519,6 +488,13 @@ export function PersonManagementPage() {
+ ) : personListQuery.isError ? ( +
+

{personListQuery.error.message}

+ +
) : persons.length === 0 ? (
暂无数据 @@ -698,8 +674,7 @@ export function PersonManagementPage() { open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} onSuccess={() => { - loadPersons() - loadStats() + invalidatePersons() setIsEditDialogOpen(false) }} /> @@ -720,7 +695,7 @@ export function PersonManagementPage() { 取消 deleteConfirmPerson && handleDelete(deleteConfirmPerson)} + onClick={() => deleteConfirmPerson && deleteMutation.mutate(deleteConfirmPerson)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > 删除 @@ -742,7 +717,7 @@ export function PersonManagementPage() { 取消 batchDeleteMutation.mutate(Array.from(selectedPersons))} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > 批量删除 @@ -880,7 +855,6 @@ function PersonEditDialog({ onSuccess: () => void }) { const [formData, setFormData] = useState({}) - const [saving, setSaving] = useState(false) const { toast } = useToast() useEffect(() => { @@ -894,29 +868,24 @@ function PersonEditDialog({ } }, [person]) - const handleSave = async () => { - if (!person) return - - try { - setSaving(true) - const result = await updatePerson(person.person_id, formData) - if (!result.success) { - throw new Error(result.error) - } + // 保存(失败由全局 mutation 错误 toast 呈现) + const updateMutation = useMutation({ + mutationFn: (vars: { personId: string; data: PersonUpdateRequest }) => + updatePerson(vars.personId, vars.data), + meta: { errorTitle: '保存失败' }, + onSuccess: () => { toast({ title: '保存成功', description: '人物信息已更新', }) onSuccess() - } catch (error) { - toast({ - title: '保存失败', - description: error instanceof Error ? error.message : '无法更新人物信息', - variant: 'destructive', - }) - } finally { - setSaving(false) - } + }, + }) + const saving = updateMutation.isPending + + const handleSave = () => { + if (!person) return + updateMutation.mutate({ personId: person.person_id, data: formData }) } if (!person) return null diff --git a/dashboard/src/routes/plugin-detail.tsx b/dashboard/src/routes/plugin-detail.tsx index 8e5ecd7e7..6a5cd16f8 100644 --- a/dashboard/src/routes/plugin-detail.tsx +++ b/dashboard/src/routes/plugin-detail.tsx @@ -23,7 +23,7 @@ import { Info, } from 'lucide-react' import { useToast } from '@/hooks/use-toast' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import type { PluginInfo } from '@/types/plugin' import { checkGitStatus, @@ -170,16 +170,14 @@ export function PluginDetailPage() { // 如果插件已安装,优先尝试从本地读取 README if (isInstalled && search.pluginId) { try { - const localResponse = await fetchWithAuth(`/api/webui/plugins/local-readme/${plugin.id}`) - - if (localResponse.ok) { - const localResult = await localResponse.json() - - if (localResult.success && localResult.data) { - setReadme(localResult.data) - setReadmeLoading(false) - return // 成功获取本地 README,直接返回 - } + const localResult = await backendApi.get<{ success: boolean; data?: string }>( + `/api/webui/plugins/local-readme/${plugin.id}` + ) + + if (localResult.success && localResult.data) { + setReadme(localResult.data) + setReadmeLoading(false) + return // 成功获取本地 README,直接返回 } } catch { // 继续执行远程获取逻辑 @@ -198,21 +196,18 @@ export function PluginDetailPage() { const cleanRepo = repo.replace(/\.git$/, '') // 使用后端代理获取 README.md - const response = await fetchWithAuth('/api/webui/plugins/fetch-raw', { - method: 'POST', - body: JSON.stringify({ - owner, - repo: cleanRepo, - branch: 'main', - file_path: 'README.md', - }), - }) - - if (!response.ok) { - throw new Error('获取 README 失败') - } - - const result = await response.json() + const result = await backendApi.post<{ success: boolean; data?: string }>( + '/api/webui/plugins/fetch-raw', + { + body: { + owner, + repo: cleanRepo, + branch: 'main', + file_path: 'README.md', + }, + errorMessage: '获取 README 失败', + } + ) if (result.success && result.data) { setReadme(result.data) diff --git a/dashboard/src/routes/plugin-mirrors.tsx b/dashboard/src/routes/plugin-mirrors.tsx index 132ff42a8..0a16035dc 100644 --- a/dashboard/src/routes/plugin-mirrors.tsx +++ b/dashboard/src/routes/plugin-mirrors.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { useNavigate } from '@tanstack/react-router' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import { Card } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -69,13 +69,10 @@ export function PluginMirrorsPage() { setLoading(true) setError(null) - const response = await fetchWithAuth('/api/webui/plugins/mirrors') - - if (!response.ok) { - throw new Error('获取镜像源列表失败') - } - - const data = await response.json() + const data = await backendApi.get<{ mirrors?: MirrorConfig[] }>( + '/api/webui/plugins/mirrors', + { errorMessage: '获取镜像源列表失败' } + ) setMirrors(data.mirrors || []) } catch (err) { const errorMessage = err instanceof Error ? err.message : '加载镜像源失败' @@ -101,16 +98,11 @@ export function PluginMirrorsPage() { // 添加镜像源 const handleAddMirror = async () => { try { - const response = await fetchWithAuth('/api/webui/plugins/mirrors', { - method: 'POST', - body: JSON.stringify(formData) + await backendApi.post('/api/webui/plugins/mirrors', { + body: formData, + errorMessage: '添加镜像源失败', }) - if (!response.ok) { - const errorData = await response.json() - throw new Error(errorData.detail || '添加镜像源失败') - } - toast({ title: '添加成功', description: '镜像源已添加' @@ -140,21 +132,17 @@ export function PluginMirrorsPage() { if (!editingMirror) return try { - const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${editingMirror.id}`, { - method: 'PUT', - body: JSON.stringify({ + await backendApi.put(`/api/webui/plugins/mirrors/${editingMirror.id}`, { + body: { name: formData.name, raw_prefix: formData.raw_prefix, clone_prefix: formData.clone_prefix, enabled: formData.enabled, priority: formData.priority - }) + }, + errorMessage: '更新镜像源失败', }) - if (!response.ok) { - throw new Error('更新镜像源失败') - } - toast({ title: '更新成功', description: '镜像源已更新' @@ -177,14 +165,10 @@ export function PluginMirrorsPage() { if (!confirm('确定要删除这个镜像源吗?')) return try { - const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${id}`, { - method: 'DELETE' + await backendApi.delete(`/api/webui/plugins/mirrors/${id}`, { + errorMessage: '删除镜像源失败', }) - if (!response.ok) { - throw new Error('删除镜像源失败') - } - toast({ title: '删除成功', description: '镜像源已删除' @@ -203,17 +187,13 @@ export function PluginMirrorsPage() { // 鍒囨崲鍚敤鐘舵€? const handleToggleEnabled = async (mirror: MirrorConfig) => { try { - const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${mirror.id}`, { - method: 'PUT', - body: JSON.stringify({ + await backendApi.put(`/api/webui/plugins/mirrors/${mirror.id}`, { + body: { enabled: !mirror.enabled - }) + }, + errorMessage: '更新状态失败', }) - if (!response.ok) { - throw new Error('更新状态失败') - } - loadMirrors() } catch (err) { toast({ @@ -244,17 +224,13 @@ export function PluginMirrorsPage() { if (newPriority < 1) return try { - const response = await fetchWithAuth(`/api/webui/plugins/mirrors/${mirror.id}`, { - method: 'PUT', - body: JSON.stringify({ + await backendApi.put(`/api/webui/plugins/mirrors/${mirror.id}`, { + body: { priority: newPriority - }) + }, + errorMessage: '更新优先级失败', }) - if (!response.ok) { - throw new Error('更新优先级失败') - } - loadMirrors() } catch (err) { toast({ diff --git a/dashboard/src/routes/resource/emoji/EmojiDialogs.tsx b/dashboard/src/routes/resource/emoji/EmojiDialogs.tsx index 843d8afd3..439ea960d 100644 --- a/dashboard/src/routes/resource/emoji/EmojiDialogs.tsx +++ b/dashboard/src/routes/resource/emoji/EmojiDialogs.tsx @@ -36,7 +36,7 @@ import { getEmojiUploadUrl, updateEmoji, } from '@/lib/emoji-api' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import type { Emoji, EmojiStatus } from '@/types/emoji' import type { UploadedFileInfo, UploadStep } from './types' @@ -599,16 +599,13 @@ export function EmojiUploadDialog({ formData.append('description', getFileEmotion(fileInfo)) try { - const response = await fetchWithAuth(getEmojiUploadUrl(), { - method: 'POST', + // 仅以 HTTP 成功与否计数,无需解析响应体 + await backendApi.post(getEmojiUploadUrl(), { body: formData, + parse: 'response', + errorMessage: '上传表情包失败', }) - - if (response.ok) { - successCount++ - } else { - failedCount++ - } + successCount++ } catch { failedCount++ } diff --git a/dashboard/src/routes/settings/LocalCacheTab.tsx b/dashboard/src/routes/settings/LocalCacheTab.tsx index 6ff620b04..24443cde2 100644 --- a/dashboard/src/routes/settings/LocalCacheTab.tsx +++ b/dashboard/src/routes/settings/LocalCacheTab.tsx @@ -50,7 +50,7 @@ import { } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/hooks/use-toast' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import { cleanupLocalCache, deleteLocalCacheDataEntry, @@ -167,15 +167,8 @@ function CacheImagePreview({ let cancelled = false let createdUrl: string | null = null - void fetchWithAuth(previewUrl, { - method: 'GET', - }) - .then((response) => { - if (!response.ok) { - throw new Error('图片预览加载失败') - } - return response.blob() - }) + void backendApi + .get(previewUrl, { parse: 'blob', errorMessage: '图片预览加载失败' }) .then((blob) => { if (cancelled) { return diff --git a/dashboard/src/routes/settings/OtherTab.tsx b/dashboard/src/routes/settings/OtherTab.tsx index 8fb0ebd81..b1229079f 100644 --- a/dashboard/src/routes/settings/OtherTab.tsx +++ b/dashboard/src/routes/settings/OtherTab.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { useNavigate } from '@tanstack/react-router' import { cn } from '@/lib/utils' -import { fetchWithAuth } from '@/lib/fetch-with-auth' +import { ApiError, backendApi } from '@/lib/http' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Slider } from '@/components/ui/slider' @@ -199,13 +199,12 @@ export function OtherTab() { try { // 调用后端API重置首次配置状态 - const response = await fetchWithAuth('/api/webui/setup/reset', { - method: 'POST', - }) - - const data = await response.json() + const data = await backendApi.post<{ success: boolean; message?: string }>( + '/api/webui/setup/reset', + { errorMessage: t('settings.other.clearStorageFailed') } + ) - if (response.ok && data.success) { + if (data.success) { toast({ title: t('settings.other.resetSuccess'), description: t('settings.other.clearStorageSuccess'), @@ -226,7 +225,11 @@ export function OtherTab() { console.error('重置配置状态错误:', error) toast({ title: t('settings.other.resetFailed'), - description: t('settings.other.clearStorageFailed'), + // HTTP 层失败展示后端给出的错误信息;网络层失败保持原有固定文案 + description: + error instanceof ApiError && error.status !== undefined + ? error.message + : t('settings.other.clearStorageFailed'), variant: 'destructive', }) } finally { diff --git a/dashboard/src/routes/setup/api.ts b/dashboard/src/routes/setup/api.ts index bdf9f9c43..9221c1a33 100644 --- a/dashboard/src/routes/setup/api.ts +++ b/dashboard/src/routes/setup/api.ts @@ -1,7 +1,6 @@ // 设置向导API调用函数 -import { parseResponse, throwIfError } from '@/lib/api-helpers' -import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth' +import { backendApi } from '@/lib/http' import { PROVIDER_TEMPLATES } from '@/routes/config/providerTemplates' import type { @@ -90,15 +89,10 @@ function buildThinkingExtraParams( // 读取Bot基础配置 export async function loadBotBasicConfig(): Promise { - const response = await fetchWithAuth('/api/webui/config/bot', { - method: 'GET', - headers: getAuthHeaders(), - }) - - const result = await parseResponse<{ config: { bot?: BotBasicConfig } }>( - response + const data = await backendApi.get<{ config: { bot?: BotBasicConfig } }>( + '/api/webui/config/bot', + { errorMessage: '读取 Bot 配置失败' } ) - const data = throwIfError(result) const botConfig = (data.config.bot || {}) as Partial const qqAccount = String(botConfig.qq_account ?? '').trim() @@ -113,15 +107,10 @@ export async function loadBotBasicConfig(): Promise { // 读取人格配置 export async function loadPersonalityConfig(): Promise { - const response = await fetchWithAuth('/api/webui/config/bot', { - method: 'GET', - headers: getAuthHeaders(), - }) - - const result = await parseResponse<{ - config: { personality?: PersonalityConfig } - }>(response) - const data = throwIfError(result) + const data = await backendApi.get<{ config: { personality?: PersonalityConfig } }>( + '/api/webui/config/bot', + { errorMessage: '读取人格配置失败' } + ) const personalityConfig = (data.config.personality || {}) as Partial return { @@ -133,13 +122,9 @@ export async function loadPersonalityConfig(): Promise { } async function loadModelConfig(): Promise { - const response = await fetchWithAuth('/api/webui/config/model', { - method: 'GET', - headers: getAuthHeaders(), + const data = await backendApi.get<{ config: ModelConfig }>('/api/webui/config/model', { + errorMessage: '读取模型配置失败', }) - - const result = await parseResponse<{ config: ModelConfig }>(response) - const data = throwIfError(result) return data.config || {} } @@ -192,26 +177,18 @@ export async function loadModelSetupConfig(): Promise { // 保存Bot基础配置 export async function saveBotBasicConfig(config: BotBasicConfig) { - const response = await fetchWithAuth('/api/webui/config/bot/section/bot', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(config), + return backendApi.post('/api/webui/config/bot/section/bot', { + body: config, + errorMessage: '保存 Bot 配置失败', }) - - const result = await parseResponse(response) - return throwIfError(result) } // 保存人格配置 export async function savePersonalityConfig(config: PersonalityConfig) { - const response = await fetchWithAuth('/api/webui/config/bot/section/personality', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(config), + return backendApi.post('/api/webui/config/bot/section/personality', { + body: config, + errorMessage: '保存人格配置失败', }) - - const result = await parseResponse(response) - return throwIfError(result) } function createBasicModel( @@ -276,14 +253,10 @@ export async function saveApiProviderSetupConfig(config: ApiProviderSetupConfig) api_providers: apiProviders, } - const saveResponse = await fetchWithAuth('/api/webui/config/model', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(updatedConfig), + return backendApi.post('/api/webui/config/model', { + body: updatedConfig, + errorMessage: '保存 API 提供商配置失败', }) - - const saveResult = await parseResponse(saveResponse) - return throwIfError(saveResult) } // 保存基础模型配置 @@ -349,22 +322,15 @@ export async function saveModelSetupConfig( model_task_config: updatedTaskConfig, } - const saveResponse = await fetchWithAuth('/api/webui/config/model', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify(updatedConfig), + return backendApi.post('/api/webui/config/model', { + body: updatedConfig, + errorMessage: '保存模型配置失败', }) - - const saveResult = await parseResponse(saveResponse) - return throwIfError(saveResult) } // 标记设置完成 export async function completeSetup() { - const response = await fetchWithAuth('/api/webui/setup/complete', { - method: 'POST', + return backendApi.post('/api/webui/setup/complete', { + errorMessage: '标记设置完成失败', }) - - const result = await parseResponse(response) - return throwIfError(result) } From b814cdd7ba0f1e3ebee06ed42817643860481f8b Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Sat, 13 Jun 2026 00:09:17 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=93=9D=20docs(webui):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20dashboard=20=E9=A2=86=E5=9F=9F=E8=AF=8D=E8=A1=A8=20?= =?UTF-8?q?CONTEXT.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 记录请求层与服务端状态的统一用语:请求客户端三实例、ApiError、 路由未命中诊断、查询/变更/queryKey 约定、配置与设置的边界。 --- dashboard/CONTEXT.md | 67 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 dashboard/CONTEXT.md diff --git a/dashboard/CONTEXT.md b/dashboard/CONTEXT.md new file mode 100644 index 000000000..ccd7fc16d --- /dev/null +++ b/dashboard/CONTEXT.md @@ -0,0 +1,67 @@ +# MaiBot WebUI(dashboard) + +MaiBot 的管理界面:React + TanStack Router 单页应用,可运行在浏览器(同源部署 / Vite 代理)或 Electron 壳内。本文档记录 WebUI 上下文中的领域词汇,供架构评审与重构时统一用语。 + +## Language + +### 请求层 + +**请求客户端(ApiClient)**: +由 `createApiClient` 实例化的 HTTP 请求深模块,收编 base URL 解析、认证、响应解析、错误格式化与诊断。 +_Avoid_: fetcher、fetch 封装、axios 实例 + +**主后端**: +MaiBot 本体暴露的 HTTP API(`/api/webui/**`),使用 HttpOnly Cookie 认证,401 时跳转登录页。对应实例 `backendApi`。 +_Avoid_: 服务器、API 服务 + +**统计服务**: +部署在 Cloudflare Workers 的外部服务,承载问卷提交与插件统计(点赞/评分/下载量),无 Cookie 认证。对应实例 `statsApi`。 +_Avoid_: Workers、云端 API + +**认证流程实例(authApi)**: +携带 Cookie 但不配置 401 跳转的请求客户端实例;登录验证、认证状态探测中 401 是正常业务结果,必须透传后端信息。 + +**ApiError**: +请求失败时由请求客户端抛出的错误;`message` 已格式化为可直接渲染的简体中文,并携带 HTTP `status` 与原始 `detail`。 +_Avoid_: ApiResponse(迁移期遗留的判别联合,最终淘汰) + +**诊断(路由未命中诊断)**: +请求客户端内置的检测:响应体为 HTML 页面时,判定为"未命中后端 API 路由"并在 ApiError 中报出请求 URL,而不是静默重试其他地址。 + +### 服务端状态 + +**查询(Query)**: +通过 TanStack Query 的 `useQuery` 管理的服务端数据读取;加载失败由页面**局部呈现**(错误文案 + 重试),不弹全局 toast。 + +**变更(Mutation)**: +通过 `useMutation` 管理的服务端写操作;失败**默认弹全局 toast**(`lib/query.ts` 的 MutationCache 统一处理,`meta.suppressErrorToast` 可关闭、`meta.errorTitle` 定制标题),成功后按 queryKey 前缀失效相关查询。 + +**查询键(queryKey)**: +服务端状态缓存的分层标识,以领域名开头(如 `['persons', 'list', 参数]`),写操作成功后按前缀整体失效。 + +### 配置与设置 + +**配置(Config)**: +存放在后端、修改后通常需要重启 MaiBot 生效的内容(bot、模型、提示词等),由 schema 驱动的动态表单编辑。 + +**设置(Settings)**: +仅存放在浏览器本地(localStorage/IndexedDB)的用户偏好(外观、缓存、安全),即时生效,不需要重启。 +_Avoid_: 与"配置"混用 + +## Relationships + +- **请求客户端** 有三个适配器实例:`backendApi`(**主后端**,401 跳登录)、`statsApi`(**统计服务**)、`authApi`(**认证流程实例**,401 不跳转);Pack 配置市场与统计服务同域,共用 `statsApi` +- **ApiError** 只由 **请求客户端** 抛出;调用方不再手写 `response.ok` 分支 +- **配置** 走 **主后端** 读写;**设置** 不经过任何请求客户端 + +## Example dialogue + +> **Dev**:「插件市场的点赞数加载失败,要在 `backendApi` 里查吗?」 +> **Domain expert**:「不,点赞走的是**统计服务**(`statsApi`),那是外部 Workers,没有 Cookie 认证;**主后端**只管插件本体的安装和配置。」 +> **Dev**:「那报错时页面拿到的是什么?」 +> **Domain expert**:「两个实例都抛 **ApiError**,`message` 可以直接给 toast 渲染;如果是路由配错返回了 HTML,**诊断**信息会写明未命中的 URL。」 + +## Flagged ambiguities + +- 错误契约曾有两套并存:多数 API 返回 `ApiResponse` 判别联合,memory-api 直接 throw —— 已裁决:统一为 throw **ApiError**,`ApiResponse` 仅作迁移期兼容包装。 +- memory-api 曾在主地址 404/返回 HTML 时静默重试硬编码的 `localhost:8001` —— 已裁决:删除该兜底,保留**诊断**,配错必须显式暴露(2026-06)。 From ac6a4b1a2bcec00286093dc150ee0cb785c67d55 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 13 Jun 2026 00:12:40 +0800 Subject: [PATCH 07/17] =?UTF-8?q?webui:=E4=BC=98=E5=8C=96=E9=83=A8?= =?UTF-8?q?=E5=88=86webui=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dynamic-form/DynamicConfigForm.tsx | 38 ++- .../components/dynamic-form/DynamicField.tsx | 26 +- dashboard/src/components/ui/slider.tsx | 22 +- dashboard/src/index.css | 128 +++++++ dashboard/src/routes/config/bot.tsx | 17 +- .../config/modelProvider/ProviderList.tsx | 66 ++-- dashboard/src/routes/index.tsx | 51 +-- dashboard/src/routes/plugin-config.tsx | 316 ++++++++++++------ .../src/routes/resource/expression/index.tsx | 8 +- .../src/routes/resource/jargon/index.tsx | 22 +- pyproject.toml | 2 +- requirements.txt | 2 +- uv.lock | 8 +- 13 files changed, 478 insertions(+), 228 deletions(-) diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index b26839ba5..7a2e9983f 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -12,6 +12,7 @@ import { import { Separator } from '@/components/ui/separator' import { resolveLocalizedText } from '@/lib/config-label' import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks' +import { cn } from '@/lib/utils' import type { ConfigSchema, FieldSchema } from '@/types/config-schema' import { DynamicField } from './DynamicField' @@ -453,20 +454,29 @@ export const DynamicConfigForm: React.FC = ({ const renderRows = (rows: FieldSchema[][]) => ( <> - {rows.map((row) => ( - row.length > 1 ? ( -
field.name).join('|')} - className="grid min-w-0 gap-3 py-0.5 md:grid-cols-[repeat(auto-fit,minmax(min(18rem,100%),1fr))]" - > - {row.map((field) => ( -
{renderField(field)}
- ))} -
- ) : ( -
{renderField(row[0])}
- ) - ))} + {rows.map((row) => { + const rowKey = row[0]['x-row'] + const isVisualImageCompressionRow = rowKey === 'visual-image-compression' + + return row.length > 1 ? ( +
field.name).join('|')} + data-config-row={rowKey} + className={cn( + "grid min-w-0 gap-3 py-0.5", + isVisualImageCompressionRow + ? "grid-cols-[minmax(0,0.8fr)_minmax(0,1fr)_minmax(0,1.1fr)] items-center" + : "md:grid-cols-[repeat(auto-fit,minmax(min(18rem,100%),1fr))]", + )} + > + {row.map((field) => ( +
{renderField(field)}
+ ))} +
+ ) : ( +
{renderField(row[0])}
+ ) + })} ) diff --git a/dashboard/src/components/dynamic-form/DynamicField.tsx b/dashboard/src/components/dynamic-form/DynamicField.tsx index bc01a03ef..a962c186d 100644 --- a/dashboard/src/components/dynamic-form/DynamicField.tsx +++ b/dashboard/src/components/dynamic-form/DynamicField.tsx @@ -31,6 +31,10 @@ const VISUAL_INLINE_FIELD_NAMES = new Set([ 'replyer_mode', 'wait_image_recognize_max_time', ]) +const COMPACT_INLINE_INPUT_WIDTH_BY_FIELD = new Map([ + ['max_image_size_mb', 'min(100%, 5.5rem)'], + ['oversized_image_handle_method', 'min(100%, 8.5rem)'], +]) export interface DynamicFieldProps { schema: FieldSchema @@ -473,7 +477,11 @@ export const DynamicField: React.FC = ({ const renderSwitch = () => { const checked = Boolean(value) return ( -
+
{renderFieldHeader()}
@@ -752,9 +760,11 @@ export const DynamicField: React.FC = ({ ['string', 'number', 'integer', 'select'].includes(schema.type) const defaultInlineRightInputWidth = isNumericField ? '7.5rem' : '12rem' const schemaInputWidth = schema['x-input-width'] - const inlineRightInputWidth = isNumericField && (!schemaInputWidth || schemaInputWidth === '12rem') - ? defaultInlineRightInputWidth - : schemaInputWidth ?? defaultInlineRightInputWidth + const compactInlineInputWidth = COMPACT_INLINE_INPUT_WIDTH_BY_FIELD.get(schema.name) + const inlineRightInputWidth = compactInlineInputWidth + ?? (isNumericField && (!schemaInputWidth || schemaInputWidth === '12rem') + ? defaultInlineRightInputWidth + : schemaInputWidth ?? defaultInlineRightInputWidth) const inlineRightInputStyle = supportsInlineRight ? { width: inlineRightInputWidth } : undefined const inlineRightInputClassName = supportsInlineRight ? '!w-[var(--field-input-width)]' : undefined @@ -766,6 +776,8 @@ export const DynamicField: React.FC = ({ if (supportsInlineRight) { return (
@@ -780,7 +792,11 @@ export const DynamicField: React.FC = ({ } return ( -
+
{renderFieldHeader()} {/* Input component */} diff --git a/dashboard/src/components/ui/slider.tsx b/dashboard/src/components/ui/slider.tsx index 187b08079..fcce25a10 100644 --- a/dashboard/src/components/ui/slider.tsx +++ b/dashboard/src/components/ui/slider.tsx @@ -19,7 +19,7 @@ const Slider = React.forwardRef< "data-dashboard-slider-value-format": dashboardValueFormat, ...props }, ref) => { - const displaysThumbValue = dashboardSliderStyle === 'config' + const hasDashboardValue = dashboardSliderStyle === 'config' const currentValues = Array.isArray(value) ? value : Array.isArray(defaultValue) @@ -45,24 +45,32 @@ const Slider = React.forwardRef< {...props} > - + {Array.from({ length: Math.max(1, thumbCount) }).map((_, index) => ( - {displaysThumbValue && ( - + {hasDashboardValue && ( + {dashboardValueFormat === 'fixed-2' && typeof currentValues[index] === 'number' ? currentValues[index].toFixed(2) : currentValues[index]} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index af22ec471..41dd0b81d 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -557,6 +557,11 @@ font-weight: 800; } +:root[data-dashboard-style='future-retro'] [data-dashboard-main='true'] [data-plugin-config-title] { + font-size: clamp(1.8rem, 2.1vw, 2.25rem) !important; + line-height: 1.08 !important; +} + :root[data-dashboard-style='future-retro'] [data-plugin-market-header='true'] { position: relative; z-index: 2; @@ -628,6 +633,52 @@ z-index: 1; } +:root[data-dashboard-style='future-retro'] + [data-home-summary-cards='true'] + [data-dashboard-card-header='true'] { + padding: 0.875rem 1rem 0.5rem !important; +} + +:root[data-dashboard-style='future-retro'] + [data-home-summary-cards='true'] + [data-dashboard-card-content='true'] { + padding: 0 1rem 0.875rem !important; +} + +:root[data-dashboard-style='future-retro'] + [data-home-summary-cards='true'] + [data-dashboard-card-header='true'].pb-3 { + padding-bottom: 0.5rem !important; +} + +[data-config-row='visual-image-compression'] + [data-dynamic-field-widget='input'], +[data-config-row='visual-image-compression'] + [data-dynamic-field-widget='select'] { + grid-template-columns: minmax(0, max-content) minmax(3.5rem, 1fr) !important; + gap: 0.5rem !important; +} + +[data-config-row='visual-image-compression'] + [data-dynamic-field-widget='input'] + > div:last-child, +[data-config-row='visual-image-compression'] + [data-dynamic-field-widget='select'] + > div:last-child { + min-width: 0 !important; + width: 100% !important; + justify-self: stretch !important; +} + +[data-config-row='visual-image-compression'] + [data-dynamic-field-widget='input'] + input, +[data-config-row='visual-image-compression'] + [data-dynamic-field-widget='select'] + button { + width: 100% !important; +} + :root[data-dashboard-style='future-retro'] [data-dashboard-button='true'], :root[data-dashboard-style='future-retro'] [data-dashboard-header='true'] button, :root[data-dashboard-style='future-retro'] [data-dashboard-tabs-trigger='true'] { @@ -825,6 +876,13 @@ padding: 0.375rem 0.25rem !important; } +:root[data-dashboard-style='future-retro'] + [data-config-bot-mode-tabs='true'] + [data-dashboard-tabs-trigger='true'] { + font-size: 0.875rem !important; + font-weight: 800; +} + :root[data-dashboard-style='future-retro'] [data-dashboard-workspace-tabs='true'] { background: transparent !important; backdrop-filter: none !important; @@ -992,6 +1050,17 @@ background: var(--retro-gold) !important; } +:root[data-dashboard-style='future-retro'] [data-dashboard-version-value='true'] { + border: 0 !important; + background: transparent !important; + color: var(--retro-rust) !important; + box-shadow: none !important; + padding: 0 !important; + font-size: 1.05rem; + font-weight: 900; + line-height: 1.1; +} + :root[data-dashboard-style='future-retro'] [data-dashboard-switch='true'] { border: var(--retro-stroke) solid var(--retro-line) !important; border-radius: 2px !important; @@ -1009,6 +1078,24 @@ box-shadow: none !important; } +[data-plugin-list-switch='true'] { + width: 1.25rem !important; + height: 2.25rem !important; + flex-direction: column !important; + align-items: center !important; + justify-content: flex-start !important; +} + +[data-plugin-list-switch='true'][data-state='checked'] [data-dashboard-switch-thumb='true'] { + translate: 0 1rem !important; + transform: none !important; +} + +[data-plugin-list-switch='true'][data-state='unchecked'] [data-dashboard-switch-thumb='true'] { + translate: 0 0 !important; + transform: none !important; +} + :root[data-dashboard-style='future-retro'] [data-dashboard-progress='true'] { height: 0.75rem; border: 1px solid var(--retro-line); @@ -1024,6 +1111,47 @@ ) !important; } +:root[data-dashboard-style='future-retro'] + [data-dashboard-slider='config'] + [data-dashboard-slider-track='true'] { + height: 0.75rem !important; + border: 1px solid var(--retro-line); + border-radius: 0 !important; + background: var(--retro-recessed) !important; +} + +:root[data-dashboard-style='future-retro'] + [data-dashboard-slider='config'] + [data-dashboard-slider-range='true'] { + background: var(--retro-rust) !important; +} + +:root[data-dashboard-style='future-retro'] + [data-dashboard-slider='config'] + [data-dashboard-slider-thumb='true'] { + display: inline-flex !important; + min-width: 2.5rem !important; + height: 2rem !important; + width: auto !important; + align-items: center; + justify-content: center; + border: 2px solid var(--retro-line) !important; + border-radius: 0 !important; + background: var(--retro-rust) !important; + color: var(--retro-paper) !important; + padding: 0 0.5rem; + box-shadow: none !important; + font-size: 1.125rem; + font-weight: 800; + line-height: 1; +} + +:root[data-dashboard-style='future-retro'] + [data-dashboard-slider='config'] + [data-dashboard-slider-value='true'] { + display: inline !important; +} + :root[data-dashboard-style='future-retro'] [data-dashboard-nav-item='true'] { border: 1px solid transparent; border-radius: 2px !important; diff --git a/dashboard/src/routes/config/bot.tsx b/dashboard/src/routes/config/bot.tsx index 91fb7341f..248350760 100644 --- a/dashboard/src/routes/config/bot.tsx +++ b/dashboard/src/routes/config/bot.tsx @@ -815,13 +815,13 @@ function BotConfigPageContent() { onValueChange={(v) => handleModeChange(v as 'visual' | 'source')} className="w-full min-w-0 sm:w-[14rem]" > - - - + + + 可视化 - - + + 源代码 @@ -831,10 +831,11 @@ function BotConfigPageContent() { disabled={saving || autoSaving || isRestarting} size="sm" variant="outline" - className="h-9 min-w-0 flex-1 sm:w-24 sm:flex-none" + className="h-9 w-9 flex-none px-0" + aria-label="刷新" + title="刷新" > - - 刷新 +
{/* 桌面端表格视图 */} -
+
- - - - - 0} - onCheckedChange={onToggleSelectAll} - /> +
+ + + +
+ 0} + onCheckedChange={onToggleSelectAll} + aria-label="选择全部厂商用于批量删除" + title="选择全部厂商用于批量删除" + /> + 选择 +
- 状态 - 名称 - 基础URL - 客户端类型 - 最大重试 - 超时(秒) - 重试间隔(秒) - 操作 + 状态 + 名称 + 基础URL + 客户端 + 最大重试 + 超时(秒) + 间隔(秒) + 操作
@@ -209,25 +219,27 @@ export function ProviderList({ paginatedProviders.map((provider, displayIndex) => { const actualIndex = providers.findIndex(p => p === provider) return ( - - + + onToggleSelect(actualIndex)} + aria-label={`选择厂商 ${provider.name} 用于批量删除`} + title="选择后可批量删除厂商" /> - + {renderTestStatus(provider.name)} - {provider.name} - + {provider.name} + {provider.base_url} - {provider.client_type} - {provider.max_retry} - {provider.timeout} - {provider.retry_interval} - + {provider.client_type} + {provider.max_retry} + {provider.timeout} + {provider.retry_interval} +
{/* 机器人状态和快速操作 */} -
+
{/* 机器人状态卡片 */} @@ -1256,28 +1251,24 @@ function IndexPageContent() { @@ -1447,6 +1425,7 @@ function IndexPageContent() { size="icon" onClick={() => setQuickShortcutDialogOpen(true)} aria-label={t('home.quickActions.customize')} + className="h-8 w-8" > diff --git a/dashboard/src/routes/plugin-config.tsx b/dashboard/src/routes/plugin-config.tsx index b37926aac..20546054e 100644 --- a/dashboard/src/routes/plugin-config.tsx +++ b/dashboard/src/routes/plugin-config.tsx @@ -92,6 +92,16 @@ interface FieldRendererProps { sectionName: string } +type PluginStatusIcon = 'loading' | 'warning' | 'circuit' + +interface PluginStatusMeta { + dotClassName: string + label: string + badgeClassName?: string + icon?: PluginStatusIcon + showsBadge?: boolean +} + function getLocaleCandidates(language: string): string[] { const normalized = (language || 'zh').replace('-', '_') const base = normalized.split('_')[0] @@ -475,6 +485,7 @@ function PluginConfigEditor({ plugin, onBack, initialTab }: PluginConfigEditorPr const { i18n } = useTranslation() const language = i18n.resolvedLanguage || i18n.language || 'zh' const [editMode, setEditMode] = useState<'visual' | 'source'>('visual') + const [pluginPageTab, setPluginPageTab] = useState<'settings' | 'details'>('settings') const [schema, setSchema] = useState(null) const [activeConfigTab, setActiveConfigTab] = useState(initialTab) const [config, setConfig] = useState>({}) @@ -745,6 +756,23 @@ function PluginConfigEditor({ plugin, onBack, initialTab }: PluginConfigEditorPr schema.plugin_info.i18n, 'name', ) + const manifestUrls = plugin.manifest.urls as { + repository?: string + homepage?: string + documentation?: string + issues?: string + } | undefined + const pluginHomepageUrl = plugin.manifest.homepage_url || manifestUrls?.homepage + const pluginRepositoryUrl = plugin.manifest.repository_url || manifestUrls?.repository + const pluginDetailItems = [ + { label: '插件 ID', value: plugin.manifest.id || plugin.id }, + { label: '版本', value: schema.plugin_info.version || plugin.manifest.version }, + { label: '类型', value: getPluginTypeLabel(plugin) }, + { label: '作者', value: plugin.manifest.author?.name }, + { label: '许可证', value: plugin.manifest.license }, + { label: '最低麦麦版本', value: plugin.manifest.host_application?.min_version }, + { label: '安装路径', value: plugin.path }, + ].filter((item): item is { label: string; value: string } => typeof item.value === 'string' && item.value.trim().length > 0) return (
@@ -755,7 +783,7 @@ function PluginConfigEditor({ plugin, onBack, initialTab }: PluginConfigEditorPr
-

+

{pluginName}

@@ -840,88 +868,138 @@ function PluginConfigEditor({ plugin, onBack, initialTab }: PluginConfigEditorPr )} - {/* 源代码模式 */} - {editMode === 'source' && ( -
- - - - 源代码模式(高级功能):直接编辑 TOML 配置文件。保存时会验证格式,只有格式正确才能保存。 - {hasTomlError && ( - ⚠️ 上次保存失败,请检查 TOML 格式 - )} - - - - { - setSourceCode(value) - if (hasTomlError) { - setHasTomlError(false) - } - }} - language="toml" - height="calc(100vh - 350px)" - minHeight="500px" - placeholder="TOML 配置内容" - /> -
- )} + setPluginPageTab(value as 'settings' | 'details')}> + + 设置 + 详情 + + + {/* 源代码模式 */} + {editMode === 'source' && ( +
+ + + + 源代码模式(高级功能):直接编辑 TOML 配置文件。保存时会验证格式,只有格式正确才能保存。 + {hasTomlError && ( + ⚠️ 上次保存失败,请检查 TOML 格式 + )} + + + + { + setSourceCode(value) + if (hasTomlError) { + setHasTomlError(false) + } + }} + language="toml" + height="calc(100vh - 350px)" + minHeight="500px" + placeholder="TOML 配置内容" + /> +
+ )} - {/* 可视化模式 */} - {editMode === 'visual' && ( - <> - {/* 配置区域 */} - {schema.layout.type === 'tabs' && schemaTabs.length > 0 ? ( - // 标签页布局 - - - {schemaTabs.map(tab => ( - - {resolveLocalizedText(tab.title, language, tab.id, tab.i18n, 'title')} - {tab.badge && ( - - {tab.badge} - - )} - - ))} - - {schemaTabs.map(tab => ( - - {tab.sections.map(sectionName => { - const section = schema.sections[sectionName] - if (!section) return null - return ( - - ) - })} - - ))} - - ) : ( - // 自动布局 -
- {sortedSections.map(([sectionName, section]) => ( - - ))} -
- )} - - )} + {/* 可视化模式 */} + {editMode === 'visual' && ( + <> + {/* 配置区域 */} + {schema.layout.type === 'tabs' && schemaTabs.length > 0 ? ( + // 标签页布局 + + + {schemaTabs.map(tab => ( + + {resolveLocalizedText(tab.title, language, tab.id, tab.i18n, 'title')} + {tab.badge && ( + + {tab.badge} + + )} + + ))} + + {schemaTabs.map(tab => ( + + {tab.sections.map(sectionName => { + const section = schema.sections[sectionName] + if (!section) return null + return ( + + ) + })} + + ))} + + ) : ( + // 自动布局 +
+ {sortedSections.map(([sectionName, section]) => ( + + ))} +
+ )} + + )} +
+ + + + 插件详情 + {plugin.manifest.description || '暂无描述'} + + +
+ {pluginDetailItems.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ {(pluginHomepageUrl || pluginRepositoryUrl || manifestUrls?.documentation || manifestUrls?.issues) && ( +
+ {pluginHomepageUrl && ( + + )} + {pluginRepositoryUrl && ( + + )} + {manifestUrls?.documentation && ( + + )} + {manifestUrls?.issues && ( + + )} +
+ )} +
+
+
+
0 ? (loadFailedCount / loadTotalCount) * 100 : 0 const loadingPercent = loadTotalCount > 0 ? (loadingCount / loadTotalCount) * 100 : 0 const circuitPercent = loadTotalCount > 0 ? (circuitActiveCount / loadTotalCount) * 100 : 0 + const showsCircuitSummary = circuitOpenCount > 0 + const modernLoadSummaryLabel = [ + `加载成功 ${loadSuccessCount} 个`, + `加载中 ${loadingCount} 个`, + showsCircuitSummary ? `熔断中 ${circuitOpenCount} 个` : '', + `加载失败 ${loadFailedCount} 个`, + ].filter(Boolean).join(',') + const futureRetroPluginSummaryLabel = [ + `已安装 ${installedCount} 个插件`, + `已启用 ${enabledCount} 个`, + `已禁用 ${disabledCount} 个`, + `加载中 ${loadingCount} 个`, + showsCircuitSummary ? `熔断中 ${circuitOpenCount} 个` : '', + `启动失败 ${loadFailedCount} 个`, + ].filter(Boolean).join(',') const isModernDashboardStyle = themeConfig.dashboardStyle === 'modern' const isFutureRetroDashboardStyle = themeConfig.dashboardStyle === 'future-retro' const getPluginStatusBarClassName = (plugin: InstalledPlugin) => { @@ -1228,7 +1321,7 @@ function PluginConfigPageContent() { } return '已启用' } - const getPluginStatusMeta = (plugin: InstalledPlugin) => { + const getPluginStatusMeta = (plugin: InstalledPlugin): PluginStatusMeta => { if (isPluginDisabled(plugin)) { return { dotClassName: 'bg-muted-foreground/45', label: '已禁用' } } @@ -1258,7 +1351,7 @@ function PluginConfigPageContent() { } } if (isPluginLoadSuccess(plugin)) { - return { dotClassName: 'bg-emerald-500', label: '加载成功', icon: 'success' as const } + return { dotClassName: 'bg-emerald-500', label: '加载成功', showsBadge: false } } return { dotClassName: 'bg-red-500', @@ -1588,14 +1681,16 @@ function PluginConfigPageContent() { 已启用 {enabledCount} 已禁用 {disabledCount} 加载中 {loadingCount} - 熔断中 {circuitOpenCount} + {showsCircuitSummary && ( + 熔断中 {circuitOpenCount} + )}
- 加载成功 {loadSuccessCount} 个,加载中 {loadingCount} 个,熔断中 {circuitOpenCount} 个,加载失败 {loadFailedCount} 个 + {modernLoadSummaryLabel} {loadSuccessCount}