diff --git a/core/chat_flow.py b/core/chat_flow.py index c370a8d..fa544b9 100644 --- a/core/chat_flow.py +++ b/core/chat_flow.py @@ -88,6 +88,7 @@ async def _finalize_and_reschedule( ) session_config = None scheduled_job_payload = None + limit_reached_after_send = False async with self.data_lock: # 更新未回复计数器 @@ -106,33 +107,54 @@ async def _finalize_and_reschedule( if not session_config: return - schedule_conf = session_config.get("schedule_settings", {}) - min_interval = int(schedule_conf.get("min_interval_minutes", 30)) * 60 - max_interval = max( - min_interval, - int(schedule_conf.get("max_interval_minutes", 900)) * 60, - ) - # 私聊采用配置区间内随机间隔,减少触发规律性 - random_interval = random.randint(min_interval, max_interval) - scheduled_at = time.time() - next_trigger_time = scheduled_at + random_interval - run_date = datetime.fromtimestamp(next_trigger_time, tz=self.timezone) - - session_payload = self.session_data.setdefault(session_id, {}) - session_payload["next_trigger_time"] = next_trigger_time - session_payload["last_scheduled_at"] = scheduled_at - session_payload["last_schedule_min_interval_seconds"] = min_interval - session_payload["last_schedule_max_interval_seconds"] = max_interval - session_payload["last_schedule_random_interval_seconds"] = ( - random_interval - ) - scheduled_job_payload = { - "run_date": run_date, - "session_config": session_config, - } + if self._is_unanswered_limit_reached( + session_id, session_config, new_unanswered_count + ): + limit_reached_after_send = True + self._clear_session_schedule_state(session_id) + logger.info( + f"[主动消息] {self._get_session_log_str(session_id, session_config)} 的未回复次数已达到上限,停止安排下一次主动消息喵。" + ) + else: + schedule_conf = session_config.get("schedule_settings", {}) + min_interval = ( + int(schedule_conf.get("min_interval_minutes", 30)) * 60 + ) + max_interval = max( + min_interval, + int(schedule_conf.get("max_interval_minutes", 900)) * 60, + ) + # 私聊采用配置区间内随机间隔,减少触发规律性 + random_interval = random.randint(min_interval, max_interval) + scheduled_at = time.time() + next_trigger_time = scheduled_at + random_interval + run_date = datetime.fromtimestamp( + next_trigger_time, tz=self.timezone + ) + + session_payload = self.session_data.setdefault(session_id, {}) + session_payload["next_trigger_time"] = next_trigger_time + session_payload["last_scheduled_at"] = scheduled_at + session_payload["last_schedule_min_interval_seconds"] = ( + min_interval + ) + session_payload["last_schedule_max_interval_seconds"] = ( + max_interval + ) + session_payload["last_schedule_random_interval_seconds"] = ( + random_interval + ) + scheduled_job_payload = { + "run_date": run_date, + "session_config": session_config, + } await self._save_data_internal() + if limit_reached_after_send: + self._purge_related_jobs(session_id) + return + if scheduled_job_payload is not None: self.scheduler.add_job( self.check_and_chat, diff --git a/core/plugin_lifecycle.py b/core/plugin_lifecycle.py index cf3c28b..0a90caa 100644 --- a/core/plugin_lifecycle.py +++ b/core/plugin_lifecycle.py @@ -136,6 +136,7 @@ async def initialize(self) -> None: try: if self.web_admin_server: await self.web_admin_server.start() + self.web_admin_server.register_astrbot_page_api() except Exception as e: logger.error(f"[主动消息] Web 管理端启动失败喵: {e}") if self.telemetry and self.telemetry.enabled: diff --git a/core/task_scheduler.py b/core/task_scheduler.py index 92b1344..9ab4255 100644 --- a/core/task_scheduler.py +++ b/core/task_scheduler.py @@ -131,6 +131,43 @@ def _is_persisted_task_still_valid( # 与 APScheduler misfire_grace_time 保持一致,允许 60 秒轻微抖动 return check_time < (next_trigger + 60) + def _get_max_unanswered_count( + self, session_config: dict | None, default: int = 3 + ) -> int: + """Return the configured unanswered limit, treating non-positive values as disabled.""" + if not isinstance(session_config, dict): + return default + schedule_conf = session_config.get("schedule_settings", {}) + if not isinstance(schedule_conf, dict): + return default + try: + return int(schedule_conf.get("max_unanswered_times", default) or 0) + except (TypeError, ValueError): + return default + + def _is_unanswered_limit_reached( + self, + session_id: str, + session_config: dict | None, + unanswered_count: int | None = None, + ) -> bool: + """Shared guard used by runtime scheduling and Pages task snapshots.""" + max_unanswered = self._get_max_unanswered_count(session_config) + if max_unanswered <= 0: + return False + if unanswered_count is None: + session_info = self.session_data.get(session_id) + if isinstance(session_info, dict): + try: + unanswered_count = int( + session_info.get("unanswered_count", 0) or 0 + ) + except (TypeError, ValueError): + unanswered_count = 0 + else: + unanswered_count = 0 + return unanswered_count >= max_unanswered + def _clear_session_schedule_state( self, session_id: str, @@ -158,6 +195,10 @@ def _clear_session_schedule_state( "last_schedule_min_interval_seconds", "last_schedule_max_interval_seconds", "last_schedule_random_interval_seconds", + "last_schedule_strategy", + "last_schedule_reason", + "last_schedule_rule", + "last_schedule_source", } changed = False @@ -191,6 +232,18 @@ def _purge_related_jobs(self, session_id: str) -> None: except Exception: pass + def _remove_schedule_for_limit_reached( + self, session_id: str, session_config: dict | None = None + ) -> bool: + """达到未回复上限时清理调度器任务和持久化调度字段。""" + normalized_session_id = self._normalize_session_id(session_id) + self._purge_related_jobs(normalized_session_id) + changed = self._clear_session_schedule_state(normalized_session_id) + logger.info( + f"[主动消息] {self._get_session_log_str(normalized_session_id, session_config)} 已达到未回复次数上限,已清理待触发任务并暂停调度喵。" + ) + return changed + def _has_related_persisted_task(self, session_id: str) -> bool: """判断同一目标是否存在仍可恢复的持久化任务(避免重复触发)。""" parsed = self._parse_session_id(session_id) @@ -420,6 +473,14 @@ async def _init_jobs_from_data(self) -> None: ) continue + if self._is_unanswered_limit_reached(session_id, session_config): + if self._clear_session_schedule_state(session_id): + cleaned_runtime_state += 1 + logger.info( + f"[主动消息] {self._get_session_log_str(session_id, session_config)} 的未回复次数已达到上限,跳过恢复定时任务喵。" + ) + continue + try: run_date = datetime.fromtimestamp(next_trigger, tz=self.timezone) existing_job = self.scheduler.get_job(session_id) @@ -499,6 +560,16 @@ async def _schedule_next_chat_and_save( "unanswered_count" ] = 0 + if not reset_counter and self._is_unanswered_limit_reached( + normalized_session_id, session_config + ): + changed = self._remove_schedule_for_limit_reached( + normalized_session_id, session_config + ) + if changed: + await self._save_data_internal() + return + # 计算随机触发时间 min_interval = int(schedule_conf.get("min_interval_minutes", 30)) * 60 max_interval = max( diff --git a/core/web_admin_server.py b/core/web_admin_server.py index 0460ddf..c7c072a 100644 --- a/core/web_admin_server.py +++ b/core/web_admin_server.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import inspect import json import math import os @@ -13,6 +14,7 @@ from datetime import datetime from pathlib import Path from typing import Any +from urllib.parse import unquote from astrbot.api import logger @@ -34,6 +36,43 @@ "[主动消息] FastAPI 未安装喵,Web 管理端不可用喵。请安装: pip install fastapi uvicorn" ) +try: + from quart import request as quart_request +except ImportError: + quart_request = None + + +def _patch_starlette_router_startup_kwargs() -> None: + """兼容 FastAPI 与较新 Starlette Router 的启动参数签名差异。""" + try: + from starlette.routing import Router + except Exception as e: + logger.debug(f"[主动消息] 检查 Starlette Router 兼容性失败喵: {e}") + return + + init = Router.__init__ + if getattr(init, "_proactive_chat_startup_patch", False): + return + + try: + params = inspect.signature(init).parameters + except (TypeError, ValueError) as e: + logger.debug(f"[主动消息] 读取 Starlette Router 签名失败喵: {e}") + return + + unsupported = {name for name in ("on_startup", "on_shutdown") if name not in params} + if not unsupported: + return + + def patched_init(self, *args, **kwargs): + for name in unsupported: + kwargs.pop(name, None) + return init(self, *args, **kwargs) + + patched_init._proactive_chat_startup_patch = True + Router.__init__ = patched_init + logger.info("[主动消息] 已应用 FastAPI / Starlette Router 启动参数兼容补丁喵。") + def _is_running_in_docker() -> bool: """检测当前进程是否运行在 Docker / 容器环境中。""" @@ -59,11 +98,16 @@ def _is_running_in_docker() -> bool: class WebAdminServer: """主动消息插件 Web 管理端服务器。""" + ASTRBOT_PLUGIN_NAME = "astrbot_plugin_proactive_chat" + ASTRBOT_PAGE_API_ENDPOINT = "dashboard" + ASTRBOT_PAGE_API_PATH = f"/{ASTRBOT_PLUGIN_NAME}/{ASTRBOT_PAGE_API_ENDPOINT}" + def __init__(self, plugin: Any): # plugin 是主插件实例,Web 端所有状态与操作都通过它间接访问。 self.plugin = plugin # 直接缓存配置引用,便于路由中统一读写。 self.config = plugin.config + self._native_config = self.config if hasattr(self.config, "save_config") else None # FastAPI 应用实例,仅在依赖存在且初始化成功时设置。 self.app: FastAPI | None = None # Uvicorn Server 实例,用于控制启动与停止。 @@ -78,6 +122,7 @@ def __init__(self, plugin: Any): self._token_expire_seconds = 60 * 60 * 24 # 简单的内存令牌表:token -> 过期时间戳。 self._tokens: dict[str, float] = {} + self._background_tasks: set[asyncio.Task] = set() # 仅当配置中设置了密码时才开启鉴权。 self._auth_enabled = bool(self.config.get("web_admin", {}).get("password", "")) # 缓存插件版本,避免在高频状态轮询与广播中重复读取文件。 @@ -101,7 +146,226 @@ def __init__(self, plugin: Any): f" 可能是 FastAPI / Pydantic 依赖版本不兼容: {e}" ) + def _decode_route_umo(self, umo: str) -> str: + """Decode URL-encoded UMO path parameters from Pages/Web API routes.""" + try: + return unquote(str(umo or "")) + except Exception: + return str(umo or "") + + def _track_background_task(self, task: asyncio.Task) -> None: + track_task = getattr(self.plugin, "_track_task", None) + if callable(track_task): + track_task(task) + return + self._background_tasks.add(task) + task.add_done_callback(self._background_tasks.discard) + + def register_astrbot_page_api(self) -> None: + """向 AstrBot WebUI 插件 Page 暴露完整管理端接口。""" + context = getattr(self.plugin, "context", None) + register = getattr(context, "register_web_api", None) + if not callable(register): + logger.debug( + "[主动消息] 当前 AstrBot 版本未提供 register_web_api,跳过插件卡片接口注册喵。" + ) + return + + routes = ( + ("dashboard", self.build_astrbot_page_payload, ["GET"]), + ("embedded-dashboard", self.build_astrbot_page_payload, ["GET"]), + ("auth-info", self._page_auth_info, ["GET"]), + ("login", self._page_login, ["POST"]), + ("status", self._page_status, ["GET"]), + ("config", self._page_config, ["GET", "POST"]), + ("config-save", self._page_update_config, ["POST"]), + ("get_config", self._page_get_config, ["GET"]), + ("save_config", self._page_save_config, ["POST"]), + ("config-schema", self._page_get_config_schema, ["GET"]), + ("session-config/sessions", self._page_list_session_configs, ["GET"]), + ("session-config/", self._page_session_config, ["GET", "POST"]), + ( + "session-config-save/", + self._page_update_session_config, + ["POST"], + ), + ( + "session-config-delete/", + self._page_reset_session_config, + ["POST"], + ), + ("jobs", self._page_list_jobs, ["GET"]), + ("jobs//reschedule", self._page_reschedule_job, ["POST"]), + ("jobs//trigger", self._page_trigger_job, ["POST"]), + ("jobs-cancel/", self._page_cancel_job, ["POST"]), + ) + + registered_count = 0 + for endpoint, handler, methods in routes: + path = f"/{self.ASTRBOT_PLUGIN_NAME}/{endpoint}" + if self._register_astrbot_page_route(register, path, handler, methods): + registered_count += 1 + + logger.info( + f"[主动消息] 已注册 AstrBot 插件卡片管理端接口 {registered_count}/{len(routes)} 个喵。" + ) + + def _register_astrbot_page_route( + self, + register, + path: str, + handler, + methods: list[str], + ) -> bool: + """兼容不同 AstrBot 版本的 register_web_api 签名。""" + attempts = ( + lambda: register(path, handler, methods, "Proactive chat WebUI API"), + lambda: register(path, handler, methods=methods), + lambda: register(methods[0], path, handler), + lambda: register(path, handler), + ) + + last_error: Exception | None = None + for attempt in attempts: + try: + attempt() + return True + except TypeError as e: + last_error = e + continue + except Exception as e: + logger.warning( + f"[主动消息] 注册 AstrBot 插件卡片接口失败喵: {path}: {e}" + ) + return False + + if last_error: + logger.debug( + f"[主动消息] AstrBot 插件卡片接口签名不兼容,已跳过 {path} 喵: {last_error}" + ) + return False + + async def _read_astrbot_page_json(self) -> dict[str, Any]: + """Read JSON sent through AstrBot Pages' Quart-based plugin API bridge.""" + if quart_request is None: + return {} + + payload = None + for kwargs in ({"silent": True}, {"force": True}, {}): + try: + payload = await quart_request.get_json(**kwargs) + if payload is not None: + break + except TypeError: + continue + except Exception: + continue + return self._unwrap_page_payload(payload) + + def _unwrap_page_payload(self, payload: Any) -> dict[str, Any]: + """Unwrap bridge envelopes such as {"body": {...}} without changing normal JSON.""" + if isinstance(payload, str): + try: + payload = json.loads(payload) + except Exception: + return {} + if not isinstance(payload, dict): + return {} + + for key in ("body", "data", "payload"): + if set(payload.keys()) != {key}: + continue + nested = payload.get(key) + if isinstance(nested, str): + try: + nested = json.loads(nested) + except Exception: + return {} + if isinstance(nested, dict): + return nested + return payload + + def _get_astrbot_page_method(self) -> str: + if quart_request is None: + return "GET" + return str(getattr(quart_request, "method", "GET") or "GET").upper() + + async def _page_auth_info(self): + return {"auth_required": False} + + async def _page_login(self): + return {"token": "page-bridge", "auth_required": False} + + async def _page_status(self): + return self._build_status_payload() + + async def _page_get_config(self): + return self._build_config_payload() + + async def _page_update_config(self): + payload = await self._read_astrbot_page_json() + return await self._apply_config_payload(payload) + + async def _page_save_config(self): + payload = await self._read_astrbot_page_json() + logger.info( + f"[主动消息] Pages save_config 收到保存请求喵,字段: {sorted(payload.keys())}" + ) + result = await self._apply_config_payload(payload) + if not result.get("ok", False): + return { + "success": False, + "error": result.get("error") or result.get("message") or "保存失败", + "received": True, + "received_keys": sorted(payload.keys()), + } + return { + "success": True, + "received": True, + "received_keys": sorted(payload.keys()), + "config": result.get("config") or self._build_config_payload(), + } + + async def _page_config(self): + if self._get_astrbot_page_method() == "POST": + return await self._page_update_config() + return await self._page_get_config() + + async def _page_get_config_schema(self): + return await self._load_config_schema_payload() + + async def _page_list_session_configs(self): + return {"sessions": self._list_session_config_payloads()} + + async def _page_get_session_config(self, umo: str): + return self._build_session_config_payload(umo) + + async def _page_update_session_config(self, umo: str): + payload = await self._read_astrbot_page_json() + return await self._apply_session_config_payload(umo, payload) + + async def _page_session_config(self, umo: str): + if self._get_astrbot_page_method() == "POST": + return await self._page_update_session_config(umo) + return await self._page_get_session_config(umo) + + async def _page_reset_session_config(self, umo: str): + return await self._reset_session_config_payload(umo) + + async def _page_list_jobs(self): + return {"jobs": self._collect_jobs()} + + async def _page_reschedule_job(self, umo: str): + return await self._reschedule_job_payload(umo) + + async def _page_trigger_job(self, umo: str): + return await self._trigger_job_payload(umo) + + async def _page_cancel_job(self, umo: str): + return await self._cancel_job_payload(umo) + def _setup_app(self) -> None: + _patch_starlette_router_startup_kwargs() # 创建 FastAPI 应用,版本号用于控制台元信息展示。 self.app = FastAPI( title="主动消息管理端", @@ -205,6 +469,11 @@ async def get_status(): # 汇总插件运行状态、计时器与连接数,供首页卡片与轮询逻辑使用。 return self._build_status_payload() + @self.app.get("/api/embedded-dashboard") + async def embedded_dashboard(): + # 给 AstrBot 插件 Page 或其它轻量入口复用的聚合快照。 + return await self.build_astrbot_page_payload() + @self.app.get("/api/markdown-files") async def list_markdown_files(): # 仅暴露插件目录内明确允许浏览的 Markdown 文档,避免前端任意探测文件系统。 @@ -279,27 +548,10 @@ async def get_config_schema(): @self.app.post("/api/config") async def update_config(payload: dict[str, Any]): - # 仅允许更新这三个一级配置块,避免前端误写其它未知字段。 - allowed_keys = {"friend_settings", "group_settings", "web_admin"} - for key in allowed_keys: - if key not in payload: - continue - if key == "web_admin": - # web_admin 采用增量合并,避免未提交字段被整个覆盖掉。 - old = dict(self.config.get("web_admin", {})) - old.update(payload.get("web_admin", {})) - # 密码字段允许显式更新,但不会通过 get_config 回传给前端。 - if "password" in payload.get("web_admin", {}): - old["password"] = payload["web_admin"]["password"] - self.config["web_admin"] = old - else: - # friend / group 配置块按前端提交的完整对象直接替换。 - self.config[key] = payload[key] - - self._save_plugin_config() - # 配置变更后立即广播,确保所有已打开页面实时刷新。 - await self._broadcast_update("config") - return {"ok": True} + result = await self._apply_config_payload(payload) + if not result.get("ok", False): + return JSONResponse(result, status_code=400) + return result @self.app.get("/api/session-config/sessions") async def list_session_configs(): @@ -337,7 +589,7 @@ async def list_session_configs(): @self.app.get("/api/session-config/{umo:path}") async def get_session_config(umo: str): # 路径参数使用 path 转换器,允许会话 ID 中包含斜杠等特殊字符。 - normalized = self.plugin._normalize_session_id(umo) + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) base = self.plugin._get_base_session_config(normalized) return { "session": normalized, @@ -353,7 +605,7 @@ async def get_session_config(umo: str): @self.app.post("/api/session-config/{umo:path}") async def update_session_config(umo: str, payload: dict[str, Any]): - normalized = self.plugin._normalize_session_id(umo) + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) # mode 用于兼容两种写法:直接提交 override,或提交最终 effective 配置。 mode = payload.get("mode", "effective") @@ -403,7 +655,7 @@ async def update_session_config(umo: str, payload: dict[str, Any]): @self.app.delete("/api/session-config/{umo:path}") async def reset_session_config(umo: str): # 删除覆写后,会话会重新完全继承全局配置。 - normalized = self.plugin._normalize_session_id(umo) + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) await self.plugin.session_override_manager.delete_override(normalized) await self._broadcast_update("session-config") return { @@ -420,7 +672,7 @@ async def list_jobs(): @self.app.post("/api/jobs/{umo:path}/reschedule") async def reschedule_job(umo: str): - normalized = self.plugin._normalize_session_id(umo) + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) session_config = self.plugin._get_session_config(normalized) if not session_config or not session_config.get("enable", False): return JSONResponse( @@ -600,7 +852,7 @@ async def open_directory(payload: dict[str, Any]): @self.app.post("/api/jobs/{umo:path}/trigger") async def trigger_job(umo: str): # 立即手动触发一次指定会话的检查与发言流程;同一会话在执行完成前禁止重复触发。 - normalized = self.plugin._normalize_session_id(umo) + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) if normalized in self.plugin.manual_trigger_sessions: return JSONResponse( { @@ -625,7 +877,7 @@ async def trigger_job(umo: str): @self.app.delete("/api/jobs/{umo:path}") async def cancel_job(umo: str): - normalized = self.plugin._normalize_session_id(umo) + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) removed = False try: # APScheduler 中的 job id 直接使用规范化后的 session id。 @@ -637,9 +889,9 @@ async def cancel_job(umo: str): async with self.plugin.data_lock: if normalized in self.plugin.session_data: - # 同步清理持久化数据中的 next_trigger_time,避免界面显示过期信息。 - self.plugin.session_data[normalized].pop("next_trigger_time", None) - await self.plugin._save_data_internal() + # 同步清理持久化调度字段,避免界面显示过期倒计时。 + if self.plugin._clear_session_schedule_state(normalized): + await self.plugin._save_data_internal() if removed: logger.info( @@ -728,13 +980,32 @@ async def websocket_endpoint(websocket: WebSocket): if websocket in self._ws_connections: self._ws_connections.remove(websocket) - def _save_plugin_config(self) -> None: + def _set_config_section(self, key: str, value: Any) -> None: + self.config[key] = value + + def _save_plugin_config(self) -> tuple[bool, str | None]: + """Persist config through AstrBot's native object and notify newer contexts.""" try: # AstrBot 配置对象通常提供 save_config 方法,这里做鸭子类型兼容。 - if hasattr(self.config, "save_config"): - self.config.save_config() + native = self._native_config or ( + self.config if hasattr(self.config, "save_config") else None + ) + if native is not None: + if native is not self.config: + native.clear() + native.update(self.config) + native.save_config() + + context = getattr(self.plugin, "context", None) + update_config = getattr(context, "update_config", None) + if callable(update_config): + updated = update_config(self.config) + if inspect.isawaitable(updated): + self._track_background_task(asyncio.create_task(updated)) + return True, None except Exception as e: logger.warning(f"[主动消息] 保存配置失败喵: {e}") + return False, str(e) def _issue_token(self) -> str: # 生成适合放入 URL / Header 的安全随机 token。 @@ -758,6 +1029,340 @@ def _verify_token(self, token: str) -> bool: return False return True + def _build_config_payload(self) -> dict[str, Any]: + web_admin = { + k: v for k, v in self.config.get("web_admin", {}).items() if k != "password" + } + return { + "friend_settings": dict(self.config.get("friend_settings", {})), + "group_settings": dict(self.config.get("group_settings", {})), + "web_admin": web_admin, + "notification_settings": dict(self.config.get("notification_settings", {})), + } + + async def _apply_config_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + # Pages 只能写入 WebUI 暴露的四个顶层配置段,避免误把运行时状态写进主配置。 + allowed_keys = { + "friend_settings", + "group_settings", + "web_admin", + "notification_settings", + } + changed = False + for key in allowed_keys: + if key not in payload: + continue + if key != "web_admin" and not isinstance(payload[key], dict): + return {"ok": False, "error": f"{key} must be an object"} + if key == "web_admin": + if not isinstance(payload.get("web_admin"), dict): + return {"ok": False, "error": "web_admin must be an object"} + old = dict(self.config.get("web_admin", {})) + old.update(payload.get("web_admin", {})) + if "password" in payload.get("web_admin", {}): + old["password"] = payload["web_admin"]["password"] + self._set_config_section("web_admin", old) + else: + self._set_config_section(key, payload[key]) + changed = True + + if not changed: + return {"ok": False, "error": "No supported config keys received"} + + saved, error = self._save_plugin_config() + if not saved: + return {"ok": False, "error": error or "Config save failed"} + self._auth_enabled = bool(self.config.get("web_admin", {}).get("password", "")) + await self._broadcast_update("config") + return {"ok": True, "config": self._build_config_payload()} + + async def _load_config_schema_payload(self) -> dict[str, Any]: + schema_path = Path(__file__).resolve().parent.parent / "_conf_schema.json" + if schema_path.exists(): + try: + schema_text = await asyncio.to_thread( + schema_path.read_text, encoding="utf-8" + ) + return json.loads(schema_text) + except Exception as e: + logger.error(f"[主动消息] 读取 Schema 失败喵: {e}") + return {} + + def _list_session_config_payloads(self) -> list[dict[str, Any]]: + result = [] + for session in self._list_known_sessions(): + override = self.plugin.session_override_manager.get_override(session) + effective = self.plugin._get_session_config(session) + session_name = self.plugin._get_session_name(session, effective) + result.append( + { + "session": session, + "session_name": session_name, + "session_display_name": self.plugin._get_session_display_name( + session, effective + ), + "has_override": bool(override), + "override_keys": list(override.keys()), + "enabled": bool(effective and effective.get("enable", False)), + "next_trigger_time": self.plugin.session_data.get(session, {}).get( + "next_trigger_time" + ), + "unanswered_count": self.plugin.session_data.get(session, {}).get( + "unanswered_count", 0 + ), + } + ) + return result + + def _build_session_config_payload(self, umo: str) -> dict[str, Any]: + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) + base = self.plugin._get_base_session_config(normalized) + return { + "session": normalized, + "base": base, + "override": self.plugin.session_override_manager.get_override(normalized), + "effective": self.plugin._get_session_config(normalized), + } + + async def _apply_session_config_payload( + self, umo: str, payload: dict[str, Any] + ) -> dict[str, Any]: + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) + mode = payload.get("mode", "effective") + + if mode == "override": + # override 模式直接保存差异片段,适合高级用户或后续批量编辑入口。 + override = payload.get("override", {}) + if not isinstance(override, dict): + return {"ok": False, "error": "override 必须是对象"} + await self.plugin.session_override_manager.set_override( + normalized, override + ) + else: + # effective 模式让前端提交完整会话配置,后端负责计算与全局配置的最小差异。 + effective = payload.get("effective", {}) + if not isinstance(effective, dict): + return {"ok": False, "error": "effective 必须是对象"} + base = self.plugin._get_base_session_config(normalized) + if not base: + return { + "ok": False, + "error": "会话未命中 friend/group 全局配置,无法保存 effective", + } + await self.plugin.session_override_manager.update_session_from_effective( + normalized, + base, + effective, + ) + + await self._broadcast_update("session-config") + return { + "ok": True, + "session": normalized, + "override": self.plugin.session_override_manager.get_override(normalized), + "effective": self.plugin._get_session_config(normalized), + } + + async def _reset_session_config_payload(self, umo: str) -> dict[str, Any]: + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) + await self.plugin.session_override_manager.delete_override(normalized) + await self._broadcast_update("session-config") + return { + "ok": True, + "session": normalized, + "override": {}, + "effective": self.plugin._get_session_config(normalized), + } + + async def _reschedule_job_payload(self, umo: str) -> dict[str, Any]: + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) + session_config = self.plugin._get_session_config(normalized) + if not session_config or not session_config.get("enable", False): + return { + "ok": False, + "session": normalized, + "error": "会话未启用或配置不存在,无法重新调度", + } + + await self.plugin._schedule_next_chat_and_save(normalized, reset_counter=False) + await self._broadcast_update("jobs") + return { + "ok": True, + "session": normalized, + "message": "已重新调度下一次主动消息时间", + } + + async def _trigger_job_payload(self, umo: str) -> dict[str, Any]: + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) + if normalized in self.plugin.manual_trigger_sessions: + return { + "ok": False, + "session": normalized, + "in_progress": True, + "message": "该任务正在立即触发中,请等待当前执行完成", + } + + self.plugin.manual_trigger_sessions.add(normalized) + self._track_background_task( + asyncio.create_task(self.plugin.check_and_chat(normalized)) + ) + await self._broadcast_update("jobs") + return { + "ok": True, + "session": normalized, + "in_progress": True, + "message": "已开始立即触发,正在等待 LLM 完成回复", + } + + async def _cancel_job_payload(self, umo: str) -> dict[str, Any]: + normalized = self.plugin._normalize_session_id(self._decode_route_umo(umo)) + removed = False + try: + self.plugin.scheduler.remove_job(normalized) + removed = True + except Exception: + pass + + async with self.plugin.data_lock: + if normalized in self.plugin.session_data: + if self.plugin._clear_session_schedule_state(normalized): + await self.plugin._save_data_internal() + + await self._broadcast_update("jobs") + return {"ok": True, "session": normalized, "removed": removed} + + async def _mark_notification_read_payload( + self, payload: dict[str, Any] + ) -> dict[str, Any]: + if not getattr(self.plugin, "notification_center", None): + return {"ok": False, "error": "通知系统不可用"} + + notification_id = payload.get("id") + if notification_id is None: + return {"ok": False, "error": "缺少必填字段 id"} + try: + normalized_id = int(notification_id) + except (TypeError, ValueError): + return {"ok": False, "error": "id 必须是数字"} + + result = await self.plugin.notification_center.mark_as_read(normalized_id) + await self._broadcast_update("notifications") + return result + + async def _mark_all_notifications_read_payload(self) -> dict[str, Any]: + if not getattr(self.plugin, "notification_center", None): + return {"ok": False, "error": "通知系统不可用"} + result = await self.plugin.notification_center.mark_all_as_read() + await self._broadcast_update("notifications") + return result + + async def _refresh_notifications_payload(self) -> dict[str, Any]: + if not getattr(self.plugin, "notification_center", None): + return {"ok": False, "error": "通知系统不可用"} + changed = await self.plugin.notification_center.refresh() + await self._broadcast_update("notifications") + payload = await self.plugin.notification_center.get_payload() + return { + "ok": True, + "changed": changed, + "items": payload.get("items", []), + "meta": payload.get("meta", {}), + } + + async def _build_markdown_file_payload(self, file_path: str) -> dict[str, Any]: + resolved = self._resolve_markdown_document(file_path) + if not resolved: + return {"ok": False, "error": "文档不存在或不允许访问"} + + try: + content = await asyncio.to_thread(resolved.read_text, encoding="utf-8") + except UnicodeDecodeError: + return { + "ok": False, + "error": "文档编码不受支持,仅支持 UTF-8 Markdown 文件", + } + except Exception as e: + logger.error(f"[主动消息] 读取 Markdown 文档失败喵: {e}") + return {"ok": False, "error": "读取文档失败", "message": str(e)} + + return { + "path": self._to_workspace_relative_path(resolved), + "title": resolved.stem, + "content": content, + "content_format": "markdown", + } + + async def _open_directory_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + target = str(payload.get("path", "plugin")).strip().lower() + directory = ( + Path(self.plugin.data_dir) + if target == "data" + else Path(__file__).resolve().parent.parent + ) + + try: + directory.mkdir(parents=True, exist_ok=True) + dir_str = str(directory) + + if _is_running_in_docker(): + return { + "ok": False, + "error": "Docker 环境下不支持在宿主机直接打开目录,请手动查看挂载路径", + "path": dir_str, + } + + if os.name == "nt": + await asyncio.to_thread(os.startfile, dir_str) + elif sys.platform == "darwin": + result = await asyncio.to_thread( + subprocess.run, + ["open", dir_str], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "未知错误").strip() + return { + "ok": False, + "error": "打开目录失败(macOS)", + "message": f"open 命令执行失败: {detail}", + "path": dir_str, + } + else: + result = await asyncio.to_thread( + subprocess.run, + ["xdg-open", dir_str], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "未知错误").strip() + return { + "ok": False, + "error": "打开目录失败(Linux)", + "message": ( + "xdg-open 执行失败,服务器可能缺少桌面环境或未安装 xdg-open: " + f"{detail}" + ), + "path": dir_str, + } + + return { + "ok": True, + "path": dir_str, + "message": "已在系统文件管理器中打开目录", + } + except Exception as e: + logger.error(f"[主动消息] 打开目录失败喵: {e}") + return { + "ok": False, + "error": "打开目录失败", + "message": str(e), + "path": str(directory), + } + def _safe_timer_meta(self, timer: Any, now: float) -> dict[str, float | int | None]: # 某些会话可能当前没有有效 timer,此时直接返回空元信息。 if timer is None: @@ -941,6 +1546,115 @@ def _collect_timer_cards(self, now: float) -> dict[str, list[dict[str, Any]]]: ) # 统一按剩余时间升序排序,让最接近触发的卡片优先显示。 + live_auto_sessions = { + str(card.get("session_id", "")) for card in auto_cards if card.get("session_id") + } + live_group_sessions = { + str(card.get("session_id", "")) + for card in group_cards + if card.get("session_id") + } + + # Expose configured sessions even when no asyncio timer handle is currently + # registered. Without these placeholders the Pages dashboard looks empty, + # while the real state is often "waiting for the first message/event". + for session_id in self._list_known_sessions(): + normalized_session_id = self.plugin._normalize_session_id(str(session_id)) + session_config = self.plugin._get_session_config(normalized_session_id) + if not session_config or not session_config.get("enable", False): + continue + + session_category = self._detect_session_category(normalized_session_id) + session_data = self.plugin.session_data.get( + normalized_session_id, self.plugin.session_data.get(str(session_id), {}) + ) + schedule_settings = session_config.get("schedule_settings", {}) + context_settings = session_config.get("context_settings", {}) + common_payload = { + "session_id": normalized_session_id, + "session_name": self.plugin._get_session_name( + normalized_session_id, session_config + ), + "session_display_name": self.plugin._get_session_display_name( + normalized_session_id, session_config + ), + "session_category": session_category, + "source_mode": context_settings.get( + "source_mode", + "platform_message_history" + if session_category == "group" + else "conversation_history", + ), + "max_unanswered_times": schedule_settings.get( + "max_unanswered_times", 0 + ), + "remaining_seconds": None, + "target_time": None, + "progress_percent": 0, + "unanswered_count": session_data.get("unanswered_count", 0), + } + + if session_category == "group": + if normalized_session_id in live_group_sessions: + continue + + idle_minutes = int( + session_config.get("group_idle_trigger_minutes", 0) or 0 + ) + if idle_minutes <= 0: + continue + + group_cards.append( + { + **common_payload, + "timer_kind": "group_silence", + "title": "群沉默检测", + "timer_kind_label": "群沉默检测", + "status": "waiting_message", + "window_seconds": idle_minutes * 60, + "group_idle_trigger_minutes": idle_minutes, + "last_message_time": self.plugin.last_message_times.get( + normalized_session_id + ) + or None, + "inactive_reason": "等待群聊新消息后开始沉默倒计时", + "is_live_group_timer": False, + } + ) + live_group_sessions.add(normalized_session_id) + continue + + if normalized_session_id in live_auto_sessions: + continue + + auto_settings = session_config.get("auto_trigger_settings", {}) + if not auto_settings.get("enable_auto_trigger", False): + continue + + trigger_delay_minutes = int( + auto_settings.get("auto_trigger_after_minutes", 0) or 0 + ) + if trigger_delay_minutes <= 0: + continue + + last_message_time = self.plugin.last_message_times.get( + normalized_session_id + ) or self.plugin.last_message_times.get(str(session_id), 0) + auto_cards.append( + { + **common_payload, + "timer_kind": "auto_trigger", + "title": "自动触发检测", + "timer_kind_label": "自动触发检测", + "status": "waiting_idle" if last_message_time else "pending_timer", + "window_seconds": trigger_delay_minutes * 60, + "auto_trigger_after_minutes": trigger_delay_minutes, + "last_message_time": last_message_time or None, + "inactive_reason": "当前没有运行中的自动触发计时器,等待运行时注册或下一次消息事件", + } + ) + live_auto_sessions.add(normalized_session_id) + auto_cards.sort( key=lambda item: ( item.get("remaining_seconds") is None, @@ -964,6 +1678,7 @@ def _build_status_payload(self) -> dict[str, Any]: now = time.time() uptime_sec = max(0, int(now - self.plugin.plugin_start_time)) timer_cards = self._collect_timer_cards(now) + visible_jobs_count = len(self._collect_jobs()) return { "running": True, @@ -984,9 +1699,7 @@ def _build_status_payload(self) -> dict[str, Any]: "sessions_count": len(self.plugin.session_data), "auto_trigger_timers": len(self.plugin.auto_trigger_timers), "group_timers": len(self.plugin.group_timers), - "jobs_count": len(self.plugin.scheduler.get_jobs()) - if self.plugin.scheduler - else 0, + "jobs_count": visible_jobs_count, # 计时器总数在前端可直接用于角标和标题,无需再做两次求和。 "timer_cards_total": len(timer_cards["auto_trigger_cards"]) + len(timer_cards["group_timer_cards"]), @@ -997,21 +1710,54 @@ def _build_status_payload(self) -> dict[str, Any]: "timestamp": datetime.now().isoformat(), } - def _collect_jobs(self) -> list[dict[str, Any]]: - if not self.plugin.scheduler: - return [] + def _build_web_admin_url(self) -> str | None: + web_admin = self.config.get("web_admin", {}) + if not web_admin.get("enabled", False): + return None + + host = str(web_admin.get("host", "127.0.0.1") or "127.0.0.1") + port = int(web_admin.get("port", 4100) or 4100) + display_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host + return f"http://{display_host}:{port}/" + + async def build_astrbot_page_payload(self) -> dict[str, Any]: + """构造 AstrBot 插件卡片 Page 的轻量运行快照。""" + return { + "ok": True, + "status": self._build_status_payload(), + "jobs": self._collect_jobs(), + "sessions": self._list_known_session_summaries(), + "web_admin": { + "available": bool(self._web_admin_available and self.app), + "enabled": bool(self.config.get("web_admin", {}).get("enabled", False)), + "url": self._build_web_admin_url(), + "auth_required": self._auth_enabled, + }, + "timestamp": datetime.now().isoformat(), + } + def _collect_jobs(self) -> list[dict[str, Any]]: jobs = [] - for job in self.plugin.scheduler.get_jobs(): + seen_sessions: set[str] = set() + + for job in self.plugin.scheduler.get_jobs() if self.plugin.scheduler else []: session_id = str(job.id) + normalized_session_id = self.plugin._normalize_session_id(session_id) + seen_sessions.add(normalized_session_id) session_data = self.plugin.session_data.get(session_id, {}) session_config = self.plugin._get_session_config(session_id) or {} schedule_settings = session_config.get("schedule_settings", {}) context_settings = session_config.get("context_settings", {}) - auto_trigger_settings = session_config.get("auto_trigger_settings", {}) + unanswered_count = session_data.get("unanswered_count", 0) + if self.plugin._is_unanswered_limit_reached( + normalized_session_id, session_config, unanswered_count + ): + continue jobs.append( { "id": session_id, + "status": "scheduled", + "status_label": "已调度", "session_name": self.plugin._get_session_name( session_id, session_config ), @@ -1022,15 +1768,14 @@ def _collect_jobs(self) -> list[dict[str, Any]]: "source_mode": context_settings.get( "source_mode", "conversation_history" ), - "max_unanswered_times": schedule_settings.get( - "max_unanswered_times", - auto_trigger_settings.get("max_unanswered_times", 0), + "max_unanswered_times": self.plugin._get_max_unanswered_count( + session_config ), # APScheduler 的 next_run_time 是 datetime,这里统一序列化为 ISO 字符串。 "next_run_time": ( job.next_run_time.isoformat() if job.next_run_time else None ), - "unanswered_count": session_data.get("unanswered_count", 0), + "unanswered_count": unanswered_count, "manual_trigger_in_progress": session_id in self.plugin.manual_trigger_sessions, # 以下字段用于前端推导进度条与调度窗口说明。 @@ -1045,6 +1790,12 @@ def _collect_jobs(self) -> list[dict[str, Any]]: "last_schedule_random_interval_seconds": session_data.get( "last_schedule_random_interval_seconds" ), + "last_schedule_strategy": session_data.get( + "last_schedule_strategy" + ), + "last_schedule_reason": session_data.get("last_schedule_reason"), + "last_schedule_rule": session_data.get("last_schedule_rule"), + "last_schedule_source": session_data.get("last_schedule_source"), # 透出当前会话配置中的调度区间与免打扰时段,供任务卡片展示。 "schedule_min_interval_minutes": schedule_settings.get( "min_interval_minutes" @@ -1055,6 +1806,96 @@ def _collect_jobs(self) -> list[dict[str, Any]]: "quiet_hours": schedule_settings.get("quiet_hours", ""), } ) + + for session_id in self._list_known_sessions(): + normalized_session_id = self.plugin._normalize_session_id(str(session_id)) + if normalized_session_id in seen_sessions: + continue + + session_config = self.plugin._get_session_config(normalized_session_id) + if not session_config or not session_config.get("enable", False): + continue + + session_data = self.plugin.session_data.get( + normalized_session_id, self.plugin.session_data.get(str(session_id), {}) + ) + schedule_settings = session_config.get("schedule_settings", {}) + context_settings = session_config.get("context_settings", {}) + unanswered_count = session_data.get("unanswered_count", 0) + if self.plugin._is_unanswered_limit_reached( + normalized_session_id, session_config, unanswered_count + ): + continue + next_trigger_time = session_data.get("next_trigger_time") + next_run_time = None + if next_trigger_time: + try: + next_run_time = datetime.fromtimestamp( + float(next_trigger_time), + tz=getattr(self.plugin, "timezone", None), + ).isoformat() + except Exception: + next_run_time = None + + jobs.append( + { + "id": normalized_session_id, + "status": "pending_schedule", + "status_label": "待调度", + "session_name": self.plugin._get_session_name( + normalized_session_id, session_config + ), + "session_display_name": self.plugin._get_session_display_name( + normalized_session_id, session_config + ), + "session_category": self._detect_session_category( + normalized_session_id + ), + "source_mode": context_settings.get( + "source_mode", "conversation_history" + ), + "max_unanswered_times": self.plugin._get_max_unanswered_count( + session_config + ), + "next_run_time": next_run_time, + "unanswered_count": unanswered_count, + "manual_trigger_in_progress": normalized_session_id + in self.plugin.manual_trigger_sessions, + "next_trigger_time": next_trigger_time, + "last_scheduled_at": session_data.get("last_scheduled_at"), + "last_schedule_min_interval_seconds": session_data.get( + "last_schedule_min_interval_seconds" + ), + "last_schedule_max_interval_seconds": session_data.get( + "last_schedule_max_interval_seconds" + ), + "last_schedule_random_interval_seconds": session_data.get( + "last_schedule_random_interval_seconds" + ), + "last_schedule_strategy": session_data.get( + "last_schedule_strategy" + ), + "last_schedule_reason": session_data.get("last_schedule_reason"), + "last_schedule_rule": session_data.get("last_schedule_rule"), + "last_schedule_source": session_data.get("last_schedule_source"), + "schedule_min_interval_minutes": schedule_settings.get( + "min_interval_minutes" + ), + "schedule_max_interval_minutes": schedule_settings.get( + "max_interval_minutes" + ), + "quiet_hours": schedule_settings.get("quiet_hours", ""), + "inactive_reason": "当前没有 APScheduler 任务,可能正在等待下一次触发条件或需要重新调度", + } + ) + + jobs.sort( + key=lambda item: ( + item.get("next_run_time") is None, + item.get("next_run_time") or "", + item.get("id") or "", + ) + ) return jobs def _list_known_sessions(self) -> list[str]: @@ -1077,6 +1918,9 @@ def _list_known_session_summaries(self) -> list[dict[str, Any]]: result: list[dict[str, Any]] = [] for session in self._list_known_sessions(): effective = self.plugin._get_session_config(session) + session_data = self.plugin.session_data.get(session, {}) + schedule_settings = (effective or {}).get("schedule_settings", {}) + auto_trigger_settings = (effective or {}).get("auto_trigger_settings", {}) result.append( { "session": session, @@ -1091,8 +1935,39 @@ def _list_known_session_summaries(self) -> list[dict[str, Any]]: "unanswered_count": self.plugin.session_data.get(session, {}).get( "unanswered_count", 0 ), + "max_unanswered_times": self.plugin._get_max_unanswered_count( + effective + ), "manual_trigger_in_progress": session in self.plugin.manual_trigger_sessions, + "enabled": bool(effective and effective.get("enable", False)), + "session_category": self._detect_session_category(session), + "next_trigger_time": session_data.get("next_trigger_time"), + "last_scheduled_at": session_data.get("last_scheduled_at"), + "last_schedule_min_interval_seconds": session_data.get( + "last_schedule_min_interval_seconds" + ), + "last_schedule_max_interval_seconds": session_data.get( + "last_schedule_max_interval_seconds" + ), + "last_schedule_random_interval_seconds": session_data.get( + "last_schedule_random_interval_seconds" + ), + "last_schedule_strategy": session_data.get( + "last_schedule_strategy" + ), + "last_schedule_reason": session_data.get("last_schedule_reason"), + "last_schedule_rule": session_data.get("last_schedule_rule"), + "last_schedule_source": session_data.get("last_schedule_source"), + "schedule_min_interval_minutes": schedule_settings.get( + "min_interval_minutes" + ), + "schedule_max_interval_minutes": schedule_settings.get( + "max_interval_minutes" + ), + "auto_trigger_after_minutes": auto_trigger_settings.get( + "auto_trigger_after_minutes" + ), } ) return result diff --git a/pages/proactive-chat/css/style.css b/pages/proactive-chat/css/style.css new file mode 100644 index 0000000..193bf08 --- /dev/null +++ b/pages/proactive-chat/css/style.css @@ -0,0 +1,3540 @@ +/* Material Design 3 - 主动消息主题 */ +:root { + /* 整个管理端的设计令牌入口:亮色模式下所有组件都从这里读取颜色变量。 */ + /* --- MD3 颜色令牌 (紫色种子) --- */ + --md-sys-color-primary: #6750A4; + --md-sys-color-on-primary: #FFFFFF; + --md-sys-color-primary-container: #EADDFF; + --md-sys-color-on-primary-container: #21005D; + + --md-sys-color-secondary: #625B71; + --md-sys-color-on-secondary: #FFFFFF; + --md-sys-color-secondary-container: #E8DEF8; + --md-sys-color-on-secondary-container: #1D192B; + + --md-sys-color-surface: rgba(255, 255, 255, 0.8); + --md-sys-color-on-surface: #1C1B1F; + --md-sys-color-on-surface-variant: #49454F; + --md-sys-color-surface-variant: #E7E0EC; + + --md-sys-color-background: #FEF7FF; + --md-sys-color-outline-variant: rgba(103, 80, 164, 0.1); + + --glass-bg: rgba(255, 255, 255, 0.65); + --glass-border: rgba(103, 80, 164, 0.15); + --glass-shadow: 0 4px 20px 0 rgba(103, 80, 164, 0.08); + + /* Markdown 代码高亮颜色令牌,供亮暗主题统一覆写。 */ + --md-code-token-key: #7C3AED; + --md-code-token-string: #047857; + --md-code-token-number: #B45309; + --md-code-token-boolean: #C026D3; + --md-code-token-keyword: #2563EB; + --md-code-token-function: #7C2D12; + --md-code-token-command: #4338CA; + --md-code-token-flag: #0F766E; + --md-code-token-punctuation: rgba(28, 27, 31, 0.52); + + --theme-transition-duration: 220ms; + --interactive-transition-duration: 180ms; + --theme-transition-easing: cubic-bezier(0.4, 0, 0.2, 1); + + /* --- 形状 --- */ + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 20px; + --radius-xl: 28px; + + /* --- 顶栏 --- */ + --top-bar-height: 80px; +} + +/* 暗色主题通过覆盖 CSS 变量切换外观,尽量避免为每个组件单独写一套颜色。 */ +body.dark-theme { + --md-sys-color-primary: #D0BCFF; + --md-sys-color-on-primary: #381E72; + --md-sys-color-primary-container: #4F378B; + --md-sys-color-on-primary-container: #EADDFF; + + --md-sys-color-surface: rgba(28, 27, 31, 0.8); + --md-sys-color-on-surface: #E6E1E5; + --md-sys-color-on-surface-variant: #CAC4D0; + --md-sys-color-surface-variant: #49454F; + + --md-sys-color-background: #141218; + --md-sys-color-outline-variant: rgba(208, 188, 255, 0.1); + + --glass-bg: rgba(28, 27, 31, 0.7); + --glass-border: rgba(208, 188, 255, 0.1); + --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4); + + --md-code-token-key: #C4B5FD; + --md-code-token-string: #6EE7B7; + --md-code-token-number: #FBBF24; + --md-code-token-boolean: #F0ABFC; + --md-code-token-keyword: #93C5FD; + --md-code-token-function: #FDBA74; + --md-code-token-command: #A5B4FC; + --md-code-token-flag: #5EEAD4; + --md-code-token-punctuation: rgba(236, 231, 246, 0.56); +} + +/* 首屏主题预初始化(在 React 挂载前生效,减少 FOUC) */ +html.theme-dark body { + --md-sys-color-primary: #D0BCFF; + --md-sys-color-on-primary: #381E72; + --md-sys-color-primary-container: #4F378B; + --md-sys-color-on-primary-container: #EADDFF; + + --md-sys-color-surface: rgba(28, 27, 31, 0.8); + --md-sys-color-on-surface: #E6E1E5; + --md-sys-color-on-surface-variant: #CAC4D0; + --md-sys-color-surface-variant: #49454F; + + --md-sys-color-background: #141218; + --md-sys-color-outline-variant: rgba(208, 188, 255, 0.1); + + --glass-bg: rgba(28, 27, 31, 0.7); + --glass-border: rgba(208, 188, 255, 0.1); + --glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4); + + --md-code-token-key: #C4B5FD; + --md-code-token-string: #6EE7B7; + --md-code-token-number: #FBBF24; + --md-code-token-boolean: #F0ABFC; + --md-code-token-keyword: #93C5FD; + --md-code-token-function: #FDBA74; + --md-code-token-command: #A5B4FC; + --md-code-token-flag: #5EEAD4; + --md-code-token-punctuation: rgba(236, 231, 246, 0.56); +} + +/* ===== 基础重置与全局交互层 ===== */ +/* 基础样式 */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* 仅在主题切换瞬间开启统一过渡,避免常驻 transition 拖慢滚动与重绘 */ +html.theme-transitioning body, +html.theme-transitioning .app, +html.theme-transitioning .sidebar, +html.theme-transitioning .main-content, +html.theme-transitioning .card, +html.theme-transitioning .config-card, +html.theme-transitioning .config-header, +html.theme-transitioning .top-bar::before, +html.theme-transitioning .nav-item, +html.theme-transitioning .btn, +html.theme-transitioning .btn-action, +html.theme-transitioning .sidebar-action-btn, +html.theme-transitioning .sidebar-github-inner, +html.theme-transitioning .header-clock-chip, +html.theme-transitioning .connection-chip, +html.theme-transitioning .status-timers-summary-pill, +html.theme-transitioning .status-timer-section-block, +html.theme-transitioning .status-timer-panel, +html.theme-transitioning .status-timer-info-item, +html.theme-transitioning .task-next-run-panel, +html.theme-transitioning input, +html.theme-transitioning select, +html.theme-transitioning textarea, +html.theme-transitioning button, +html.theme-transitioning .MuiPaper-root, +html.theme-transitioning .MuiAccordion-root, +html.theme-transitioning .MuiAccordionSummary-root, +html.theme-transitioning .MuiAccordionDetails-root, +html.theme-transitioning .MuiTypography-root, +html.theme-transitioning .MuiChip-root, +html.theme-transitioning .MuiButton-root, +html.theme-transitioning .MuiOutlinedInput-root, +html.theme-transitioning .MuiInputBase-input, +html.theme-transitioning .MuiSvgIcon-root { + transition: + background-color var(--theme-transition-duration) var(--theme-transition-easing), + color var(--theme-transition-duration) var(--theme-transition-easing), + border-color var(--theme-transition-duration) var(--theme-transition-easing), + fill var(--theme-transition-duration) var(--theme-transition-easing), + stroke var(--theme-transition-duration) var(--theme-transition-easing); +} + + +/* 移动端触摸优化 */ +html { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + touch-action: manipulation; +} + +body { + /* 背景使用多层径向渐变,营造轻量的 MD3 柔和氛围,而不是纯色平铺。 */ + background-color: var(--md-sys-color-background); + background-image: + radial-gradient(at 0% 0%, rgba(103, 80, 164, 0.08) 0, transparent 40%), + radial-gradient(at 50% 0%, rgba(236, 72, 153, 0.05) 0, transparent 40%), + radial-gradient(at 100% 0%, rgba(103, 80, 164, 0.08) 0, transparent 40%); + background-attachment: fixed; + color: var(--md-sys-color-on-surface); + font-family: 'Outfit', "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + min-height: 100vh; + overflow-x: hidden; +} + +/* 无障碍:仅供读屏器读取 */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ===== 首屏启动层 ===== */ +/* 首屏加载页 */ +.boot-loader { + /* 启动层覆盖整个视口,在 React 尚未挂载时承担品牌露出与状态提示职责。 */ + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: #1C1B1F; + font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: + radial-gradient(ellipse at 20% 15%, rgba(103, 80, 164, 0.14), transparent 52%), + radial-gradient(ellipse at 80% 85%, rgba(103, 80, 164, 0.09), transparent 52%), + #FEF7FF; + opacity: 1; + transform: translateZ(0); + will-change: opacity, transform; +} + +html.theme-dark .boot-loader, +body.dark-theme .boot-loader { + color: #E6E1E5; + background: + radial-gradient(ellipse at 20% 15%, rgba(208, 188, 255, 0.13), transparent 52%), + radial-gradient(ellipse at 80% 85%, rgba(103, 80, 164, 0.18), transparent 52%), + #141218; +} + +#loading-skeleton.is-exiting { + opacity: 0; + transform: scale(0.985); + transition: opacity 0.3s ease-out, transform 0.3s ease-out; +} + +.boot-loader__logo { + /* Logo 外壳带轻微浮动动画,用于缓解静态等待时的“卡住感”。 */ + width: 80px; + height: 80px; + border-radius: 20px; + margin-bottom: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(103, 80, 164, 0.08); + border: 1px solid rgba(103, 80, 164, 0.18); + box-shadow: 0 4px 24px rgba(103, 80, 164, 0.12); + overflow: hidden; + animation: boot-loader-float 2.4s ease-in-out infinite; + will-change: transform; +} + +.boot-loader__logo img { + width: 56px; + height: 56px; + object-fit: contain; + border-radius: 8px; +} + +html.theme-dark .boot-loader__logo, +body.dark-theme .boot-loader__logo { + background: rgba(208, 188, 255, 0.08); + border-color: rgba(208, 188, 255, 0.15); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35); +} + +.boot-loader__title { + font-size: 22px; + font-weight: 700; + margin-bottom: 8px; + letter-spacing: 0.01em; + color: #1C1B1F; +} + +html.theme-dark .boot-loader__title, +body.dark-theme .boot-loader__title { + color: #E6E1E5; +} + +.boot-loader__subtitle { + font-size: 13px; + color: #49454F; + margin-bottom: 28px; +} + +html.theme-dark .boot-loader__subtitle, +body.dark-theme .boot-loader__subtitle { + color: #CAC4D0; +} + +.boot-loader__progress { + /* 进度条不反映真实百分比,只承担“系统仍在工作”的感知反馈。 */ + width: min(260px, 68vw); + height: 3px; + border-radius: 999px; + overflow: hidden; + background: rgba(103, 80, 164, 0.12); + contain: paint; +} + +html.theme-dark .boot-loader__progress, +body.dark-theme .boot-loader__progress { + background: rgba(208, 188, 255, 0.12); +} + +.boot-loader__progress-bar { + height: 100%; + width: 42%; + border-radius: 999px; + background: linear-gradient(90deg, + rgba(103, 80, 164, 0.25) 0%, + rgba(103, 80, 164, 0.85) 50%, + rgba(103, 80, 164, 0.25) 100%); + animation: boot-loader-progress 1.45s ease-in-out infinite; + will-change: transform; + transform: translate3d(-110%, 0, 0) scaleX(0.35); +} + +html.theme-dark .boot-loader__progress-bar, +body.dark-theme .boot-loader__progress-bar { + background: linear-gradient(90deg, + rgba(208, 188, 255, 0.2) 0%, + rgba(208, 188, 255, 0.9) 50%, + rgba(208, 188, 255, 0.2) 100%); +} + +/* 首屏 logo 呼吸浮动动画。 */ +@keyframes boot-loader-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-7px); } +} + +/* 首屏进度条往返扫光动画。 */ +@keyframes boot-loader-progress { + 0% { transform: translate3d(-110%, 0, 0) scaleX(0.35); } + 50% { transform: translate3d(40%, 0, 0) scaleX(0.85); } + 100% { transform: translate3d(220%, 0, 0) scaleX(0.35); } +} + +.boot-login { + display: none; + width: min(360px, calc(100vw - 48px)); + margin: 16px auto 0; + text-align: left; +} + +.boot-login__label { + display: block; + margin-bottom: 8px; + font-size: 13px; + color: #625B71; +} + +.boot-login__input { + width: 100%; + box-sizing: border-box; + height: 44px; + padding: 0 14px; + border: 1px solid rgba(103, 80, 164, 0.22); + border-radius: 14px; + background: rgba(255, 255, 255, 0.88); + color: #1C1B1F; + outline: none; + font-size: 14px; + box-shadow: 0 8px 24px rgba(103, 80, 164, 0.08); +} + +.boot-login__input:focus { + border-color: rgba(103, 80, 164, 0.5); + box-shadow: 0 0 0 3px rgba(103, 80, 164, 0.12), 0 8px 24px rgba(103, 80, 164, 0.08); +} + +.boot-login__button { + width: 100%; + margin-top: 12px; + height: 44px; + border: none; + border-radius: 14px; + background: linear-gradient(135deg, #7F67BE 0%, #6750A4 100%); + color: #fff; + font-size: 14px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 10px 24px rgba(103, 80, 164, 0.22); +} + +.boot-login__button:disabled { + cursor: not-allowed; +} + +html.theme-dark .boot-login__label, +body.dark-theme .boot-login__label { + color: #CAC4D0; +} + +html.theme-dark .boot-login__input, +body.dark-theme .boot-login__input { + background: rgba(28, 27, 31, 0.88); + color: #E6E1E5; + border-color: rgba(208, 188, 255, 0.22); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.24); +} + +html.theme-dark .boot-login__input:focus, +body.dark-theme .boot-login__input:focus { + border-color: rgba(208, 188, 255, 0.48); + box-shadow: 0 0 0 3px rgba(208, 188, 255, 0.16), 0 8px 24px rgba(0, 0, 0, 0.24); +} + +html.theme-dark .boot-login__button, +body.dark-theme .boot-login__button { + background: linear-gradient(135deg, #A58CFF 0%, #7F67BE 100%); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28); +} + +/* ===== 通用材质与布局骨架 ===== */ +/* 磨砂玻璃效果 */ +.glass { + /* 该类可供需要独立套用玻璃材质的区域复用。 */ + background: var(--glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); +} + +/* 布局 */ +.app { + /* 整体采用“左侧导航 + 右侧主工作区”的桌面管理台结构。 */ + display: flex; + height: 100vh; + overflow: hidden; +} + +/* ===== 侧边栏导航区 ===== */ +/* 侧边栏 (左侧导航) */ +.sidebar { + /* 侧边栏固定宽度,使用玻璃材质与主内容区形成层次区隔。 */ + width: 280px; + height: 100%; + background: var(--glass-bg); + backdrop-filter: blur(24px); + border-right: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + padding: 32px 16px; + z-index: 50; +} + +.sidebar-header { + padding: 0 16px 32px; + display: flex; + align-items: center; + gap: 12px; +} + +.sidebar-logo-img { + width: 48px; + height: 48px; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(103, 80, 164, 0.2); + object-fit: contain; +} + +.nav-item { + /* 单个导航项在 hover / active 状态下通过背景与透明度强化当前定位。 */ + padding: 14px 20px; + border-radius: 16px; + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + transition: + background-color var(--interactive-transition-duration) var(--theme-transition-easing), + color var(--interactive-transition-duration) var(--theme-transition-easing), + opacity var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing); + color: var(--md-sys-color-on-surface); + opacity: 0.8; + margin-bottom: 4px; +} + +.sidebar-notification-badge { + margin-left: auto; + min-width: 22px; + height: 22px; + padding: 0 7px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #FF6B6B, #E53935); + color: #FFFFFF; + font-size: 11px; + font-weight: 800; + line-height: 1; + letter-spacing: 0.01em; + box-shadow: 0 8px 18px rgba(229, 57, 53, 0.22); + border: 1px solid rgba(255, 255, 255, 0.42); + flex-shrink: 0; +} + +.nav-item.active .sidebar-notification-badge { + background: linear-gradient(135deg, #FF5A5F, #D32F2F); + box-shadow: 0 8px 18px rgba(211, 47, 47, 0.24); +} + +.nav-item:hover { + background: rgba(103, 80, 164, 0.08); + opacity: 1; +} + +.nav-item.active { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + opacity: 1; + font-weight: 600; +} + +/* ===== 顶栏与主内容区 ===== */ +/* 主内容区域 */ +.main-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +.top-bar { + /* 顶栏悬浮于内容区上方,使用伪元素实现半透明模糊遮罩。 */ + height: var(--top-bar-height); + padding: 0 40px; + display: flex; + align-items: center; + justify-content: space-between; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + background: transparent; + overflow: hidden; + isolation: isolate; +} + +.top-bar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + background: rgba(255, 255, 255, 0.68); + -webkit-mask-image: linear-gradient(to bottom, black 55%, transparent 100%); + mask-image: linear-gradient(to bottom, black 55%, transparent 100%); + pointer-events: none; + z-index: 0; +} + +body.dark-theme .top-bar::before { + background: rgba(20, 18, 24, 0.88); +} + +.top-bar > * { + position: relative; + z-index: 1; +} + +.main-content { + /* 顶部留出 top-bar 高度,避免内容滚动时被悬浮顶栏遮挡。 */ + flex: 1; + overflow-y: auto; + padding: calc(var(--top-bar-height) + 16px) 24px 24px; + box-sizing: border-box; +} + +/* ===== 仪表盘卡片与通用容器 ===== */ +/* 仪表盘卡片 (Bento 风格) */ +.dashboard-grid { + /* 12 栏网格为桌面端默认布局基础,状态页和任务页都在此之上做跨度分配。 */ + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 24px; +} + +.card { + /* card 是管理端最常见的视觉容器:统一圆角、边框、阴影和悬浮反馈。 */ + background: var(--md-sys-color-surface); + border-radius: var(--radius-lg); + padding: 24px; + border: 1px solid var(--glass-border); + box-shadow: var(--glass-shadow); + transition: + background-color var(--theme-transition-duration) var(--theme-transition-easing), + border-color var(--theme-transition-duration) var(--theme-transition-easing), + color var(--theme-transition-duration) var(--theme-transition-easing), + box-shadow var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing); +} + +/* 为前端的卡片提供统一的入场动画,减少页面首次渲染时的生硬感。 */ +.status-panel-card, +.tasks-summary-card, +.task-card-enhanced, +.status-timer-card, +.status-timer-section-block, +.tasks-empty-card, +.status-timers-empty-card, +.notifications-hero-card, +.notifications-empty-card, +.markdown-docs-sidebar-card, +.markdown-docs-content-card, +.markdown-docs-empty-card, +.markdown-docs-empty-side-card, +.config-card, +.config-header, +.config-renderer-shell, +.config-renderer-main, +.config-renderer-aside, +.config-renderer-form-card, +.config-renderer-preview-card, +.config-renderer-session-card, +.notifications-inline-meta-item, +.markdown-docs-file-item { + animation: card-fade-up 0.36s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.status-panel-card:nth-child(1), +.task-card-enhanced:nth-child(1), +.status-timer-card:nth-child(1), +.status-timer-section-block:nth-child(1) { + animation-delay: 0.02s; +} + +.status-panel-card:nth-child(2), +.task-card-enhanced:nth-child(2), +.status-timer-card:nth-child(2), +.status-timer-section-block:nth-child(2) { + animation-delay: 0.06s; +} + +.status-panel-card:nth-child(3), +.task-card-enhanced:nth-child(3), +.status-timer-card:nth-child(3), +.status-timer-section-block:nth-child(3) { + animation-delay: 0.1s; +} + +.status-panel-card:nth-child(n+4), +.task-card-enhanced:nth-child(n+4), +.status-timer-card:nth-child(n+4), +.status-timer-section-block:nth-child(n+4) { + animation-delay: 0.14s; +} + +.notifications-hero-card, +.config-header, +.markdown-docs-sidebar-card, +.config-renderer-aside { + animation-delay: 0.02s; +} + +.markdown-docs-content-card, +.config-renderer-main, +.config-renderer-form-card, +.config-renderer-session-card { + animation-delay: 0.08s; +} + +.config-card, +.config-renderer-shell, +.config-renderer-preview-card, +.notifications-empty-card, +.markdown-docs-empty-card, +.markdown-docs-empty-side-card { + animation-delay: 0.12s; +} + +.notifications-inline-meta-item:nth-child(1), +.markdown-docs-file-item:nth-child(1), +.config-card .MuiAccordion-root:nth-child(1), +.config-card .MuiPaper-root:nth-child(1) { + animation-delay: 0.03s; +} + +.notifications-inline-meta-item:nth-child(2), +.markdown-docs-file-item:nth-child(2), +.config-card .MuiAccordion-root:nth-child(2), +.config-card .MuiPaper-root:nth-child(2) { + animation-delay: 0.07s; +} + +.markdown-docs-file-item:nth-child(n+3), +.config-card .MuiAccordion-root:nth-child(n+3), +.config-card .MuiPaper-root:nth-child(n+3) { + animation-delay: 0.11s; +} + +/* ===== 配置页容器 ===== */ +/* 配置页面专用卡片样式 - 固定高度,内部滚动 */ +.config-card { + /* 配置页需要长表单滚动,因此外层卡片固定高度、内部区域自行滚动。 */ + height: calc(100vh - var(--top-bar-height) - 40px); /* 视口高度减去顶部栏和底部边距 */ + display: flex; + flex-direction: column; + padding: 0 !important; /*以此接管padding控制*/ + overflow: hidden; +} + +.config-header { + /* 配置头部固定在卡片顶部,确保模式切换、提示信息和标题不随表单滚走。 */ + padding: 20px 24px; + flex-shrink: 0; + border-bottom: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(8px); + z-index: 10; +} + +body.dark-theme .config-header { + background: rgba(20, 18, 24, 0.8); /* 增加不透明度,减弱透底亮度 */ + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 30px 0 rgba(103, 80, 164, 0.12); +} + +/* Bento 跨列:供各页面按 12 栏系统快速声明卡片宽度。 */ +.span-4 { grid-column: span 4; } +.span-8 { grid-column: span 8; } +.span-12 { grid-column: span 12; } + +/* 状态徽章 */ +.badge { + padding: 4px 12px; + border-radius: 99px; + font-size: 12px; + font-weight: 600; +} + +.badge-success { background: #dcfce7; color: #166534; } +.badge-error { background: #fee2e2; color: #991b1b; } + +/* ===== 通用按钮与交互控件 ===== */ +/* 按钮 */ +.btn { + padding: 10px 20px; + border-radius: 12px; + border: none; + font-weight: 600; + cursor: pointer; + transition: + background-color var(--interactive-transition-duration) var(--theme-transition-easing), + border-color var(--interactive-transition-duration) var(--theme-transition-easing), + color var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing), + box-shadow var(--interactive-transition-duration) var(--theme-transition-easing), + opacity var(--interactive-transition-duration) var(--theme-transition-easing); +} + +.btn-primary { + background: var(--md-sys-color-primary); + color: white; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4); +} + +/* 通用操作按钮样式 */ +.btn-action { + width: 100%; + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-on-surface); + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + font-weight: 600; + border: 1px solid transparent; + transition: + background-color var(--interactive-transition-duration) var(--theme-transition-easing), + color var(--interactive-transition-duration) var(--theme-transition-easing), + border-color var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing), + box-shadow var(--interactive-transition-duration) var(--theme-transition-easing), + opacity var(--interactive-transition-duration) var(--theme-transition-easing); + padding: 12px 16px; + line-height: 1.5; + cursor: pointer; +} + +.btn-action span { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-action:hover { + background: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + border-color: var(--md-sys-color-outline-variant); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.btn-action:active { + transform: translateY(0); + box-shadow: none; + opacity: 0.8; +} + +/* 暗色模式下的按钮覆盖样式 */ +body.dark-theme .btn-action { + background: rgba(255, 255, 255, 0.08); + color: var(--md-sys-color-on-surface); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +body.dark-theme .btn-action:hover { + background: rgba(208, 188, 255, 0.15); + color: var(--md-sys-color-primary); + border-color: rgba(208, 188, 255, 0.3); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +/* ===== 滚动行为与兼容辅助类 ===== */ +/* 滚动条 */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: var(--md-sys-color-secondary-container); + border-radius: 10px; +} +::-webkit-scrollbar-thumb:hover { background: var(--md-sys-color-secondary); } + +/* 移动端滚动优化 */ +.sidebar > div:nth-child(2), +.main-content { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; +} + +/* 按钮点击反馈 */ +.nav-item, +.btn, +.btn-action { + -webkit-tap-highlight-color: transparent; + user-select: none; +} + +.nav-item:active, +.btn:active { + opacity: 0.7; + transition: opacity 0.12s var(--theme-transition-easing); +} + +/* 兼容之前的组件辅助样式 */ +.mono { + font-family: "Consolas", "SFMono-Regular", monospace; + font-size: 12px; +} +.grid { + display: grid; + gap: 14px; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} + +/* ===== 顶栏组件样式:时钟、连接状态、侧栏操作区 ===== */ +.header-clock-chip { + font-size: 14px; + font-weight: 700; + color: var(--md-sys-color-primary); + background: var(--md-sys-color-surface-variant); + padding: 6px 14px; + border-radius: 12px; + border: 1px solid var(--md-sys-color-outline-variant); + box-shadow: 0 2px 4px rgba(0,0,0,0.05); + display: flex; + align-items: center; + gap: 8px; +} + +.header-clock-label { + font-size: 14px; + opacity: 0.8; + font-weight: 600; +} + +.header-clock-value { + font-family: Monaco, Consolas, "Courier New", monospace; + font-size: 15px; + font-weight: 800; + letter-spacing: 0.5px; +} + +.connection-chip { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + border-radius: 12px; + border: 1px solid transparent; +} + +.connection-chip.is-connected { + background: rgba(76, 175, 80, 0.1); + border-color: rgba(76, 175, 80, 0.2); +} + +.connection-chip.is-disconnected { + background: rgba(244, 67, 54, 0.1); + border-color: rgba(244, 67, 54, 0.2); +} + +.connection-chip-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.connection-chip.is-connected .connection-chip-dot { + background: #4CAF50; + box-shadow: 0 0 8px #4CAF50; +} + +.connection-chip.is-disconnected .connection-chip-dot { + background: #F44336; + box-shadow: 0 0 8px #F44336; +} + +.connection-chip-text { + font-weight: 600 !important; + font-size: 13px !important; +} + +.connection-chip.is-connected .connection-chip-text { + color: #4CAF50; +} + +.connection-chip.is-disconnected .connection-chip-text { + color: #F44336; +} + +.sidebar-actions-panel { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} + +.sidebar-action-btn { + width: 100%; + margin-bottom: 0; + padding: 10px; + font-size: 13px; + font-weight: 600; + background: rgba(0, 0, 0, 0.05); + color: var(--md-sys-color-on-surface); + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid rgba(0, 0, 0, 0.04); + border-radius: 12px; + box-shadow: none; + line-height: 1.2; +} + +.sidebar-action-btn:hover { + transform: none; + box-shadow: none; + background: rgba(103, 80, 164, 0.08); + color: var(--md-sys-color-on-surface); +} + +body.dark-theme .sidebar-action-btn { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.05); +} + +body.dark-theme .sidebar-action-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: var(--md-sys-color-on-surface); +} + +.sidebar-github-card { + display: block; + color: var(--md-sys-color-on-surface); +} + +.sidebar-github-inner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: rgba(0, 0, 0, 0.03); + border-radius: 12px; + transition: + background-color var(--interactive-transition-duration) var(--theme-transition-easing), + border-color var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing), + box-shadow var(--interactive-transition-duration) var(--theme-transition-easing); + border: 1px solid rgba(0, 0, 0, 0.06); + cursor: pointer; +} + +body.dark-theme .sidebar-github-inner { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.05); +} + +.sidebar-github-inner:hover { + transform: translateY(-1px); + box-shadow: var(--glass-shadow); +} + +.sidebar-github-texts { + display: flex; + flex-direction: column; + gap: 3px; + overflow: hidden; +} + +.sidebar-github-author { + font-weight: 700 !important; + color: inherit !important; + font-size: 0.75rem !important; + line-height: 1.2 !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; + max-width: 170px; +} + +.sidebar-github-meta { + display: block !important; + opacity: 0.6; + line-height: 1.2 !important; + font-size: 0.7rem !important; + color: inherit !important; +} + +/* ===== 状态页:概览卡片与计时器可视化 ===== */ +.proactive-status-grid { + align-items: stretch; +} + +.status-panel-card { + height: 100%; + display: flex; + flex-direction: column; + min-height: 332px; +} + +.status-panel-card-primary { + background: + linear-gradient(180deg, rgba(59, 130, 246, 0.06), transparent 50%), + var(--md-sys-color-surface); +} + +.status-panel-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.status-panel-icon-blue { background: rgba(59, 130, 246, 0.1); } +.status-panel-icon-purple { background: rgba(168, 85, 247, 0.12); } +.status-panel-icon-pink { background: rgba(236, 72, 153, 0.1); } + +.status-metric-list { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; + justify-content: center; +} + +.status-metric-row { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} + +.status-metric-label { + opacity: 0.72; + font-weight: 500 !important; +} + +.status-metric-value { + font-weight: 600 !important; + text-align: right; +} + +.status-metric-value.is-emphasize { + font-weight: 800 !important; +} + +.status-metric-value.is-success { + color: #2E7D32; +} + +.status-metric-value.is-error { + color: #C62828; +} + +.status-divider { + height: 1px; + background: var(--md-sys-color-outline-variant); +} + +.status-actions-list { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1; + justify-content: center; +} + +.status-action-tooltip-wrap { + position: relative; + width: 100%; +} + +.status-action-tooltip-bubble { + position: absolute; + left: 50%; + bottom: calc(100% + 10px); + transform: translateX(-50%) translateY(6px); + width: max-content; + max-width: min(92vw, 460px); + padding: 10px 14px; + border-radius: 12px; + background: rgba(28, 27, 31, 0.92); + color: #fff; + font-size: 12px; + line-height: 1.5; + text-align: center; + white-space: normal; + word-break: keep-all; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.22); + border: 1px solid rgba(255, 255, 255, 0.08); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease; + z-index: 12; +} + +.status-action-tooltip-bubble::after { + content: ''; + position: absolute; + left: 50%; + top: 100%; + transform: translateX(-50%); + border-width: 6px 6px 0 6px; + border-style: solid; + border-color: rgba(28, 27, 31, 0.92) transparent transparent transparent; +} + +.status-action-tooltip-wrap:hover .status-action-tooltip-bubble, +.status-action-tooltip-wrap:focus-within .status-action-tooltip-bubble { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +body.dark-theme .status-action-tooltip-bubble { + background: rgba(245, 244, 248, 0.96); + color: #1C1B1F; + border-color: rgba(28, 27, 31, 0.08); +} + +body.dark-theme .status-action-tooltip-bubble::after { + border-color: rgba(245, 244, 248, 0.96) transparent transparent transparent; +} + + +.status-timers-section { + /* 计时器区域采用“分区 + 卡片网格”的结构,便于区分群沉默与自动触发。 */ + display: flex; + flex-direction: column; + gap: 18px; + padding: 20px; + border-radius: calc(var(--radius-lg) + 2px); + border: 1px solid var(--glass-border); + background: + linear-gradient(180deg, rgba(103, 80, 164, 0.05), rgba(103, 80, 164, 0) 34%), + rgba(255, 255, 255, 0.3); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} + +body.dark-theme .status-timers-section { + background: + linear-gradient(180deg, rgba(208, 188, 255, 0.08), rgba(208, 188, 255, 0) 34%), + rgba(20, 18, 24, 0.36); + border-color: rgba(208, 188, 255, 0.12); +} + +.status-timers-header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; + padding-bottom: 4px; +} + +.status-timers-summary-pills { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.status-timers-summary-pill { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--glass-border); + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-on-surface-variant); +} + +.status-timers-summary-pill-label { + font-size: 12px; + font-weight: 600; +} + +.status-timers-summary-pill-count { + min-width: 18px; + height: 18px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 6px; + font-size: 11px; + font-weight: 800; + color: var(--md-sys-color-on-primary-container); + background: var(--md-sys-color-primary-container); +} + +.status-timer-sections { + display: flex; + flex-direction: column; + gap: 20px; +} + +.status-timer-section-block { + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.34); + border: 1px solid rgba(103, 80, 164, 0.12); +} + +body.dark-theme .status-timer-section-block { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(208, 188, 255, 0.14); +} + +.status-timer-section-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 10px; + border-bottom: 1px dashed rgba(103, 80, 164, 0.14); +} + +body.dark-theme .status-timer-section-head { + border-bottom-color: rgba(208, 188, 255, 0.16); +} + +.status-timer-section-head-main { + min-width: 0; +} + +.status-timer-section-title { + font-weight: 800 !important; +} + +.status-timer-section-desc { + opacity: 0.72; + margin-top: 4px !important; +} + +.status-timer-section-count { + flex-shrink: 0; + padding: 6px 12px; + border-radius: 999px; + background: var(--md-sys-color-surface-variant); + border: 1px solid var(--glass-border); + color: var(--md-sys-color-primary); + font-size: 12px; + font-weight: 800; +} + +.status-timer-section-body { + padding-top: 2px; +} + +.status-timer-section-empty { + padding: 14px 16px; + border-radius: 14px; + background: rgba(103, 80, 164, 0.05); + border: 1px dashed rgba(103, 80, 164, 0.18); + color: var(--md-sys-color-on-surface-variant); + font-size: 13px; +} + +.status-timers-empty-card { + text-align: center; + padding: 44px 24px; +} + +.status-timers-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 18px; +} + +.status-timer-card { + /* 单张计时器卡片负责展示一个会话当前最值得关注的计时状态。 */ + display: flex; + flex-direction: column; + gap: 14px; + min-height: 312px; + position: relative; + overflow: hidden; + isolation: isolate; + border-top: 4px solid transparent; +} + + +.status-timer-card.accent-group-silence { + border-top-color: #2563EB; + background: + linear-gradient(180deg, rgba(59, 130, 246, 0.06), transparent 30%), + var(--md-sys-color-surface); +} + +.status-timer-card.accent-auto-group { + border-top-color: #8B5CF6; + background: + linear-gradient(180deg, rgba(139, 92, 246, 0.07), transparent 30%), + var(--md-sys-color-surface); +} + +.status-timer-card.accent-auto-friend { + border-top-color: #EC4899; + background: + linear-gradient(180deg, rgba(236, 72, 153, 0.07), transparent 30%), + var(--md-sys-color-surface); +} + +.status-timer-card.is-urgent { + border-color: rgba(245, 158, 11, 0.35); +} + +.status-timer-card.is-expired { + border-color: rgba(244, 67, 54, 0.28); +} + +.status-timer-card-top { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto; + gap: 14px; + align-items: flex-start; +} + +.status-timer-title-block { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.status-timer-kicker { + display: block !important; + margin-bottom: 4px !important; + opacity: 0.6; +} + +.status-timer-session { + font-weight: 700 !important; + word-break: break-all; + font-size: 13px !important; + line-height: 1.4; +} + +.status-timer-session.is-primary { + font-size: 28px !important; + letter-spacing: -0.2px; + line-height: 1.25; +} + +.status-timer-session-sub { + display: block !important; + max-width: 100%; + white-space: nowrap; + word-break: normal; + overflow: visible; + text-overflow: clip; + opacity: 0.72; + font-size: 12px !important; +} + +.status-timer-chip-stack { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 8px; + max-width: 280px; + flex-shrink: 0; +} + +.status-timer-kind-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; + white-space: nowrap; + border: 1px solid transparent; + background: rgba(103, 80, 164, 0.08); + color: var(--md-sys-color-primary); +} + +.status-timer-kind-badge.accent-group-silence { + background: rgba(59, 130, 246, 0.12); + color: #2563EB; + border-color: rgba(59, 130, 246, 0.18); +} + +.status-timer-kind-badge.accent-auto-group { + background: rgba(139, 92, 246, 0.12); + color: #7C3AED; + border-color: rgba(139, 92, 246, 0.18); +} + +.status-timer-kind-badge.accent-auto-friend { + background: rgba(236, 72, 153, 0.12); + color: #DB2777; + border-color: rgba(236, 72, 153, 0.18); +} + +.status-timer-meta-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + flex-wrap: wrap; +} + +.status-timer-category-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + background: rgba(103, 80, 164, 0.08); + color: var(--md-sys-color-primary); +} + +.status-timer-reset-hint { + /* 当群沉默计时器被新消息重置时,短暂显示顶部提示帮助用户理解时间跳变。 */ + position: absolute; + top: 12px; + left: 16px; + right: 16px; + z-index: 3; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(240, 246, 255, 0.84); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(59, 130, 246, 0.18); + box-shadow: 0 8px 18px rgba(59, 130, 246, 0.12); + color: #2563EB; + font-size: 11px; + font-weight: 700; + line-height: 1.4; + pointer-events: none; + opacity: 0; + transform: translateY(-6px) scale(0.98); + animation: status-reset-hint-fade 4.2s ease-out forwards; +} + +body.dark-theme .status-timer-reset-hint { + background: rgba(30, 58, 138, 0.95); + border-color: rgba(59, 130, 246, 0.4); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + color: #93C5FD; +} + +.status-timer-reset-dot { + width: 7px; + height: 7px; + border-radius: 999px; + background: #2563EB; + box-shadow: 0 0 0 rgba(37, 99, 235, 0.35); + animation: status-reset-dot-pulse 1.4s ease-out infinite; + flex-shrink: 0; +} + +.status-timer-card.is-resetting { + border-color: rgba(59, 130, 246, 0.28); + box-shadow: 0 12px 32px rgba(59, 130, 246, 0.12); +} + +.status-timer-panel { + background: var(--md-sys-color-surface-variant); + padding: 16px; + border-radius: 16px; + border: 1px solid rgba(103, 80, 164, 0.08); + display: flex; + flex-direction: column; + gap: 10px; +} + +.status-timer-primary-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.status-timer-label { + display: block !important; + margin-bottom: 4px !important; + opacity: 0.68; +} + +.status-timer-countdown { + font-weight: 800 !important; + color: var(--md-sys-color-primary); +} + +.status-timer-progress-value { + flex-shrink: 0; + font-size: 13px; + font-weight: 800; + color: var(--md-sys-color-on-surface-variant); +} + +.status-timer-info-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.status-timer-info-item { + padding: 14px; + border-radius: 14px; + background: rgba(255,255,255,0.42); + border: 1px solid var(--glass-border); +} + +body.dark-theme .status-timer-info-item { + background: rgba(255,255,255,0.04); +} + +.status-timer-info-label { + display: block !important; + margin-bottom: 6px !important; + opacity: 0.68; +} + +.status-timer-info-value { + font-weight: 600 !important; + line-height: 1.45 !important; + word-break: break-word; + white-space: pre-wrap; +} + +.status-timer-info-value.is-emphasize { + font-weight: 800 !important; +} + +/* ===== 任务页:任务卡片与调度进度 ===== */ +.tasks-header-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.tasks-header-subtitle { + opacity: 0.72; +} + +.tasks-empty-card { + text-align: center; + padding: 48px 24px; +} + +.tasks-empty-icon { + font-size: 34px; + margin-bottom: 12px; +} + +.notifications-view { + display: flex; + flex-direction: column; + gap: 18px; +} + +.notifications-hero-card { + padding: 18px 24px; + background: + linear-gradient(135deg, rgba(103, 80, 164, 0.08), rgba(103, 80, 164, 0.025) 48%, rgba(255, 255, 255, 0.72) 100%), + var(--md-sys-color-surface); + position: relative; + overflow: hidden; +} + +.notifications-hero-card::after { + content: ''; + position: absolute; + inset: auto -40px -56px auto; + width: 180px; + height: 180px; + border-radius: 50%; + background: radial-gradient(circle, rgba(103, 80, 164, 0.14), transparent 68%); + pointer-events: none; +} + +.notifications-header-row { + margin-bottom: 0; + align-items: flex-start; + position: relative; + z-index: 1; +} + +.notifications-header-main { + min-width: 0; + flex: 1; +} + +.notifications-title-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 14px 18px; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.notifications-inline-meta { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + margin-top: 0; + max-width: none; +} + +.notifications-inline-meta-item { + min-width: auto; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0; + border-radius: 0; + background: transparent; + border: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.notifications-inline-meta-item.is-pill { + min-height: 34px; + padding: 0 12px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.58); + border: 1px solid rgba(103, 80, 164, 0.12); + box-shadow: none; +} + +.notifications-inline-meta-item strong { + font-size: 12px; + line-height: 1.2; + font-weight: 700; + color: var(--md-sys-color-on-surface); + word-break: break-word; +} + +.notifications-inline-meta-label { + font-size: 12px; + font-weight: 700; + color: var(--md-sys-color-on-surface-variant); + white-space: nowrap; +} + +.notifications-actions-row { + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + flex-shrink: 0; + padding-left: 12px; + padding-top: 2px; +} + +.notifications-actions-row .MuiButton-root { + min-height: 52px; + padding: 0 22px; + border-radius: 18px; + font-weight: 700; + box-shadow: none; + border-width: 1px; +} + +.notifications-empty-card { + padding: 56px 28px; +} + +.notifications-feed-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.markdown-docs-shell { + display: grid; + grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); + gap: 18px; + align-items: start; +} + +.markdown-docs-aside-column { + min-width: 0; +} + +.markdown-docs-aside-sticky { + position: sticky; + top: calc(var(--top-bar-height) + 16px); +} + +.markdown-docs-sidebar-card, +.markdown-docs-content-card { + min-height: 520px; +} + +.markdown-docs-sidebar-card { + display: flex; + flex-direction: column; + gap: 16px; + height: calc(100vh - var(--top-bar-height) - 32px); + max-height: 880px; + overflow: hidden; +} + +.markdown-docs-sidebar-head { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.markdown-docs-file-list { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + flex: 1; + overflow-y: auto; + padding-right: 4px; +} + +.markdown-docs-content-column { + min-width: 0; +} + +.markdown-docs-file-item { + width: 100%; + text-align: left; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(103, 80, 164, 0.12); + background: rgba(255, 255, 255, 0.56); + color: var(--md-sys-color-on-surface); + cursor: pointer; + transition: + background-color var(--interactive-transition-duration) var(--theme-transition-easing), + border-color var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing), + box-shadow var(--interactive-transition-duration) var(--theme-transition-easing); +} + +.markdown-docs-file-item:hover { + transform: translateY(-2px); + background: rgba(103, 80, 164, 0.08); + border-color: rgba(103, 80, 164, 0.18); + box-shadow: 0 8px 22px rgba(103, 80, 164, 0.08); +} + +.markdown-docs-file-item.is-active { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + border-color: rgba(103, 80, 164, 0.22); +} + +.markdown-docs-file-item-top { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} + +.markdown-docs-file-icon { + flex-shrink: 0; +} + +.markdown-docs-file-title { + font-size: 14px; + font-weight: 800; + line-height: 1.35; + word-break: break-word; +} + +.markdown-docs-file-path { + opacity: 0.72; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.markdown-docs-content-card { + display: flex; + flex-direction: column; + gap: 18px; +} + +.markdown-docs-article-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--glass-border); +} + +.markdown-docs-article { + min-height: 380px; +} + +.markdown-docs-empty-card, +.markdown-docs-empty-side-card { + min-height: 240px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.tasks-grid-enhanced { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + gap: 18px; +} + +.task-card-enhanced { + /* 任务卡片与状态页卡片风格保持一致,但更强调执行时间与操作按钮区域。 */ + display: flex; + flex-direction: column; + gap: 16px; + min-height: 320px; + position: relative; + overflow: hidden; +} + +.notification-feed-card { + min-height: auto; + padding: 18px 20px 16px; + gap: 12px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(103, 80, 164, 0.06), rgba(103, 80, 164, 0.02) 34%, rgba(255, 255, 255, 0.78) 100%), + var(--md-sys-color-surface); + box-shadow: 0 8px 24px rgba(103, 80, 164, 0.08); +} + +.notification-feed-card::after { + width: 140px; + height: 140px; + inset: auto -34px -46px auto; + background: radial-gradient(circle, rgba(103, 80, 164, 0.12), transparent 70%); +} + +.notification-feed-card.is-read { + border-color: rgba(103, 80, 164, 0.10); +} + +.notification-feed-card-top { + align-items: center; + justify-content: space-between; + gap: 14px; + padding-bottom: 2px; +} + +.notification-feed-heading-group { + min-width: 0; + flex: 1; + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: nowrap; +} + +.notification-feed-title-block { + gap: 6px; +} + +.notification-feed-meta-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.notification-feed-time { + opacity: 0.78; + font-size: 11.5px !important; +} + +.notification-feed-title { + flex: 0 1 auto; + min-width: 0; + font-size: 1.22rem !important; + line-height: 1.35 !important; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.notification-feed-side { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding-top: 1px; +} + +.notification-feed-meta-inline { + flex-wrap: wrap; + justify-content: flex-end; + padding-left: 12px; +} + +.notification-feed-heading-group .MuiChip-root, +.notification-feed-type-chip { + height: 30px; + border-radius: 999px; + font-weight: 700; + flex-shrink: 0; + margin-left: 0; +} + +.notification-feed-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + border: 1px solid transparent; + white-space: nowrap; +} + +.notification-feed-status-pill.is-read { + color: #5B6472; + background: rgba(91, 100, 114, 0.10); + border-color: rgba(91, 100, 114, 0.14); +} + +.notification-feed-status-pill.is-unread { + color: #B45309; + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.22); +} + + +.task-card-enhanced.is-urgent { + border-color: rgba(245, 158, 11, 0.35); +} + +.task-card-enhanced.is-expired { + border-color: rgba(244, 67, 54, 0.28); +} + +.task-card-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.task-card-title-block { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.task-card-kicker { + display: block !important; + margin-bottom: 4px !important; + opacity: 0.6; +} + +.task-card-session { + font-weight: 700 !important; + word-break: break-all; + font-size: 13px !important; + line-height: 1.4; +} + +.task-card-session.is-primary { + font-size: 28px !important; + letter-spacing: -0.2px; + line-height: 1.25; +} + +.task-card-session-sub { + display: block !important; + max-width: none; + white-space: nowrap; + overflow: visible; + text-overflow: clip; + opacity: 0.72; + font-size: 12px !important; +} + +.task-next-run-panel { + background: var(--md-sys-color-surface-variant); + padding: 16px; + border-radius: 16px; + border: 1px solid rgba(103, 80, 164, 0.08); + display: flex; + flex-direction: column; + gap: 10px; +} + +.notification-feed-content-panel { + position: relative; + padding: 16px 16px 56px; + background: linear-gradient(180deg, rgba(103, 80, 164, 0.08), rgba(103, 80, 164, 0.035)); + border-color: rgba(103, 80, 164, 0.09); + border-radius: 16px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45); +} + +.notification-feed-content-text { + color: var(--md-sys-color-on-surface); +} + +.notification-feed-content-text-plain { + white-space: pre-wrap; +} + +.notification-md { + color: var(--md-sys-color-on-surface); + line-height: 1.82; + font-size: 0.97rem; + word-break: break-word; +} + +.notification-md > :first-child { + margin-top: 0; +} + +.notification-md > :last-child { + margin-bottom: 0; +} + +.notification-md p, +.notification-md ul, +.notification-md ol, +.notification-md blockquote, +.notification-md pre, +.notification-md .notification-md-table-wrap, +.notification-md .notification-md-details, +.notification-md .notification-md-mermaid-block { + margin: 0 0 0.95rem; +} + +.notification-md h1, +.notification-md h2, +.notification-md h3, +.notification-md h4, +.notification-md h5, +.notification-md h6 { + margin: 1.2rem 0 0.72rem; + color: var(--md-sys-color-on-surface); + font-weight: 800; + letter-spacing: -0.01em; + line-height: 1.32; +} + +.notification-md h1 { + font-size: 1.32rem; + padding-bottom: 0.46rem; + border-bottom: 1px solid rgba(103, 80, 164, 0.16); +} + +.notification-md h2 { + font-size: 1.18rem; + padding-left: 0.72rem; + border-left: 4px solid var(--md-sys-color-primary); +} + +.notification-md h3 { + font-size: 1.05rem; + color: var(--md-sys-color-primary); +} + +.notification-md h4, +.notification-md h5, +.notification-md h6 { + font-size: 0.98rem; +} + +.notification-md p { + color: var(--md-sys-color-on-surface); +} + +.notification-md strong { + color: var(--md-sys-color-on-surface); + font-weight: 800; +} + +.notification-md em, +.notification-md del { + color: var(--md-sys-color-on-surface-variant); +} + +.notification-md a { + color: var(--md-sys-color-primary); + text-decoration: none; + font-weight: 700; + border-bottom: 1px solid rgba(103, 80, 164, 0.22); + transition: + color var(--interactive-transition-duration) var(--theme-transition-easing), + border-color var(--interactive-transition-duration) var(--theme-transition-easing), + opacity var(--interactive-transition-duration) var(--theme-transition-easing); +} + +.notification-md a:hover { + opacity: 0.88; + border-bottom-color: rgba(103, 80, 164, 0.42); +} + +.notification-md ul, +.notification-md ol { + padding-left: 1.35rem; +} + +.notification-md li + li { + margin-top: 0.34rem; +} + +.notification-md li::marker { + color: var(--md-sys-color-primary); + font-weight: 700; +} + +.notification-md hr { + border: 0; + height: 1px; + margin: 1rem 0; + background: linear-gradient( + 90deg, + transparent, + rgba(103, 80, 164, 0.24), + transparent + ); +} + +.notification-md blockquote { + margin-left: 0; + margin-right: 0; + padding: 0.9rem 1rem; + border-left: 4px solid var(--md-sys-color-primary); + border-radius: 0 var(--radius-md) var(--radius-md) 0; + background: linear-gradient(135deg, rgba(103, 80, 164, 0.12), rgba(103, 80, 164, 0.05)); + color: var(--md-sys-color-on-surface); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.24); +} + +.notification-md blockquote p { + margin: 0; +} + +.notification-md .notification-md-callout { + position: relative; + padding: 1rem 1.1rem 1rem 1.15rem; + border-left-width: 5px; + border-radius: 0 var(--radius-md) var(--radius-md) 0; + background: linear-gradient(135deg, rgba(103, 80, 164, 0.08), rgba(103, 80, 164, 0.03)); +} + +.notification-md .notification-md-callout-header { + display: inline-flex; + align-items: center; + gap: 0.55rem; + margin-bottom: 0.72rem; + font-weight: 800; + line-height: 1.2; +} + +.notification-md .notification-md-callout-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.2rem; + height: 1.2rem; + font-size: 0.95rem; +} + +.notification-md .notification-md-callout-title { + font-size: 1rem; + letter-spacing: -0.01em; +} + +.notification-md .notification-md-callout.is-note { + border-left-color: #2563EB; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(37, 99, 235, 0.04)); +} + +.notification-md .notification-md-callout.is-note .notification-md-callout-header { + color: #2563EB; +} + +.notification-md .notification-md-callout.is-tip { + border-left-color: #0F9D58; + background: linear-gradient(135deg, rgba(15, 157, 88, 0.12), rgba(15, 157, 88, 0.04)); +} + +.notification-md .notification-md-callout.is-tip .notification-md-callout-header { + color: #0F9D58; +} + +.notification-md .notification-md-callout.is-important { + border-left-color: #7C3AED; + background: linear-gradient(135deg, rgba(124, 58, 237, 0.14), rgba(124, 58, 237, 0.05)); +} + +.notification-md .notification-md-callout.is-important .notification-md-callout-header { + color: #7C3AED; +} + +.notification-md .notification-md-callout.is-warning { + border-left-color: #D97706; + background: linear-gradient(135deg, rgba(217, 119, 6, 0.14), rgba(217, 119, 6, 0.05)); +} + +.notification-md .notification-md-callout.is-warning .notification-md-callout-header { + color: #D97706; +} + +.notification-md .notification-md-callout.is-caution { + border-left-color: #DC2626; + background: linear-gradient(135deg, rgba(220, 38, 38, 0.14), rgba(220, 38, 38, 0.05)); +} + +.notification-md .notification-md-callout.is-caution .notification-md-callout-header { + color: #DC2626; +} + +.notification-md .notification-md-callout > :last-child { + margin-bottom: 0; +} + +.notification-md :not(pre) > code { + display: inline-block; + padding: 0.14rem 0.48rem; + margin: 0 0.1rem; + border-radius: 999px; + border: 1px solid rgba(103, 80, 164, 0.16); + background: rgba(103, 80, 164, 0.10); + color: var(--md-sys-color-primary); + font-family: "Consolas", "SFMono-Regular", monospace; + font-size: 0.9em; + font-weight: 700; + line-height: 1.45; +} + +.notification-md-code-block, +.notification-md-mermaid-block { + margin: 0 0 0.95rem; + overflow: hidden; + border-radius: 18px; + border: 1px solid rgba(103, 80, 164, 0.18); + background: + linear-gradient(180deg, rgba(103, 80, 164, 0.18), rgba(103, 80, 164, 0.05) 18%, rgba(255, 255, 255, 0.94) 100%), + rgba(255, 255, 255, 0.92); + box-shadow: + 0 10px 28px rgba(103, 80, 164, 0.12), + inset 0 1px 0 rgba(255, 255, 255, 0.65); +} + +.notification-md-code-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid rgba(103, 80, 164, 0.10); + background: linear-gradient(180deg, rgba(103, 80, 164, 0.12), rgba(103, 80, 164, 0.06)); +} + +.notification-md-code-lang { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 24px; + padding: 0 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.58); + border: 1px solid rgba(103, 80, 164, 0.14); + color: var(--md-sys-color-primary); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.notification-md pre { + overflow-x: auto; + margin: 0; + padding: 1rem 1.05rem 1.05rem; + border: none; + border-radius: 0 0 18px 18px; + background: + radial-gradient(circle at top right, rgba(103, 80, 164, 0.08), transparent 34%), + linear-gradient(180deg, rgba(248, 245, 255, 0.96), rgba(241, 236, 251, 0.96)); + box-shadow: none; + scrollbar-width: thin; + scrollbar-color: rgba(103, 80, 164, 0.28) transparent; +} + +.notification-md pre code { + display: block; + min-width: max-content; + color: var(--md-sys-color-on-surface); + font-family: "Consolas", "SFMono-Regular", monospace; + font-size: 0.92rem; + line-height: 1.72; + background: transparent; +} + +.notification-md pre code .token { + font-weight: 600; +} + +.notification-md pre code .token-key, +.notification-md pre code .token-property { + color: var(--md-code-token-key); +} + +.notification-md pre code .token-string { + color: var(--md-code-token-string); +} + +.notification-md pre code .token-number { + color: var(--md-code-token-number); +} + +.notification-md pre code .token-boolean { + color: var(--md-code-token-boolean); +} + +.notification-md pre code .token-keyword { + color: var(--md-code-token-keyword); +} + +.notification-md pre code .token-function { + color: var(--md-code-token-function); +} + +.notification-md pre code .token-command { + color: var(--md-code-token-command); + font-weight: 800; +} + +.notification-md pre code .token-flag { + color: var(--md-code-token-flag); +} + +.notification-md pre code .token-punctuation { + color: var(--md-code-token-punctuation); +} + +.notification-md .notification-md-table-wrap { + overflow-x: auto; + border-radius: 16px; + border: 1px solid rgba(103, 80, 164, 0.12); + background: rgba(255, 255, 255, 0.34); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32); +} + +.notification-md .notification-md-details { + overflow: hidden; + border-radius: 16px; + border: 1px solid rgba(103, 80, 164, 0.16); + background: linear-gradient(180deg, rgba(103, 80, 164, 0.08), rgba(103, 80, 164, 0.03)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3); +} + +.notification-md .notification-md-summary { + position: relative; + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.88rem 1rem; + cursor: pointer; + list-style: none; + user-select: none; + font-weight: 800; + color: var(--md-sys-color-on-surface); +} + +.notification-md .notification-md-summary::-webkit-details-marker { + display: none; +} + +.notification-md .notification-md-summary::before { + content: '▸'; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--md-sys-color-primary); + font-size: 0.92rem; + line-height: 1; + transform: rotate(0deg); + transition: transform var(--interactive-transition-duration) var(--theme-transition-easing); +} + +.notification-md .notification-md-details[open] > .notification-md-summary::before { + transform: rotate(90deg); +} + +.notification-md .notification-md-details > :not(summary) { + margin-left: 1rem; + margin-right: 1rem; +} + +.notification-md .notification-md-details > :not(summary):last-child { + margin-bottom: 1rem; +} + +.notification-md .notification-md-details > .notification-md-summary + * { + margin-top: 0.1rem; +} + +.notification-md-mermaid { + overflow: hidden; + padding: 1rem; + background: + radial-gradient(circle at top right, rgba(103, 80, 164, 0.08), transparent 34%), + linear-gradient(180deg, rgba(248, 245, 255, 0.96), rgba(241, 236, 251, 0.96)); + text-align: center; +} + +.notification-md-mermaid-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.7rem 0.9rem 0; +} + +.notification-md-mermaid-tool-btn { + min-width: 2rem; + height: 2rem; + padding: 0 0.65rem; + border: 1px solid rgba(103, 80, 164, 0.16); + border-radius: 999px; + background: rgba(255, 255, 255, 0.7); + color: var(--md-sys-color-primary); + font-size: 0.9rem; + font-weight: 800; + line-height: 1; + cursor: pointer; + transition: + background-color var(--interactive-transition-duration) var(--theme-transition-easing), + border-color var(--interactive-transition-duration) var(--theme-transition-easing), + transform var(--interactive-transition-duration) var(--theme-transition-easing), + box-shadow var(--interactive-transition-duration) var(--theme-transition-easing); +} + +.notification-md-mermaid-tool-btn:hover { + background: rgba(103, 80, 164, 0.12); + border-color: rgba(103, 80, 164, 0.24); + transform: translateY(-1px); +} + +.notification-md-mermaid-viewport { + position: relative; + overflow: hidden; + width: 100%; + min-height: 240px; + border-radius: 14px; + cursor: default; + touch-action: none; +} + +.notification-md-mermaid-viewport.is-pannable { + cursor: grab; +} + +.notification-md-mermaid-viewport.is-dragging { + cursor: grabbing; +} + +.notification-md-mermaid-canvas { + display: flex; + align-items: center; + justify-content: center; + min-width: 100%; + min-height: 240px; + transform-origin: center center; + will-change: transform; +} + +.notification-md-mermaid svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + user-select: none; + pointer-events: none; +} + +.notification-md-mermaid.is-error { + text-align: left; + color: #B3261E; + white-space: pre-wrap; + font-family: "Consolas", "SFMono-Regular", monospace; + font-size: 0.9rem; + line-height: 1.7; +} + +.notification-md table { + width: 100%; + min-width: 420px; + border-collapse: collapse; +} + +.notification-md th, +.notification-md td { + padding: 0.72rem 0.85rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(103, 80, 164, 0.10); +} + +.notification-md th { + background: rgba(103, 80, 164, 0.10); + color: var(--md-sys-color-primary); + font-weight: 800; + white-space: nowrap; +} + +.notification-md td { + color: var(--md-sys-color-on-surface); +} + +.notification-md tr:last-child td { + border-bottom: none; +} + +.notification-feed-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + padding-top: 0; +} + +.notification-feed-actions .MuiButton-root { + min-height: 40px; + padding: 0 16px; + border-radius: 18px; + border-width: 1px; + font-weight: 700; + background: rgba(255, 255, 255, 0.62); + box-shadow: none; +} + +.notification-feed-actions .MuiButton-root.Mui-disabled { + background: rgba(255, 255, 255, 0.34); + border-color: rgba(103, 80, 164, 0.10); +} + +.notification-feed-actions-inside { + position: absolute; + right: 14px; + bottom: 12px; + justify-content: flex-end; +} + +.task-next-run-primary-row { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.task-next-run-label { + display: block !important; + margin-bottom: 4px !important; + opacity: 0.68; +} + +.task-next-run-time { + font-weight: 800 !important; + color: var(--md-sys-color-primary); +} + +.task-countdown-text { + opacity: 0.82; +} + +.task-status-pill { + /* 用统一的状态 pill 表达 future / soon / urgent / expired 等时序语义。 */ + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; +} + +.task-status-pill.is-future { + background: rgba(103, 80, 164, 0.14); + color: var(--md-sys-color-primary); +} + +.task-status-pill.is-soon { + background: rgba(59, 130, 246, 0.12); + color: #2563EB; +} + +.task-status-pill.is-urgent { + background: rgba(245, 158, 11, 0.16); + color: #B45309; +} + +.task-status-pill.is-expired, +.task-status-pill.is-unknown { + background: rgba(244, 67, 54, 0.14); + color: #C62828; +} + +.task-progress-track { + /* 任务与计时器共用同一套进度条视觉语言,降低界面认知成本。 */ + width: 100%; + height: 8px; + border-radius: 999px; + background: rgba(103, 80, 164, 0.12); + overflow: hidden; +} + +.task-progress-bar { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, #6750A4, #8B5CF6); + transition: width var(--theme-transition-duration) var(--theme-transition-easing); +} + +.task-progress-bar.is-soon { + background: linear-gradient(90deg, #3B82F6, #60A5FA); +} + +.task-progress-bar.is-urgent { + background: linear-gradient(90deg, #F59E0B, #F97316); +} + +.task-progress-bar.is-expired, +.task-progress-bar.is-unknown { + background: linear-gradient(90deg, #EF4444, #F87171); +} + + +body.dark-theme .sidebar-notification-badge { + border-color: rgba(28, 27, 31, 0.42); + box-shadow: 0 10px 22px rgba(255, 107, 107, 0.18); +} + +body.dark-theme .sidebar-github-author { + color: var(--md-sys-color-on-surface); +} + +body.dark-theme .sidebar-github-meta { + color: var(--md-sys-color-on-surface-variant); + opacity: 0.9; +} + +body.dark-theme .notifications-hero-card { + background: + linear-gradient(135deg, rgba(208, 188, 255, 0.10), rgba(79, 55, 139, 0.10) 50%, rgba(28, 27, 31, 0.78) 100%), + var(--md-sys-color-surface); +} + + +body.dark-theme .notification-feed-card { + background: + linear-gradient(180deg, rgba(208, 188, 255, 0.09), rgba(208, 188, 255, 0.025) 34%, rgba(28, 27, 31, 0.88) 100%), + var(--md-sys-color-surface); + border-color: rgba(208, 188, 255, 0.10); +} + +body.dark-theme .markdown-docs-file-item { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(208, 188, 255, 0.12); +} + +body.dark-theme .markdown-docs-file-item:hover { + background: rgba(208, 188, 255, 0.12); + border-color: rgba(208, 188, 255, 0.20); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22); +} + +body.dark-theme .markdown-docs-file-item.is-active { + background: rgba(208, 188, 255, 0.18); + color: var(--md-sys-color-on-surface); + border-color: rgba(208, 188, 255, 0.24); +} + +body.dark-theme .notifications-actions-row .MuiButton-root { + box-shadow: none; +} + +body.dark-theme .notifications-inline-meta-item.is-pill { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(208, 188, 255, 0.12); + box-shadow: none; +} + +body.dark-theme .notification-feed-status-pill.is-read { + color: #C7CFD9; + background: rgba(199, 207, 217, 0.12); + border-color: rgba(199, 207, 217, 0.18); +} + +body.dark-theme .notification-feed-status-pill.is-unread { + color: #FBC02D; + background: rgba(251, 192, 45, 0.14); + border-color: rgba(251, 192, 45, 0.22); +} + +body.dark-theme .notification-feed-content-panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)); + border-color: rgba(208, 188, 255, 0.10); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +body.dark-theme .notification-md h1 { + border-bottom-color: rgba(208, 188, 255, 0.18); +} + +body.dark-theme .notification-md h3, +body.dark-theme .notification-md a, +body.dark-theme .notification-md :not(pre) > code { + color: var(--md-sys-color-primary); +} + +body.dark-theme .notification-md a { + border-bottom-color: rgba(208, 188, 255, 0.26); +} + +body.dark-theme .notification-md a:hover { + border-bottom-color: rgba(208, 188, 255, 0.42); +} + +body.dark-theme .notification-md hr { + background: linear-gradient( + 90deg, + transparent, + rgba(208, 188, 255, 0.26), + transparent + ); +} + +body.dark-theme .notification-md blockquote { + background: linear-gradient(135deg, rgba(208, 188, 255, 0.14), rgba(79, 55, 139, 0.18)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +body.dark-theme .notification-md .notification-md-callout { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +body.dark-theme .notification-md .notification-md-callout.is-note { + background: linear-gradient(135deg, rgba(96, 165, 250, 0.16), rgba(30, 64, 175, 0.18)); +} + +body.dark-theme .notification-md .notification-md-callout.is-note .notification-md-callout-header { + color: #93C5FD; +} + +body.dark-theme .notification-md .notification-md-callout.is-tip { + background: linear-gradient(135deg, rgba(74, 222, 128, 0.16), rgba(21, 128, 61, 0.18)); +} + +body.dark-theme .notification-md .notification-md-callout.is-tip .notification-md-callout-header { + color: #86EFAC; +} + +body.dark-theme .notification-md .notification-md-callout.is-important { + background: linear-gradient(135deg, rgba(196, 181, 253, 0.18), rgba(91, 33, 182, 0.20)); +} + +body.dark-theme .notification-md .notification-md-callout.is-important .notification-md-callout-header { + color: #D8B4FE; +} + +body.dark-theme .notification-md .notification-md-callout.is-warning { + background: linear-gradient(135deg, rgba(251, 191, 36, 0.18), rgba(180, 83, 9, 0.18)); +} + +body.dark-theme .notification-md .notification-md-callout.is-warning .notification-md-callout-header { + color: #FCD34D; +} + +body.dark-theme .notification-md .notification-md-callout.is-caution { + background: linear-gradient(135deg, rgba(248, 113, 113, 0.18), rgba(153, 27, 27, 0.18)); +} + +body.dark-theme .notification-md .notification-md-callout.is-caution .notification-md-callout-header { + color: #FCA5A5; +} + +body.dark-theme .notification-md :not(pre) > code { + background: rgba(208, 188, 255, 0.12); + border-color: rgba(208, 188, 255, 0.18); +} + +body.dark-theme .notification-md-code-block, +body.dark-theme .notification-md-mermaid-block { + border-color: rgba(208, 188, 255, 0.18); + background: + linear-gradient(180deg, rgba(208, 188, 255, 0.12), rgba(79, 55, 139, 0.08) 16%, rgba(23, 20, 29, 0.96) 100%), + rgba(23, 20, 29, 0.96); + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +body.dark-theme .notification-md-code-header { + border-bottom-color: rgba(208, 188, 255, 0.12); + background: linear-gradient(180deg, rgba(208, 188, 255, 0.14), rgba(79, 55, 139, 0.20)); +} + +body.dark-theme .notification-md-code-lang { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(208, 188, 255, 0.18); +} + +body.dark-theme .notification-md pre { + background: + radial-gradient(circle at top right, rgba(208, 188, 255, 0.08), transparent 34%), + linear-gradient(180deg, rgba(30, 27, 38, 0.98), rgba(20, 18, 24, 0.98)); +} + +body.dark-theme .notification-md pre code { + color: #ECE7F6; +} + +body.dark-theme .notification-md pre code .token-key, +body.dark-theme .notification-md pre code .token-property { + color: var(--md-code-token-key); +} + +body.dark-theme .notification-md pre code .token-string { + color: var(--md-code-token-string); +} + +body.dark-theme .notification-md pre code .token-number { + color: var(--md-code-token-number); +} + +body.dark-theme .notification-md pre code .token-boolean { + color: var(--md-code-token-boolean); +} + +body.dark-theme .notification-md pre code .token-keyword { + color: var(--md-code-token-keyword); +} + +body.dark-theme .notification-md pre code .token-function { + color: var(--md-code-token-function); +} + +body.dark-theme .notification-md pre code .token-command { + color: var(--md-code-token-command); +} + +body.dark-theme .notification-md pre code .token-flag { + color: var(--md-code-token-flag); +} + +body.dark-theme .notification-md pre code .token-punctuation { + color: var(--md-code-token-punctuation); +} + +body.dark-theme .notification-md .notification-md-table-wrap { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(208, 188, 255, 0.14); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +body.dark-theme .notification-md .notification-md-details { + background: linear-gradient(180deg, rgba(208, 188, 255, 0.12), rgba(79, 55, 139, 0.10)); + border-color: rgba(208, 188, 255, 0.18); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +body.dark-theme .notification-md .notification-md-summary { + color: var(--md-sys-color-on-surface); +} + +body.dark-theme .notification-md-mermaid { + background: + radial-gradient(circle at top right, rgba(208, 188, 255, 0.08), transparent 34%), + linear-gradient(180deg, rgba(30, 27, 38, 0.98), rgba(20, 18, 24, 0.98)); +} + +body.dark-theme .notification-md-mermaid-tool-btn { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(208, 188, 255, 0.18); + color: var(--md-sys-color-primary); +} + +body.dark-theme .notification-md-mermaid-tool-btn:hover { + background: rgba(208, 188, 255, 0.14); + border-color: rgba(208, 188, 255, 0.24); +} + +body.dark-theme .notification-md-mermaid.is-error { + color: #F2B8B5; +} + +body.dark-theme .notification-md th, +body.dark-theme .notification-md td { + border-bottom-color: rgba(208, 188, 255, 0.10); +} + +body.dark-theme .notification-md th { + background: rgba(208, 188, 255, 0.10); +} + +body.dark-theme .notification-feed-actions .MuiButton-root { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(208, 188, 255, 0.18); + box-shadow: none; +} + +body.dark-theme .notification-feed-actions .MuiButton-root.Mui-disabled { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(208, 188, 255, 0.10); +} + +body.dark-theme .status-metric-label, +body.dark-theme .status-timer-section-desc, +body.dark-theme .status-timer-kicker, +body.dark-theme .status-timer-session-sub, +body.dark-theme .task-card-kicker, +body.dark-theme .task-card-session-sub, +body.dark-theme .task-next-run-label, +body.dark-theme .status-timer-label, +body.dark-theme .status-timer-info-label, +body.dark-theme .tasks-header-subtitle, +body.dark-theme .task-countdown-text, +body.dark-theme .notifications-inline-meta-label, +body.dark-theme .notification-feed-time { + color: var(--md-sys-color-on-surface-variant); + opacity: 0.95; +} + +body.dark-theme .status-timer-section-count, +body.dark-theme .status-timers-summary-pill, +body.dark-theme .header-clock-chip, +body.dark-theme .connection-chip { + color: var(--md-sys-color-on-surface); +} + +/* ========== 响应式设计 ========== */ + +/* 平板设备:缩窄侧边栏、把 12 栏压缩为 6 栏,兼顾内容密度与可读性。 */ +@media (max-width: 1024px) { + .sidebar { + width: 240px; + } + + .top-bar { + padding: 0 24px; + } + + .main-content { + padding: calc(var(--top-bar-height) + 16px) 24px 24px; + } + + .dashboard-grid { + gap: 16px; + grid-template-columns: repeat(6, 1fr); + } + + .span-4 { grid-column: span 3; } + .span-8 { grid-column: span 6; } + .span-12 { grid-column: span 6; } + + .card { + padding: 20px; + } +} + +/* 移动设备:整体从左右分栏切换为上下堆叠,导航改为横向可滚动。 */ +@media (max-width: 768px) { + .app { + flex-direction: column; + height: auto; + min-height: 100vh; + } + + .sidebar { + width: 100%; + height: auto; + padding: 12px 16px; + border-right: none; + border-bottom: 1px solid var(--glass-border); + flex-direction: column; + gap: 12px; + } + + .sidebar-header { + padding: 0; + flex-direction: row; + justify-content: space-between; + width: 100%; + } + + .sidebar-logo-img { + width: 40px; + height: 40px; + } + + .sidebar > div:nth-child(2) { + display: flex; + flex-direction: row; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + margin: 0 !important; + padding-bottom: 4px; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + } + + .nav-item { + white-space: nowrap; + padding: 10px 16px; + margin-bottom: 0; + flex-shrink: 0; + font-size: 14px; + } + + .sidebar > div:nth-child(3) { + display: none; + } + + .top-bar { + position: relative; + top: auto; + left: auto; + right: auto; + height: auto; + min-height: calc(var(--top-bar-height) * 0.75); + padding: 12px 16px; + flex-wrap: wrap; + gap: 12px; + } + + .top-bar::before { + display: none; + } + + .top-bar > h5 { + font-size: 1.25rem; + } + + .main-wrapper { + flex: 1; + overflow: visible; + } + + .main-content { + padding: 16px; + overflow-y: visible; + height: auto; + } + + .dashboard-grid { + grid-template-columns: 1fr !important; + gap: 12px; + } + + .span-4, .span-8, .span-12 { + grid-column: span 1 !important; + } + + .card { + padding: 16px; + border-radius: var(--radius-md); + } + + .card:hover { + transform: none; + } + + .top-bar > div { + width: 100%; + justify-content: space-between; + } + + .tasks-header-row, + .status-timer-section-head { + flex-direction: column; + align-items: flex-start; + } + + .notifications-actions-row { + width: 100%; + justify-content: flex-start; + padding-left: 0; + } + + .notifications-title-row { + align-items: flex-start; + } + + .notifications-inline-meta { + width: 100%; + gap: 8px 10px; + } + + .status-timer-info-grid { + grid-template-columns: 1fr; + } + + .status-panel-card { + min-height: auto; + } + + .tasks-grid-enhanced { + grid-template-columns: 1fr; + } + + .markdown-docs-shell { + grid-template-columns: 1fr; + } + + .markdown-docs-aside-sticky { + position: static; + } + + .markdown-docs-sidebar-card { + min-height: auto; + height: auto; + max-height: none; + overflow: visible; + } + + .markdown-docs-content-card { + min-height: auto; + } + + .markdown-docs-file-list { + min-height: auto; + flex: initial; + overflow: visible; + padding-right: 0; + } +} + +/* 小屏手机:进一步压缩边距与卡片留白,保证信息仍能在窄屏完整呈现。 */ +@media (max-width: 480px) { + .notification-md { + font-size: 0.94rem; + } + + .notification-md h1 { + font-size: 1.18rem; + } + + .notification-md h2 { + font-size: 1.08rem; + } + + .notification-md h3, + .notification-md h4, + .notification-md h5, + .notification-md h6 { + font-size: 0.98rem; + } + + .notification-md-code-header { + min-height: 38px; + padding: 0 12px; + } + + .notification-md-code-lang { + min-height: 22px; + padding: 0 9px; + font-size: 10px; + } + + .notification-md pre { + padding: 0.85rem 0.9rem 0.95rem; + border-radius: 0 0 14px 14px; + } + + .notification-md .notification-md-table-wrap { + margin-left: -2px; + margin-right: -2px; + } + + .notification-md .notification-md-summary { + padding: 0.78rem 0.88rem; + } + + .notification-md .notification-md-callout { + padding: 0.9rem 0.92rem 0.9rem 0.98rem; + } + + .notification-md .notification-md-callout-header { + margin-bottom: 0.6rem; + } + + .notification-md-mermaid { + padding: 0.85rem; + } + .sidebar { + padding: 10px 12px; + } + + .sidebar-header { + gap: 8px; + } + + .sidebar-logo-img { + width: 36px; + height: 36px; + } + + .nav-item { + padding: 8px 14px; + font-size: 13px; + } + + .top-bar { + padding: 10px 12px; + min-height: 56px; + } + + .main-content { + padding: 12px; + } + + .card { + padding: 14px; + } + + .header-clock-chip, + .connection-chip { + width: 100%; + justify-content: center; + } + + .task-next-run-primary-row, + .task-card-top, + .status-timer-primary-row { + flex-direction: column; + align-items: flex-start; + } + + .notification-feed-side, + .notification-feed-actions { + width: 100%; + justify-content: flex-start; + } + + .notification-feed-card-top { + align-items: flex-start; + } + + .notification-feed-heading-group { + width: 100%; + flex-wrap: wrap; + gap: 8px; + } + + .notification-feed-title { + width: 100%; + white-space: normal; + } + + .notification-feed-meta-inline { + padding-left: 0; + gap: 8px; + } + + .notification-feed-content-panel { + padding: 14px 15px 54px; + } + + .notification-feed-actions-inside { + right: 12px; + bottom: 10px; + } + + .status-timer-card-top { + grid-template-columns: 1fr; + } + + .status-timer-chip-stack { + justify-content: flex-start; + max-width: none; + } + + .status-action-tooltip-bubble { + left: 0; + transform: translateX(0) translateY(6px); + width: 100%; + max-width: none; + text-align: left; + } + + .status-action-tooltip-bubble::after { + left: 22px; + transform: none; + } + + .status-action-tooltip-wrap:hover .status-action-tooltip-bubble, + .status-action-tooltip-wrap:focus-within .status-action-tooltip-bubble { + transform: translateX(0) translateY(0); + } +} + +/* 超小屏幕:针对极窄设备继续缩小导航与容器 padding。 */ +@media (max-width: 360px) { + .sidebar { + padding: 8px 10px; + } + + .sidebar-logo-img { + width: 32px; + height: 32px; + } + + .nav-item { + padding: 7px 12px; + font-size: 12px; + } + + .top-bar { + padding: 8px 10px; + } + + .main-content { + padding: 10px; + } + + .card { + padding: 12px; + } +} + +/* ===== 动画、骨架屏与低动态偏好兼容 ===== */ +/* ========== 骨架屏动画 ========== */ +@keyframes card-fade-up { + 0% { + opacity: 0; + transform: translateY(10px) scale(0.985); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.skeleton { + /* skeleton 类可为未来异步区域提供统一的闪烁占位样式。 */ + background: linear-gradient( + 90deg, + var(--md-sys-color-surface-variant) 0%, + rgba(var(--md-sys-color-on-surface-variant-rgb, 73, 69, 79), 0.15) 50%, + var(--md-sys-color-surface-variant) 100% + ); + background-size: 200% 100%; + animation: skeleton-loading 1.5s ease-in-out infinite; + border-radius: var(--radius-sm); + will-change: background-position; +} + +@keyframes skeleton-loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +/* 群沉默重置提示中的小圆点脉冲动画。 */ +@keyframes status-reset-dot-pulse { + 0% { + transform: scale(0.9); + box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.34); + } + 70% { + transform: scale(1); + box-shadow: 0 0 0 8px rgba(37, 99, 235, 0); + } + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(37, 99, 235, 0); + } +} + +/* 群沉默重置提示条的淡入停留淡出动画。 */ +@keyframes status-reset-hint-fade { + 0% { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + 10% { + opacity: 1; + transform: translateY(0) scale(1); + } + 82% { + opacity: 1; + transform: translateY(0) scale(1); + } + 100% { + opacity: 0; + transform: translateY(-4px) scale(0.985); + } +} + +body.dark-theme .skeleton, +html.theme-dark .skeleton { + background: linear-gradient( + 90deg, + var(--md-sys-color-surface-variant) 0%, + rgba(202, 196, 208, 0.15) 50%, + var(--md-sys-color-surface-variant) 100% + ); + background-size: 200% 100%; +} + +/* 尊重系统“减少动态效果”偏好,关闭高频动画,提升无障碍体验。 */ +@media (prefers-reduced-motion: reduce) { + .boot-loader__logo, + .boot-loader__progress-bar, + .skeleton, + .status-timer-reset-dot, + .status-timer-reset-hint, + .status-panel-card, + .tasks-summary-card, + .task-card-enhanced, + .status-timer-card, + .status-timer-section-block, + .tasks-empty-card, + .status-timers-empty-card, + .notifications-hero-card, + .notifications-empty-card, + .markdown-docs-sidebar-card, + .markdown-docs-content-card, + .markdown-docs-empty-card, + .markdown-docs-empty-side-card, + .config-card, + .config-header, + .config-renderer-shell, + .config-renderer-main, + .config-renderer-aside, + .config-renderer-form-card, + .config-renderer-preview-card, + .config-renderer-session-card, + .notifications-inline-meta-item, + .markdown-docs-file-item { + animation: none !important; + } + + #loading-skeleton.is-exiting { + transition: opacity 0.15s linear !important; + transform: none !important; + } + + .boot-loader__progress-bar { + transform: translate3d(0, 0, 0) scaleX(1); + } +} diff --git a/pages/proactive-chat/fonts/outfit-400.ttf b/pages/proactive-chat/fonts/outfit-400.ttf new file mode 100644 index 0000000..0b0a0d7 Binary files /dev/null and b/pages/proactive-chat/fonts/outfit-400.ttf differ diff --git a/pages/proactive-chat/fonts/outfit-500.ttf b/pages/proactive-chat/fonts/outfit-500.ttf new file mode 100644 index 0000000..0ba786a Binary files /dev/null and b/pages/proactive-chat/fonts/outfit-500.ttf differ diff --git a/pages/proactive-chat/fonts/outfit-600.ttf b/pages/proactive-chat/fonts/outfit-600.ttf new file mode 100644 index 0000000..52e25bc Binary files /dev/null and b/pages/proactive-chat/fonts/outfit-600.ttf differ diff --git a/pages/proactive-chat/fonts/outfit-700.ttf b/pages/proactive-chat/fonts/outfit-700.ttf new file mode 100644 index 0000000..0389ff2 Binary files /dev/null and b/pages/proactive-chat/fonts/outfit-700.ttf differ diff --git a/pages/proactive-chat/fonts/outfit-800.ttf b/pages/proactive-chat/fonts/outfit-800.ttf new file mode 100644 index 0000000..6f0672b Binary files /dev/null and b/pages/proactive-chat/fonts/outfit-800.ttf differ diff --git a/pages/proactive-chat/fonts/outfit.css b/pages/proactive-chat/fonts/outfit.css new file mode 100644 index 0000000..d997f61 --- /dev/null +++ b/pages/proactive-chat/fonts/outfit.css @@ -0,0 +1,45 @@ +/* + * Outfit 字体族定义。 + * 采用本地静态字体文件而非远程 CDN,确保插件离线可用且首屏加载更稳定。 + * 各个字重拆分声明,浏览器可根据 font-weight 自动匹配最接近的字体文件。 + */ +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 400; + /* swap 可避免字体尚未加载时页面长时间不可见。 */ + font-display: swap; + src: url(outfit-400.ttf) format('truetype'); +} + +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(outfit-500.ttf) format('truetype'); +} + +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(outfit-600.ttf) format('truetype'); +} + +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(outfit-700.ttf) format('truetype'); +} + +@font-face { + font-family: 'Outfit'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: url(outfit-800.ttf) format('truetype'); +} diff --git a/pages/proactive-chat/index.html b/pages/proactive-chat/index.html new file mode 100644 index 0000000..f5154e2 --- /dev/null +++ b/pages/proactive-chat/index.html @@ -0,0 +1,29 @@ + + + + + + 主动消息管理端 + + + + + + + +
+ +
主动消息管理端
+
正在初始化 Pages 控制台...
+
+
+
+
+ +
+ + + + diff --git a/pages/proactive-chat/logo.png b/pages/proactive-chat/logo.png new file mode 100644 index 0000000..ddc92c5 Binary files /dev/null and b/pages/proactive-chat/logo.png differ diff --git a/pages/proactive-chat/page-app.css b/pages/proactive-chat/page-app.css new file mode 100644 index 0000000..ebe991c --- /dev/null +++ b/pages/proactive-chat/page-app.css @@ -0,0 +1,814 @@ +.pc-app { + min-height: 100vh; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + color: var(--md-sys-color-on-surface); +} + +.pc-sidebar { + position: sticky; + top: 0; + height: 100vh; + padding: 22px 18px; + border-right: 1px solid var(--glass-border); + background: linear-gradient(180deg, var(--glass-bg), rgba(255, 255, 255, 0.42)); + backdrop-filter: blur(18px); + overflow-y: auto; +} + +body.dark-theme .pc-sidebar { + background: linear-gradient(180deg, var(--glass-bg), rgba(28, 27, 31, 0.48)); +} + +.pc-brand { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} + +.pc-logo { + width: 54px; + height: 54px; + border-radius: 16px; + object-fit: cover; + box-shadow: 0 10px 28px rgba(103, 80, 164, 0.22); +} + +.pc-logo-mark { + width: 54px; + height: 54px; + display: grid; + place-items: center; + flex: 0 0 auto; + border-radius: 16px; + background: + radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.94), transparent 28px), + linear-gradient(135deg, var(--md-sys-color-primary), #14b8a6 52%, #f59e0b); + color: #fff; + box-shadow: 0 10px 28px rgba(103, 80, 164, 0.22); +} + +.pc-logo-mark span { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 11px; + background: rgba(255, 255, 255, 0.22); + font-size: 22px; + font-weight: 900; +} + +.pc-brand-title { + font-size: 20px; + font-weight: 800; + line-height: 1.1; +} + +.pc-brand-subtitle { + margin-top: 4px; + color: var(--md-sys-color-on-surface-variant); + font-size: 12px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; +} + +.pc-nav { + display: grid; + gap: 8px; +} + +.pc-nav-button, +.pc-icon-button, +.pc-button { + appearance: none; + border: 0; + font: inherit; + cursor: pointer; +} + +.pc-nav-button { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 46px; + padding: 0 14px; + border-radius: 16px; + background: transparent; + color: var(--md-sys-color-on-surface-variant); + font-weight: 700; + text-align: left; +} + +.pc-nav-button:hover, +.pc-nav-button.is-active { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); +} + +.pc-nav-icon { + width: 24px; + text-align: center; + font-size: 18px; +} + +.pc-sidebar-actions { + display: grid; + gap: 10px; + margin: 24px 0; +} + +.pc-github-card { + display: block; + padding: 14px; + border: 1px solid var(--glass-border); + border-radius: 20px; + background: var(--glass-bg); + color: inherit; + text-decoration: none; + box-shadow: var(--glass-shadow); +} + +.pc-github-author { + color: var(--md-sys-color-on-surface-variant); + font-size: 12px; + font-weight: 700; +} + +.pc-github-title { + margin-top: 4px; + font-size: 13px; + font-weight: 800; +} + +.pc-main { + min-width: 0; + display: flex; + flex-direction: column; + height: 100vh; +} + +.pc-topbar { + min-height: var(--top-bar-height); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px 28px; + border-bottom: 1px solid var(--glass-border); + background: rgba(254, 247, 255, 0.72); + backdrop-filter: blur(18px); +} + +body.dark-theme .pc-topbar { + background: rgba(20, 18, 24, 0.72); +} + +.pc-title { + font-size: 26px; + font-weight: 850; +} + +.pc-subtitle { + margin-top: 4px; + color: var(--md-sys-color-on-surface-variant); + font-size: 13px; +} + +.pc-topbar-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.pc-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--glass-border); + border-radius: 999px; + background: var(--glass-bg); + color: var(--md-sys-color-on-surface-variant); + font-size: 13px; + font-weight: 700; +} + +.pc-chip.is-ok { + color: #0F7A43; + background: rgba(15, 122, 67, 0.1); +} + +.pc-chip.is-warn { + color: #9A3412; + background: rgba(154, 52, 18, 0.1); +} + +.pc-icon-button { + width: 38px; + height: 38px; + border-radius: 14px; + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + font-weight: 900; +} + +.pc-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 24px 28px 34px; +} + +.pc-section { + display: none; +} + +.pc-section.is-active { + display: block; +} + +.pc-grid { + display: grid; + gap: 16px; +} + +.pc-grid.metrics { + grid-template-columns: repeat(4, minmax(160px, 1fr)); + margin-bottom: 18px; +} + +.pc-grid.two { + grid-template-columns: minmax(0, 1fr) minmax(320px, .8fr); +} + +.pc-card { + border: 1px solid var(--glass-border); + border-radius: 20px; + background: var(--glass-bg); + box-shadow: var(--glass-shadow); + padding: 18px; +} + +.pc-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.pc-card-title { + font-size: 16px; + font-weight: 850; +} + +.pc-card-subtitle { + margin-top: 4px; + color: var(--md-sys-color-on-surface-variant); + font-size: 13px; + line-height: 1.55; +} + +.pc-metric { + min-height: 118px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.pc-metric-label { + color: var(--md-sys-color-on-surface-variant); + font-size: 13px; + font-weight: 700; +} + +.pc-metric-value { + margin-top: 16px; + font-size: 30px; + font-weight: 900; +} + +.pc-list { + display: grid; + gap: 12px; +} + +.pc-row { + display: grid; + gap: 8px; + padding: 14px; + border: 1px solid var(--glass-border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.42); +} + +body.dark-theme .pc-row { + background: rgba(255, 255, 255, 0.04); +} + +.pc-row-title { + font-weight: 850; + word-break: break-word; +} + +.pc-row-meta { + color: var(--md-sys-color-on-surface-variant); + font-size: 13px; + line-height: 1.6; + word-break: break-word; +} + +.pc-row-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pc-inline-chip { + display: inline-flex; + align-items: center; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 850; + vertical-align: middle; +} + +.pc-inline-chip.is-ok { + background: rgba(20, 120, 80, 0.14); + color: #147850; +} + +.pc-inline-chip.is-warn { + background: rgba(180, 110, 20, 0.14); + color: #9a5f00; +} + +.pc-timer-list { + display: grid; + gap: 14px; +} + +.pc-timer-card { + display: grid; + gap: 12px; + padding: 16px; + border: 1px solid var(--glass-border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.46); +} + +body.dark-theme .pc-timer-card { + background: rgba(255, 255, 255, 0.05); +} + +.pc-timer-card.is-pending { + border-style: dashed; +} + +.pc-timer-card.is-pending .pc-timer-countdown { + color: var(--md-sys-color-on-surface-variant); +} + +.pc-timer-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.pc-timer-countdown { + font-size: 30px; + font-weight: 900; + color: var(--md-sys-color-primary); +} + +.pc-timer-countdown span { + color: var(--md-sys-color-on-surface-variant); + font-size: 14px; + font-weight: 800; +} + +.pc-progress { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: rgba(103, 80, 164, 0.14); +} + +.pc-progress > div { + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--md-sys-color-primary), #0F7A43); + transition: width .25s ease; +} + +.pc-button { + min-height: 38px; + padding: 0 14px; + border-radius: 14px; + background: var(--md-sys-color-primary); + color: var(--md-sys-color-on-primary); + font-weight: 800; +} + +.pc-button.secondary { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); +} + +.pc-button.ghost { + border: 1px solid var(--glass-border); + background: transparent; + color: var(--md-sys-color-on-surface); +} + +.pc-empty { + padding: 30px 16px; + color: var(--md-sys-color-on-surface-variant); + text-align: center; + border: 1px dashed var(--glass-border); + border-radius: 18px; +} + +.pc-error { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 14px; + background: rgba(179, 38, 30, 0.1); + color: #B3261E; + font-weight: 700; +} + +.pc-textarea, +.pc-input, +.pc-select { + width: 100%; + border: 1px solid var(--glass-border); + border-radius: 14px; + background: var(--md-sys-color-surface); + color: var(--md-sys-color-on-surface); + font: inherit; +} + +.pc-textarea { + min-height: 280px; + padding: 14px; + resize: vertical; + font-family: Consolas, "SFMono-Regular", monospace; + font-size: 13px; + line-height: 1.55; +} + +.pc-input, +.pc-select { + height: 42px; + padding: 0 12px; +} + +.pc-form-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.pc-config-card { + padding: 0; + overflow: hidden; +} + +.pc-config-toolbar, +.pc-config-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + border-bottom: 1px solid var(--glass-border); +} + +.pc-config-footer { + border-top: 1px solid var(--glass-border); + border-bottom: 0; + flex-wrap: wrap; +} + +.pc-config-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.pc-segmented { + display: inline-flex; + padding: 4px; + border: 1px solid var(--glass-border); + border-radius: 16px; + background: var(--md-sys-color-surface); +} + +.pc-segmented button { + min-height: 34px; + padding: 0 14px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--md-sys-color-on-surface-variant); + cursor: pointer; + font: inherit; + font-weight: 800; +} + +.pc-segmented button.is-active { + background: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); +} + +.session-picker { + min-width: 360px; +} + +.pc-config-form { + display: grid; + gap: 12px; + padding: 18px; +} + +.pc-config-group { + border: 1px solid var(--glass-border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.34); + overflow: hidden; +} + +body.dark-theme .pc-config-group { + background: rgba(255, 255, 255, 0.04); +} + +.pc-config-group.depth-0 { + border-color: rgba(103, 80, 164, 0.28); +} + +.pc-config-summary { + width: 100%; + min-height: 54px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 0; + background: rgba(103, 80, 164, 0.07); + color: var(--md-sys-color-on-surface); + cursor: pointer; + font: inherit; + text-align: left; +} + +.pc-config-summary:hover { + background: rgba(103, 80, 164, 0.12); +} + +.pc-config-arrow { + width: 22px; + color: var(--md-sys-color-primary); + font-weight: 900; + text-align: center; +} + +.pc-config-title, +.pc-field-title { + display: block; + font-weight: 850; +} + +.pc-config-hint, +.pc-field-hint { + display: block; + margin-top: 4px; + color: var(--md-sys-color-on-surface-variant); + font-size: 12px; + line-height: 1.55; +} + +.pc-config-count { + padding: 3px 8px; + border: 1px solid var(--glass-border); + border-radius: 999px; + color: var(--md-sys-color-primary); + font-size: 12px; + font-weight: 800; + white-space: nowrap; +} + +.pc-config-children { + display: grid; + gap: 10px; + padding: 12px; +} + +.pc-config-field, +.pc-session-enable { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 360px); + gap: 16px; + align-items: center; + padding: 14px; + border: 1px solid var(--glass-border); + border-radius: 14px; + background: var(--md-sys-color-surface); +} + +.pc-config-field.depth-0 { + grid-template-columns: minmax(0, 1fr) minmax(260px, 460px); +} + +.pc-session-enable { + margin: 14px 18px 0; + border-color: rgba(103, 80, 164, 0.22); + background: rgba(103, 80, 164, 0.06); +} + +.pc-field-copy { + min-width: 0; +} + +.pc-field-control { + min-width: 0; +} + +.pc-number-field { + display: grid; + grid-template-columns: minmax(120px, 1fr) 110px; + gap: 10px; + align-items: center; +} + +.pc-number-field .pc-input:only-child { + grid-column: 1 / -1; +} + +.pc-range { + width: 100%; + accent-color: var(--md-sys-color-primary); +} + +.pc-textarea.compact { + min-height: 96px; +} + +.pc-textarea.prompt { + min-height: 180px; +} + +.pc-switch { + position: relative; + display: inline-flex; + width: 52px; + height: 30px; + flex-shrink: 0; +} + +.pc-switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.pc-switch span { + position: absolute; + inset: 0; + border-radius: 999px; + background: rgba(120, 113, 108, 0.28); + transition: background .18s ease; +} + +.pc-switch span::after { + content: ""; + position: absolute; + top: 4px; + left: 4px; + width: 22px; + height: 22px; + border-radius: 50%; + background: #fff; + box-shadow: 0 2px 6px rgba(0, 0, 0, .24); + transition: transform .18s ease; +} + +.pc-switch input:checked + span { + background: var(--md-sys-color-primary); +} + +.pc-switch input:checked + span::after { + transform: translateX(22px); +} + +.pc-warning, +.pc-feedback { + margin: 14px 18px 0; + padding: 11px 13px; + border-radius: 14px; + font-size: 13px; + font-weight: 700; +} + +.pc-warning, +.pc-feedback.warn { + background: rgba(154, 52, 18, 0.1); + color: #9A3412; +} + +.pc-feedback.success { + background: rgba(15, 122, 67, 0.1); + color: #0F7A43; +} + +.pc-footer-note { + margin-top: 12px; + color: var(--md-sys-color-on-surface-variant); + font-size: 12px; +} + +@media (max-width: 980px) { + .pc-app { + grid-template-columns: 1fr; + } + + .pc-sidebar { + position: relative; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--glass-border); + } + + .pc-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .pc-main { + height: auto; + min-height: 100vh; + } + + .pc-grid.metrics, + .pc-grid.two { + grid-template-columns: 1fr; + } + + .pc-config-field, + .pc-config-field.depth-0, + .pc-session-enable { + grid-template-columns: 1fr; + } + + .session-picker { + min-width: 0; + width: 100%; + } +} + +@media (max-width: 620px) { + .pc-topbar { + align-items: flex-start; + flex-direction: column; + padding: 16px; + } + + .pc-content { + padding: 16px; + } + + .pc-nav { + grid-template-columns: 1fr; + } + + .pc-config-toolbar { + align-items: stretch; + flex-direction: column; + } + + .pc-config-actions, + .pc-segmented { + width: 100%; + } + + .pc-segmented button { + flex: 1; + } + + .pc-number-field { + grid-template-columns: 1fr; + } +} diff --git a/pages/proactive-chat/page-app.js b/pages/proactive-chat/page-app.js new file mode 100644 index 0000000..fb921c9 --- /dev/null +++ b/pages/proactive-chat/page-app.js @@ -0,0 +1,1291 @@ +(function () { + "use strict"; + + var PLUGIN_REPO = "https://github.com/DBJD-CR/astrbot_plugin_proactive_chat"; + var state = { + view: "status", + bridge: null, + bridgeReady: false, + error: "", + status: {}, + jobs: [], + sessions: [], + config: null, + configSchema: null, + configMode: "global", + expandedKeys: [], + saveFeedback: "", + saveFeedbackType: "", + sessionConfigState: { baseAvailable: true, message: "" }, + selectedSession: "", + sessionDetail: null, + theme: safeStorageGet("theme") || "light", + busy: {}, + realtimeTimer: null, + loadingSnapshot: false, + lastRealtimeAt: 0 + }; + + var viewMeta = { + status: { label: "运行状态", icon: "📊", subtitle: "服务状态、调度概览与会话计时器" }, + tasks: { label: "任务管理", icon: "📋", subtitle: "查看、立即触发、重新调度或取消会话任务" }, + config: { label: "配置管理", icon: "⚙️", subtitle: "编辑全局配置与会话差异配置" } + }; + + function safeStorageGet(key) { + try { + return localStorage.getItem(key); + } catch (e) { + return ""; + } + } + + function safeStorageSet(key, value) { + try { + localStorage.setItem(key, value); + } catch (e) {} + } + + function $(id) { + return document.getElementById(id); + } + + function escapeHtml(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function text(value, fallback) { + if (value === null || value === undefined || value === "") return fallback || "--"; + return String(value); + } + + function asArray(value) { + return Array.isArray(value) ? value : []; + } + + function objectKeys(value) { + return value && typeof value === "object" ? Object.keys(value) : []; + } + + function closest(node, selector) { + while (node && node !== document) { + if (node.matches && node.matches(selector)) return node; + node = node.parentNode; + } + return null; + } + + function parsePath(path) { + return String(path || "").split(".").filter(Boolean); + } + + function getByPath(source, path) { + var parts = parsePath(path); + var current = source; + for (var i = 0; i < parts.length; i += 1) { + if (!current || typeof current !== "object") return undefined; + current = current[parts[i]]; + } + return current; + } + + function setByPath(source, path, value) { + var parts = parsePath(path); + var current = source; + for (var i = 0; i < parts.length - 1; i += 1) { + var key = parts[i]; + if (!current[key] || typeof current[key] !== "object" || Array.isArray(current[key])) { + current[key] = {}; + } + current = current[key]; + } + if (parts.length) current[parts[parts.length - 1]] = value; + } + + function stripLeadingEmoji(value) { + return String(value || "").replace(/^[\s\u2600-\u27BF\uD800-\uDBFF][\s\uFE0F\u200D\uDC00-\uDFFF]*/g, "").trim() || String(value || ""); + } + + function setBusy(key, value) { + state.busy[key] = Boolean(value); + render(); + } + + function setError(message) { + state.error = message || ""; + render(); + } + + function formatDuration(totalSeconds) { + var seconds = Math.max(0, Math.floor(Number(totalSeconds) || 0)); + var days = Math.floor(seconds / 86400); + var hours = Math.floor((seconds % 86400) / 3600); + var minutes = Math.floor((seconds % 3600) / 60); + var secs = seconds % 60; + var parts = []; + if (days) parts.push(days + "天"); + if (hours) parts.push(hours + "小时"); + if (minutes) parts.push(minutes + "分"); + if (secs || !parts.length) parts.push(secs + "秒"); + return parts.slice(0, 3).join(""); + } + + function formatDate(value) { + if (!value) return "--"; + var date = new Date(value); + if (isNaN(date.getTime())) return text(value); + return date.toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + + function timestampMs(value) { + if (value === null || value === undefined || value === "") return null; + if (typeof value === "number") { + return value > 100000000000 ? value : value * 1000; + } + var raw = String(value); + if (/^\d+(\.\d+)?$/.test(raw)) { + var numeric = Number(raw); + return numeric > 100000000000 ? numeric : numeric * 1000; + } + var date = new Date(raw); + return isNaN(date.getTime()) ? null : date.getTime(); + } + + function clampPercent(value) { + var number = Math.round(Number(value) || 0); + return Math.max(0, Math.min(100, number)); + } + + function stableJson(value) { + if (Array.isArray(value)) { + return "[" + value.map(stableJson).join(",") + "]"; + } + if (value && typeof value === "object") { + var keys = Object.keys(value).sort(); + var parts = []; + for (var i = 0; i < keys.length; i += 1) { + parts.push(JSON.stringify(keys[i]) + ":" + stableJson(value[keys[i]])); + } + return "{" + parts.join(",") + "}"; + } + return JSON.stringify(value); + } + + function sameConfigValue(left, right) { + return stableJson(left || {}) === stableJson(right || {}); + } + + function normalizePayload(payload) { + if (!payload || typeof payload !== "object") return payload || {}; + if (payload.error) { + throw new Error(typeof payload.error === "string" ? payload.error : payload.error.message || "请求失败"); + } + if (payload.ok === false || payload.success === false) { + throw new Error(payload.message || "请求失败"); + } + if (typeof payload.code === "number" && payload.code !== 0) { + throw new Error(payload.message || payload.msg || "请求失败 (" + payload.code + ")"); + } + if ((payload.ok === true || payload.success === true || payload.code === 0) && Object.prototype.hasOwnProperty.call(payload, "data")) { + return normalizePayload(payload.data || {}); + } + return payload; + } + + function waitBridge(timeoutMs) { + return new Promise(function (resolve) { + var started = Date.now(); + if (window.AstrBotPluginPage) { + resolve(window.AstrBotPluginPage); + return; + } + var timer = setInterval(function () { + if (window.AstrBotPluginPage) { + clearInterval(timer); + resolve(window.AstrBotPluginPage); + return; + } + if (Date.now() - started > timeoutMs) { + clearInterval(timer); + resolve(null); + } + }, 60); + }); + } + + function apiGet(endpoint) { + if (!state.bridge || typeof state.bridge.apiGet !== "function") { + return Promise.reject(new Error("AstrBot Pages bridge 未注入,请从 AstrBot WebUI 的插件页面重新打开。")); + } + return Promise.resolve(state.bridge.apiGet(endpoint)).then(normalizePayload); + } + + function apiPost(endpoint, body) { + if (!state.bridge || typeof state.bridge.apiPost !== "function") { + return Promise.reject(new Error("AstrBot Pages bridge 未注入,请从 AstrBot WebUI 的插件页面重新打开。")); + } + return Promise.resolve(state.bridge.apiPost(endpoint, body || {})).then(normalizePayload); + } + + function directBridgePost(endpoint, body) { + if (!state.bridge || typeof state.bridge.apiPost !== "function") { + return Promise.reject(new Error("AstrBot Pages bridge 未注入,请从 AstrBot WebUI 的插件页面重新打开。")); + } + // 保存配置走裸 endpoint,用 received 标记确认命中了本插件的新 Pages 后端。 + return Promise.resolve(state.bridge.apiPost(endpoint, body || {})).then(normalizePayload); + } + + function route(endpoint) { + return endpoint; + } + + function hideBoot() { + var boot = $("loading-skeleton"); + if (!boot) return; + boot.classList.add("is-exiting"); + setTimeout(function () { + if (boot.parentNode) boot.parentNode.removeChild(boot); + }, 220); + } + + function initTheme() { + document.body.classList.toggle("dark-theme", state.theme === "dark"); + document.documentElement.classList.toggle("theme-dark", state.theme === "dark"); + } + + function toggleTheme() { + state.theme = state.theme === "dark" ? "light" : "dark"; + safeStorageSet("theme", state.theme); + initTheme(); + renderHeader(); + } + + function navButton(key) { + var meta = viewMeta[key]; + return [ + '' + ].join(""); + } + + function shellHtml() { + return [ + '
', + '', + '
', + '
', + '
', + '
', + '
', + '
', + '
', + '
', + '
', + '
' + ].join(""); + } + + function renderShell() { + $("root").innerHTML = shellHtml(); + bindShellEvents(); + render(); + } + + function bindShellEvents() { + document.addEventListener("click", function (event) { + var viewBtn = closest(event.target, "[data-view]"); + if (viewBtn) { + switchView(viewBtn.getAttribute("data-view")); + return; + } + var action = closest(event.target, "[data-action]"); + if (!action) return; + handleAction(action.getAttribute("data-action"), action); + }); + document.addEventListener("change", function (event) { + if (event.target && event.target.id === "session-select") { + state.selectedSession = event.target.value; + state.expandedKeys = []; + loadSessionDetail(state.selectedSession); + } + if (event.target && event.target.getAttribute("data-config-path")) { + updateConfigFromControl(event.target, true); + } + }); + document.addEventListener("input", function (event) { + if (event.target && event.target.getAttribute("data-config-path")) { + updateConfigFromControl(event.target, false); + } + }); + } + + function handleAction(action, node) { + if (action === "refresh") loadCurrentView(); + if (action === "theme") toggleTheme(); + if (action === "trigger-job") triggerJob(node.getAttribute("data-id")); + if (action === "reschedule-job") rescheduleJob(node.getAttribute("data-id")); + if (action === "cancel-job") cancelJob(node.getAttribute("data-id")); + if (action === "save-config") saveConfig(); + if (action === "load-config") loadConfig(); + if (action === "save-session") saveSessionConfig(); + if (action === "reset-session") resetSessionConfig(); + if (action === "config-mode") switchConfigMode(node.getAttribute("data-mode")); + if (action === "toggle-config") toggleConfigGroup(node.getAttribute("data-path")); + if (action === "toggle-all-config") toggleAllConfigGroups(); + if (action === "reset-config-defaults") resetConfigDefaults(); + if (action === "discard-config") loadConfig(); + } + + function switchView(view) { + if (!viewMeta[view]) return; + state.view = view; + render(); + loadCurrentView(); + } + + function loadCurrentView() { + if (state.view === "status") loadDashboard(); + if (state.view === "tasks") loadJobs(); + if (state.view === "config") loadConfig(); + } + + function renderHeader() { + var meta = viewMeta[state.view]; + var status = state.status || {}; + var connected = state.bridgeReady; + $("pc-topbar").innerHTML = [ + '
', escapeHtml(meta.label), '
', + '
', escapeHtml(meta.subtitle), '
', + '
', + '🕒 ', escapeHtml(formatDate(new Date())), '', + '', connected ? "已连接 Pages bridge" : "未连接 Pages bridge", '', + '同步 ', state.lastRealtimeAt ? escapeHtml(formatDate(state.lastRealtimeAt)) : '--', '', + 'WebSocket ', Number(status.ws_connections || 0), ' 个', + '', + '', + '
' + ].join(""); + } + + function renderError() { + $("pc-error").innerHTML = state.error ? '
' + escapeHtml(state.error) + '
' : ""; + } + + function render() { + if (!$("pc-topbar")) return; + initTheme(); + renderHeader(); + renderError(); + var keys = ["status", "tasks", "config"]; + for (var i = 0; i < keys.length; i += 1) { + var section = $("view-" + keys[i]); + if (section) section.classList.toggle("is-active", state.view === keys[i]); + } + var navs = document.querySelectorAll("[data-view]"); + for (var n = 0; n < navs.length; n += 1) { + navs[n].classList.toggle("is-active", navs[n].getAttribute("data-view") === state.view); + } + renderStatus(); + renderTasks(); + renderConfig(); + } + + function metric(label, value, hint) { + return [ + '
', + '
', escapeHtml(label), '
', + '
', escapeHtml(value), '
', + '
', escapeHtml(hint || ""), '
', + '
' + ].join(""); + } + + function renderStatus() { + var status = state.status || {}; + var timerCards = collectTimerCards(status); + var autoCards = timerCards.auto; + var groupCards = timerCards.group; + var fallbackCards = timerCards.fallback; + var scheduledAutoCards = fallbackCards.filter(function (card) { + return detectSessionType(card.session_id || card.session || card.id) !== "group"; + }); + var scheduledGroupCards = fallbackCards.filter(function (card) { + return detectSessionType(card.session_id || card.session || card.id) === "group"; + }); + var visibleAutoTriggerCount = autoCards.length + scheduledAutoCards.length; + var visibleGroupTimerCount = groupCards.length + scheduledGroupCards.length; + var autoTriggerSummary = visibleAutoTriggerCount + " 个"; + if (scheduledAutoCards.length) autoTriggerSummary += "(实时 " + autoCards.length + " / 调度 " + scheduledAutoCards.length + ")"; + var groupTimerSummary = visibleGroupTimerCount + " 个"; + if (scheduledGroupCards.length) groupTimerSummary += "(实时 " + groupCards.length + " / 调度 " + scheduledGroupCards.length + ")"; + var allCards = groupCards.concat(autoCards).concat(fallbackCards); + var realJobsCount = Number(status.jobs_count || 0); + var visibleJobsCount = Math.max(realJobsCount, asArray(state.jobs).length); + var pendingJobsCount = Math.max(0, visibleJobsCount - realJobsCount); + $("view-status").innerHTML = [ + '
', + metric("插件状态", status.running ? "运行中" : "已停止", "版本 " + text(status.version, "...")), + metric("运行时长", formatDuration(status.uptime_seconds), "启动后持续运行时间"), + metric("调度任务", text(visibleJobsCount, "0"), (status.scheduler_running ? "调度器运行中" : "调度器未启动") + " · 已调度 " + realJobsCount + " / 待调度 " + pendingJobsCount), + metric("会话数据", text(status.sessions_count, "0"), "自动/会话触发 " + visibleAutoTriggerCount + " / 群沉默 " + visibleGroupTimerCount), + '
', + '
', + '
会话计时器可视化
实时展示自动触发检测与群沉默检测的倒计时、进度和会话状态。
', + renderTimerList(allCards), '
', + '
调度概览
', + '
', + infoRow("调度器", status.scheduler_running ? "运行中" : "未启动"), + infoRow("当前任务总数", visibleJobsCount + " 个"), + pendingJobsCount ? infoRow("待调度会话", pendingJobsCount + " 个") : "", + infoRow("自动/会话触发计时器", autoTriggerSummary), + infoRow("群沉默计时器", groupTimerSummary), + fallbackCards.length ? infoRow("其中会话调度倒计时", fallbackCards.length + " 个") : "", + infoRow("数据时间", formatDate(status.timestamp)), + '
' + ].join(""); + } + + function infoRow(label, value) { + return '
' + escapeHtml(label) + '
' + escapeHtml(value) + '
'; + } + + function collectTimerCards(status) { + var autoCards = asArray(status.auto_trigger_cards); + var groupCards = asArray(status.group_timer_cards); + var seen = {}; + var seenSessions = {}; + var fallbackSessions = {}; + var fallback = []; + var jobs = asArray(state.jobs); + + function cardSessionId(card) { + return String(card && (card.session_id || card.session || card.id) || ""); + } + + function cardHasTarget(card) { + return !!(card && (card.target_time || card.next_trigger_time || card.next_run_time || card.remaining_seconds !== null && card.remaining_seconds !== undefined)); + } + + function mark(card) { + var sessionId = cardSessionId(card); + var key = String(card.timer_kind || "timer") + ":" + sessionId; + if (key !== "timer:") seen[key] = true; + if (sessionId && cardHasTarget(card)) seenSessions[sessionId] = true; + } + for (var a = 0; a < autoCards.length; a += 1) mark(autoCards[a] || {}); + for (var g = 0; g < groupCards.length; g += 1) mark(groupCards[g] || {}); + + function pushFallback(source, kind) { + source = source || {}; + var id = source.session || source.id || source.session_id || ""; + var targetRaw = source.next_trigger_time || source.next_run_time; + var target = timestampMs(targetRaw); + if (!id || !target) return; + var key = kind + ":" + id; + if (seen[key] || seenSessions[id]) return; + var remaining = Math.max(0, Math.ceil((target - Date.now()) / 1000)); + var windowSeconds = Number(source.last_schedule_random_interval_seconds || source.last_schedule_max_interval_seconds || 0); + if (!windowSeconds && source.schedule_max_interval_minutes) windowSeconds = Number(source.schedule_max_interval_minutes) * 60; + var started = timestampMs(source.last_scheduled_at); + var progress = 0; + if (started && target > started) { + progress = clampPercent(((Date.now() - started) / (target - started)) * 100); + } else if (windowSeconds > 0) { + progress = clampPercent(((windowSeconds - remaining) / windowSeconds) * 100); + } + fallback.push({ + session_id: id, + session: id, + session_name: source.session_name, + session_display_name: source.session_display_name, + session_category: source.session_category, + timer_kind: kind, + timer_kind_label: kind === "scheduled_job" ? "调度任务" : "下次触发", + status: remaining <= 0 ? "expired" : "running", + remaining_seconds: remaining, + target_time: target / 1000, + window_seconds: windowSeconds, + progress_percent: progress, + unanswered_count: source.unanswered_count, + max_unanswered_times: source.max_unanswered_times, + last_schedule_strategy: source.last_schedule_strategy, + last_schedule_reason: source.last_schedule_reason, + last_schedule_rule: source.last_schedule_rule, + last_schedule_source: source.last_schedule_source + }); + seen[key] = true; + seenSessions[id] = true; + fallbackSessions[id] = true; + } + + for (var j = 0; j < jobs.length; j += 1) pushFallback(jobs[j], "scheduled_job"); + fallback.sort(function (left, right) { + return Number(left.remaining_seconds || 0) - Number(right.remaining_seconds || 0); + }); + function keepPrimaryCard(card) { + var id = cardSessionId(card); + return !id || !fallbackSessions[id] || cardHasTarget(card); + } + return { auto: autoCards.filter(keepPrimaryCard), group: groupCards.filter(keepPrimaryCard), fallback: fallback }; + } + + function timerStatusLabel(item) { + if (item.status === "waiting_message") return "等待消息"; + if (item.status === "waiting_idle") return "等待空闲"; + if (item.status === "pending_timer") return "未挂起"; + if (item.status === "unknown") return "待确认"; + if (item.remaining_seconds === null || item.remaining_seconds === undefined) return "未启动"; + if (Number(item.remaining_seconds || 0) <= 0) return "待刷新"; + if (Number(item.remaining_seconds || 0) <= 300) return "即将触发"; + return "计时中"; + } + + function scheduleStrategyLabel(item) { + var strategy = String(item && item.last_schedule_strategy || "").toLowerCase(); + var source = String(item && item.last_schedule_source || "").toLowerCase(); + if (strategy === "contextual" || source === "recent_context") return "语境预测"; + if (strategy === "random" || source === "random_interval") return "随机区间"; + return ""; + } + + function scheduleReasonLabel(item) { + var rule = String(item && item.last_schedule_rule || ""); + var reason = String(item && item.last_schedule_reason || ""); + var labels = { + explicit_delay: "明确延后", + tomorrow: "明天再聊", + do_not_disturb: "暂不打扰", + sleep_night: "睡眠休息", + movie: "观影追剧", + meeting_or_class: "会议课程", + commute: "通勤路上", + meal: "用餐时间", + shower: "短时离开", + game: "游戏中", + short_later: "稍后再聊" + }; + if (labels[rule]) return labels[rule]; + if (reason.indexOf("context:explicit_delay:") === 0) { + return "明确延后 " + reason.replace("context:explicit_delay:", ""); + } + return reason; + } + + function renderTimerList(items) { + if (!items.length) return '
🫧 暂无运行中的会话计时器
'; + var html = ['
']; + for (var i = 0; i < items.length; i += 1) { + var item = items[i] || {}; + var progress = clampPercent(item.progress_percent); + var hasCountdown = item.remaining_seconds !== null && item.remaining_seconds !== undefined; + var remaining = hasCountdown ? formatDuration(item.remaining_seconds) : (item.status === "waiting_message" ? "等待消息" : "--"); + var countdownSuffix = hasCountdown ? " 后触发" : (item.status === "waiting_message" ? " 后开始" : " 待启动"); + var targetText = item.target_time ? formatDate(Number(item.target_time) * 1000) : "--"; + var chipClass = hasCountdown && Number(item.remaining_seconds || 0) > 300 ? "is-ok" : "is-warn"; + var detailText = (item.timer_kind_label || item.title || item.timer_kind || "计时器") + " · 目标时间 " + targetText + " · 未回复 " + text(item.unanswered_count, "0") + "/" + text(item.max_unanswered_times, "0"); + var strategyText = scheduleStrategyLabel(item); + var reasonText = scheduleReasonLabel(item); + if (strategyText) detailText += " · 调度 " + strategyText; + if (reasonText && strategyText === "语境预测") detailText += " · " + reasonText; + if (item.inactive_reason) detailText += " · " + item.inactive_reason; + html.push('
'); + html.push('
', escapeHtml(item.session_display_name || item.session_name || item.session || item.session_id || item.id || "会话"), '
'); + html.push('
', escapeHtml(item.session_id || item.session || item.id || ""), '
'); + html.push('', escapeHtml(timerStatusLabel(item)), '
'); + html.push('
', escapeHtml(remaining), '', escapeHtml(countdownSuffix), '
'); + html.push('
', escapeHtml(detailText), '
'); + html.push('
'); + html.push('
进度 ', progress, '%', item.window_seconds ? ' · 窗口 ' + escapeHtml(formatDuration(item.window_seconds)) : '', '
'); + html.push('
'); + } + html.push('
'); + return html.join(""); + } + + function renderTasks() { + var jobs = state.jobs || []; + if (!jobs.length) { + $("view-tasks").innerHTML = '
任务列表
暂无调度任务或已配置会话
'; + return; + } + var html = ['
任务列表
当前共 ', jobs.length, ' 个任务
']; + for (var i = 0; i < jobs.length; i += 1) { + var job = jobs[i] || {}; + var id = job.id || job.session || ""; + var isPending = job.status === "pending_schedule"; + var statusLabel = job.status_label || (isPending ? "待调度" : "已调度"); + var nextTime = job.next_run_time || job.next_trigger_time; + var detailText = "下次运行: " + formatDate(timestampMs(nextTime) || nextTime) + " · 来源: " + text(job.source_mode, "--") + " · 未回复: " + text(job.unanswered_count, "0") + "/" + text(job.max_unanswered_times, "0"); + var strategyText = scheduleStrategyLabel(job); + var reasonText = scheduleReasonLabel(job); + if (strategyText) detailText += " · 调度: " + strategyText; + if (reasonText && strategyText === "语境预测") detailText += " · " + reasonText; + if (job.inactive_reason) detailText += " · " + job.inactive_reason; + html.push('
'); + html.push('
', escapeHtml(job.session_display_name || job.session_name || id || "任务"), ' ', escapeHtml(statusLabel), '
'); + html.push('
UMO: ', escapeHtml(id), '
'); + html.push('
', escapeHtml(detailText), '
'); + html.push('
'); + html.push(''); + html.push(''); + if (!isPending) html.push(''); + html.push('
'); + } + html.push('
'); + $("view-tasks").innerHTML = html.join(""); + } + + function detectSessionType(sessionId) { + var raw = String(sessionId || ""); + if (raw.indexOf(":GroupMessage:") >= 0 || raw.indexOf(":GuildMessage:") >= 0) return "group"; + return "friend"; + } + + function getSessionSchemaEntries(schema, sessionType) { + var rootKey = sessionType === "group" ? "group_settings" : "friend_settings"; + var rootItems = schema && schema[rootKey] && schema[rootKey].items || {}; + var orderedKeys = [ + "auto_trigger_settings", + "group_idle_trigger_minutes", + "proactive_prompt", + "context_settings", + "schedule_settings", + "tts_settings", + "segmented_reply_settings" + ]; + var entries = []; + entries.push(["session_name", rootItems.session_name || { + type: "string", + default: "", + description: "会话备注名", + hint: "用于日志和管理端展示。为空时将回退显示 UMO。" + }]); + for (var i = 0; i < orderedKeys.length; i += 1) { + if (rootItems[orderedKeys[i]]) entries.push([orderedKeys[i], rootItems[orderedKeys[i]]]); + } + return entries; + } + + function getConfigEntries() { + var schema = state.configSchema || {}; + if (state.configMode === "session") { + return getSessionSchemaEntries(schema, detectSessionType(state.selectedSession)); + } + var keys = objectKeys(schema); + var entries = []; + for (var i = 0; i < keys.length; i += 1) entries.push([keys[i], schema[keys[i]]]); + return entries; + } + + function getAllExpandablePathsFromEntries(entries, prefix) { + var paths = []; + for (var i = 0; i < entries.length; i += 1) { + var key = entries[i][0]; + var schema = entries[i][1] || {}; + var path = prefix ? prefix + "." + key : key; + if (schema.type === "object" && schema.items) { + paths.push(path); + var childEntries = []; + var childKeys = objectKeys(schema.items); + for (var j = 0; j < childKeys.length; j += 1) childEntries.push([childKeys[j], schema.items[childKeys[j]]]); + paths = paths.concat(getAllExpandablePathsFromEntries(childEntries, path)); + } + } + return paths; + } + + function schemaTitle(key, schema) { + return stripLeadingEmoji(schema && schema.description || key); + } + + function schemaDefault(schema) { + if (!schema) return ""; + if (schema.default !== undefined) return schema.default; + if (schema.type === "bool" || schema.type === "boolean") return false; + if (schema.type === "int" || schema.type === "integer" || schema.type === "number" || schema.type === "float" || schema.type === "double") return 0; + if (schema.type === "list" || schema.type === "array") return []; + if (schema.type === "object") return {}; + return ""; + } + + function fieldValue(path, schema) { + var value = getByPath(state.config, path); + return value === undefined ? schemaDefault(schema) : value; + } + + function conditionMatches(path, schema) { + if (!schema || !schema.condition) return true; + var parts = parsePath(path); + parts.pop(); + var parent = getByPath(state.config, parts.join(".")) || {}; + var keys = objectKeys(schema.condition); + for (var i = 0; i < keys.length; i += 1) { + if (parent[keys[i]] !== schema.condition[keys[i]]) return false; + } + return true; + } + + function controlAttrs(path, type) { + return ' data-config-path="' + escapeHtml(path) + '" data-config-type="' + escapeHtml(type || "string") + '"'; + } + + function renderConfigField(key, schema, path, depth) { + schema = schema || {}; + if (schema.hidden || !conditionMatches(path, schema)) return ""; + var value = fieldValue(path, schema); + var title = schemaTitle(key, schema); + var hint = schema.hint ? '
' + escapeHtml(schema.hint) + '
' : ""; + var type = schema.type || "string"; + + if (type === "object" && schema.items) { + var expanded = state.expandedKeys.indexOf(path) >= 0; + var childKeys = objectKeys(schema.items); + var children = []; + if (expanded) { + for (var i = 0; i < childKeys.length; i += 1) { + var childKey = childKeys[i]; + children.push(renderConfigField(childKey, schema.items[childKey], path + "." + childKey, depth + 1)); + } + } + return [ + '
', + '', + expanded ? '
' + children.join("") + '
' : "", + '
' + ].join(""); + } + + var input = ""; + if (type === "bool" || type === "boolean") { + input = ''; + } else if (type === "int" || type === "integer" || type === "number" || type === "float" || type === "double") { + var slider = schema.slider || {}; + var min = slider.min !== undefined ? slider.min : schema.minimum; + var max = slider.max !== undefined ? slider.max : schema.maximum; + var step = slider.step !== undefined ? slider.step : (type === "int" || type === "integer" ? 1 : 0.1); + var rangeAttrs = ""; + if (min !== undefined) rangeAttrs += ' min="' + escapeHtml(min) + '"'; + if (max !== undefined) rangeAttrs += ' max="' + escapeHtml(max) + '"'; + if (step !== undefined) rangeAttrs += ' step="' + escapeHtml(step) + '"'; + input = [ + '
', + min !== undefined && max !== undefined ? '' : "", + '', + '
' + ].join(""); + } else if ((schema.options && Array.isArray(schema.options))) { + var labels = Array.isArray(schema.labels) ? schema.labels : []; + var options = []; + for (var o = 0; o < schema.options.length; o += 1) { + options.push(''); + } + input = ''; + } else if (type === "list" || type === "array") { + input = ''; + } else if (type === "text") { + input = ''; + } else { + input = ''; + } + + return [ + '
', + '
', escapeHtml(title), '
', hint, '
', + '
', input, '
', + '
' + ].join(""); + } + + function renderConfig() { + var schema = state.configSchema || {}; + var entries = getConfigEntries(); + var sessionOptions = ['']; + for (var i = 0; i < state.sessions.length; i += 1) { + var s = state.sessions[i] || {}; + var value = s.session || s; + var display = s.session_display_name || s.session_name || value; + if (s.session_name && s.session_name !== display) display += " (" + value + ")"; + sessionOptions.push(''); + } + var fields = []; + for (var e = 0; e < entries.length; e += 1) { + fields.push(renderConfigField(entries[e][0], entries[e][1], entries[e][0], 0)); + } + var selectedMeta = null; + for (var m = 0; m < state.sessions.length; m += 1) { + if ((state.sessions[m].session || state.sessions[m]) === state.selectedSession) selectedMeta = state.sessions[m]; + } + var canSaveSession = state.configMode !== "session" || state.sessionConfigState.baseAvailable; + var saveBusy = state.configMode === "session" ? state.busy.sessionSave : state.busy.configSave; + var saveAction = state.configMode === "session" ? "save-session" : "save-config"; + var saveText = state.configMode === "session" ? "保存会话配置" : "保存配置"; + if (saveBusy) saveText = "保存中..."; + $("view-config").innerHTML = [ + '
', + '
', + '
', + '', + '', + '
', + '
', + state.configMode === "session" ? '' : "", + '', + '
', + '
', + state.configMode === "session" && selectedMeta ? '' : "", + state.configMode === "session" && state.config ? renderSessionEnableCard() : "", + state.sessionConfigState.message ? '
' + escapeHtml(state.sessionConfigState.message) + '
' : "", + state.saveFeedback ? '' : "", + objectKeys(schema).length ? '
' + fields.join("") + '
' : '
暂无配置 Schema
', + '', + '
' + ].join(""); + } + + function renderSessionEnableCard() { + var enabled = Boolean(state.config && state.config.enable); + var label = detectSessionType(state.selectedSession) === "group" ? "群聊会话启用状态" : "私聊会话启用状态"; + return [ + '
', + '
', label, '
', + '
这是当前会话的独立开关。关闭后,该会话会暂停主动消息,但不影响同类型全局配置与其他会话。
', + '
', enabled ? "已启用" : "已暂停", '', + '
', + '
' + ].join(""); + } + + function setFeedback(type, message) { + state.saveFeedbackType = type || ""; + state.saveFeedback = message || ""; + } + + function switchConfigMode(mode) { + if (mode !== "global" && mode !== "session") return; + state.configMode = mode; + state.expandedKeys = []; + setFeedback("", ""); + if (mode === "session" && !state.selectedSession && state.sessions.length) { + state.selectedSession = state.sessions[0].session || state.sessions[0]; + } + loadConfig(); + } + + function toggleConfigGroup(path) { + var index = state.expandedKeys.indexOf(path); + if (index >= 0) { + state.expandedKeys.splice(index, 1); + } else { + state.expandedKeys.push(path); + } + render(); + } + + function toggleAllConfigGroups() { + if (state.expandedKeys.length) { + state.expandedKeys = []; + } else { + state.expandedKeys = getAllExpandablePathsFromEntries(getConfigEntries(), ""); + } + render(); + } + + function generateDefaults(entries) { + var result = {}; + for (var i = 0; i < entries.length; i += 1) { + var key = entries[i][0]; + var schema = entries[i][1] || {}; + if (schema.type === "object" && schema.items) { + var childEntries = []; + var childKeys = objectKeys(schema.items); + for (var j = 0; j < childKeys.length; j += 1) childEntries.push([childKeys[j], schema.items[childKeys[j]]]); + result[key] = generateDefaults(childEntries); + } else { + result[key] = schemaDefault(schema); + } + } + return result; + } + + function resetConfigDefaults() { + if (!window.confirm("确定要恢复默认值吗?\n\n这会覆盖当前编辑区内容,需要点击保存后才会写入。")) return; + state.config = generateDefaults(getConfigEntries()); + if (state.configMode === "session" && state.config.enable === undefined) { + state.config.enable = true; + } + setFeedback("warn", "已恢复为默认值,点击保存后生效。"); + render(); + } + + function updateConfigFromControl(node, shouldRender, suppressRender) { + if (!state.config || typeof state.config !== "object") state.config = {}; + var path = node.getAttribute("data-config-path"); + var type = node.getAttribute("data-config-type") || "string"; + var value = node.value; + if (type === "bool") { + value = Boolean(node.checked); + shouldRender = true; + } else if (type === "list") { + value = String(value || "").split(/\r?\n/); + } else if (type === "int" || type === "integer") { + if (value === "" || value === "-") return; + value = parseInt(value, 10); + if (isNaN(value)) value = 0; + } else if (type === "number" || type === "float" || type === "double") { + if (value === "" || value === "-") return; + value = parseFloat(value); + if (isNaN(value)) value = 0; + } + setByPath(state.config, path, value); + setFeedback("", ""); + if (node.type === "range") { + var pair = document.querySelector('input[type="number"][data-config-path="' + path.replace(/"/g, '\\"') + '"]'); + if (pair) pair.value = node.value; + } + if (!suppressRender && (shouldRender || type === "select")) render(); + } + + function syncConfigFromVisibleControls() { + if (!state.config || typeof state.config !== "object") state.config = {}; + var root = $("view-config") || document; + var nodes = root.querySelectorAll("[data-config-path]"); + for (var i = 0; i < nodes.length; i += 1) { + updateConfigFromControl(nodes[i], false, true); + } + } + + function cleanConfig(obj) { + if (Array.isArray(obj)) { + var list = []; + for (var i = 0; i < obj.length; i += 1) { + var item = typeof obj[i] === "string" ? obj[i].trim() : obj[i]; + if (item !== "") list.push(item); + } + return list; + } + if (obj && typeof obj === "object") { + var next = {}; + var keys = objectKeys(obj); + for (var k = 0; k < keys.length; k += 1) next[keys[k]] = cleanConfig(obj[keys[k]]); + return next; + } + return obj; + } + + function stripSessionRuntimeKeys(config) { + var cleaned = cleanConfig(config || {}); + if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) return {}; + // 会话 effective 配置会带运行时元信息,保存和回读校验都只比较真实配置字段。 + var copy = {}; + var keys = objectKeys(cleaned); + for (var i = 0; i < keys.length; i += 1) { + if (keys[i].charAt(0) === "_") continue; + copy[keys[i]] = cleaned[keys[i]]; + } + return copy; + } + + function loadDashboard() { + if (!state.bridgeReady) { + setError("AstrBot Pages bridge 未注入,当前只能显示静态页面。"); + return; + } + apiGet(route("dashboard")).then(function (data) { + applyDashboardPayload(data); + setError(""); + render(); + }).catch(function () { + Promise.all([apiGet(route("status")), apiGet(route("jobs")), apiGet(route("session-config/sessions"))]).then(function (parts) { + state.status = parts[0] || {}; + state.jobs = asArray(parts[1].jobs || parts[1]); + state.sessions = asArray(parts[2].sessions || parts[2]); + setError(""); + render(); + }).catch(function (err) { + setError(err.message || "加载状态失败"); + }); + }); + } + + function applyDashboardPayload(data) { + data = data || {}; + state.status = data.status || state.status || {}; + state.jobs = asArray(data.jobs); + state.sessions = asArray(data.sessions); + state.lastRealtimeAt = Date.now(); + } + + function loadRealtimeSnapshot() { + if (!state.bridgeReady || !state.bridge) return; + if (state.loadingSnapshot) return; + if (document.visibilityState === "hidden") return; + if (state.busy.configSave || state.busy.sessionSave) return; + if (state.view === "config") return; + state.loadingSnapshot = true; + apiGet(route("dashboard")).then(function (data) { + applyDashboardPayload(data); + if (state.view === "status" || state.view === "tasks") render(); + }).catch(function () { + return Promise.all([apiGet(route("status")), apiGet(route("jobs")), apiGet(route("session-config/sessions"))]).then(function (parts) { + state.status = parts[0] || state.status || {}; + state.jobs = asArray(parts[1].jobs || parts[1]); + state.sessions = asArray(parts[2].sessions || parts[2]); + state.lastRealtimeAt = Date.now(); + if (state.view === "status" || state.view === "tasks") render(); + }).catch(function () {}); + }).finally(function () { + state.loadingSnapshot = false; + }); + } + + function initRealtimeSync() { + if (state.realtimeTimer) return; + state.realtimeTimer = setInterval(loadRealtimeSnapshot, 1000); + } + + function loadJobs() { + apiGet(route("jobs")).then(function (data) { + state.jobs = asArray(data.jobs || data); + setError(""); + render(); + }).catch(function (err) { + setError(err.message || "加载任务失败"); + }); + } + + function triggerJob(id) { + if (!id) return; + apiPost(route("jobs/" + encodeURIComponent(id) + "/trigger"), {}).then(loadJobs).catch(function (err) { setError(err.message); }); + } + + function rescheduleJob(id) { + if (!id) return; + apiPost(route("jobs/" + encodeURIComponent(id) + "/reschedule"), {}).then(loadJobs).catch(function (err) { setError(err.message); }); + } + + function cancelJob(id) { + if (!id) return; + apiPost(route("jobs-cancel/" + encodeURIComponent(id)), {}).then(loadJobs).catch(function (err) { setError(err.message); }); + } + + function loadConfig() { + Promise.all([ + apiGet(route("get_config")), + apiGet(route("config-schema")), + apiGet(route("session-config/sessions")) + ]).then(function (parts) { + state.configSchema = parts[1] && (parts[1].schema || parts[1]) || {}; + state.sessions = asArray(parts[2].sessions || parts[2]); + if (state.configMode === "session") { + if (!state.selectedSession && state.sessions.length) { + state.selectedSession = state.sessions[0].session || state.sessions[0]; + } + if (state.selectedSession) { + loadSessionDetail(state.selectedSession); + return; + } + state.config = null; + state.sessionConfigState = { baseAvailable: false, message: "请先选择一个会话。" }; + } else { + state.config = parts[0] || {}; + state.sessionConfigState = { baseAvailable: true, message: "" }; + } + if (!state.expandedKeys.length) { + state.expandedKeys = getAllExpandablePathsFromEntries(getConfigEntries(), ""); + } + setError(""); + render(); + }).catch(function (err) { + setError(err.message || "加载配置失败"); + }); + } + + function saveConfig() { + try { + syncConfigFromVisibleControls(); + var cleaned = cleanConfig(state.config || {}); + state.config = cleaned; + var payload = { + friend_settings: cleaned.friend_settings, + group_settings: cleaned.group_settings, + web_admin: cleaned.web_admin, + notification_settings: cleaned.notification_settings + }; + setBusy("configSave", true); + // 保存后立即回读,避免只更新前端状态而后端实际没有持久化。 + directBridgePost("save_config", payload).then(function (data) { + if (!data || data.received !== true) { + throw new Error("保存请求没有命中新后端 save_config 接口,请重载插件后再试"); + } + return apiGet(route("get_config")).then(function (serverConfig) { + var mismatched = []; + var keys = ["friend_settings", "group_settings", "web_admin", "notification_settings"]; + for (var i = 0; i < keys.length; i += 1) { + if (!sameConfigValue(serverConfig && serverConfig[keys[i]], payload[keys[i]])) { + mismatched.push(keys[i]); + } + } + if (mismatched.length) { + throw new Error("保存请求已到后端,但配置回读不一致: " + mismatched.join(", ")); + } + state.config = serverConfig || cleaned; + setFeedback("success", "全局配置已保存,后端回读校验通过。"); + setError(""); + render(); + }); + }).catch(function (err) { + setFeedback("error", err.message || "配置保存失败"); + setError(err.message); + }).finally(function () { + setBusy("configSave", false); + }); + } catch (e) { + setError("JSON 格式错误: " + e.message); + } + } + + function loadSessionDetail(session) { + if (!session) { + state.sessionDetail = null; + render(); + return; + } + apiGet(route("session-config/" + encodeURIComponent(session))).then(function (data) { + state.sessionDetail = data || {}; + state.config = state.sessionDetail.effective || state.sessionDetail.override || {}; + state.sessionConfigState = state.sessionDetail.base ? { baseAvailable: true, message: "" } : { + baseAvailable: false, + message: "该会话尚未命中对应类型的全局 session_list,暂时无法保存会话差异配置。请先在对应全局配置中加入该会话。" + }; + if (!state.expandedKeys.length) { + state.expandedKeys = getAllExpandablePathsFromEntries(getConfigEntries(), ""); + } + setError(""); + render(); + }).catch(function (err) { + setError(err.message || "加载会话配置失败"); + }); + } + + function saveSessionConfig() { + if (!state.selectedSession) { + setError("请先选择会话"); + return; + } + try { + syncConfigFromVisibleControls(); + var effective = stripSessionRuntimeKeys(state.config || {}); + var payload = { mode: "effective", effective: effective }; + setBusy("sessionSave", true); + apiPost(route("session-config-save/" + encodeURIComponent(state.selectedSession)), payload).then(function (data) { + return apiGet(route("session-config/" + encodeURIComponent(state.selectedSession))).then(function (serverDetail) { + var serverEffective = stripSessionRuntimeKeys(serverDetail && serverDetail.effective); + if (!sameConfigValue(serverEffective, payload.effective)) { + throw new Error("会话配置保存请求已到后端,但配置回读不一致"); + } + state.sessionDetail = serverDetail || data || {}; + state.config = state.sessionDetail.effective || payload.effective; + setFeedback("success", "会话差异配置已保存,后端回读校验通过。"); + setError(""); + render(); + }); + }).catch(function (err) { + setFeedback("error", err.message || "会话配置保存失败"); + setError(err.message); + }).finally(function () { + setBusy("sessionSave", false); + }); + } catch (e) { + setError("JSON 格式错误: " + e.message); + } + } + + function resetSessionConfig() { + if (!state.selectedSession) { + setError("请先选择会话"); + return; + } + if (!window.confirm("确定要清空该会话的差异配置吗?\n\n清空后将完全继承全局默认配置。")) return; + apiPost(route("session-config-delete/" + encodeURIComponent(state.selectedSession)), {}).then(function (data) { + state.sessionDetail = data || {}; + state.config = state.sessionDetail.effective || {}; + setFeedback("success", "会话差异配置已清空。"); + loadConfig(); + }).catch(function (err) { setError(err.message); }); + } + + function initClock() { + setInterval(function () { + var node = $("pc-clock"); + if (node) node.textContent = formatDate(new Date()); + if (state.view === "status" && $("view-status")) renderStatus(); + }, 1000); + } + + function init() { + initTheme(); + renderShell(); + hideBoot(); + initClock(); + waitBridge(5000).then(function (bridge) { + state.bridge = bridge; + state.bridgeReady = !!bridge; + if (!bridge) { + setError("AstrBot Pages bridge 未注入,当前页面无法读取插件数据。"); + return; + } + Promise.resolve(typeof bridge.ready === "function" ? bridge.ready() : null).then(function () { + state.bridgeReady = true; + loadDashboard(); + initRealtimeSync(); + }).catch(function (err) { + state.bridgeReady = false; + setError(err.message || "AstrBot Pages bridge 初始化失败"); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})();