feat: revamp web hub — global connection status, array config editor, log streaming
- Global connection badge in app header (persistent dashboard SSE via ConnectionProvider) - LogProvider keeps log data and SSE stream alive across page switches (no reload spinner) - Config drawer: password reveal toggle, array-type row editor for list fields - Plugin management: optimistic updates, race-free loading, reset all group overrides - Logs: load-earlier pagination; backend tail-read optimization (before/end offsets) - web_auth: Web password changes persist (sync_admin seeding only when .env has explicit key) - Dashboard: collect in thread pool + 1s result cache - ErrorBoundary + local Suspense: plugin chunk loading stays inside content area - UI polish: table row hover, mobile header/breadcrumb fixes, wider config drawer - config_standard: array type inference for list fields; array env skip - random_jm_code / steam_info: array schema migration; README web frontend note Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,7 @@ HeXi/
|
||||
│ ├── data/ # 运行数据(不入库)
|
||||
│ ├── config/ # 业务配置(不入库)
|
||||
│ ├── resource/ # 全局静态资源(fonts/ 字体、menu_config 等)
|
||||
│ ├── web/ # 统一 Web 前端(Vue 3)
|
||||
│ ├── web/ # 统一 Web 前端(React + Vite,构建产物 dist/ 供 /hub 挂载)
|
||||
│ ├── logs/ # 日志(不入库)
|
||||
│ └── web_*.py # Web 管理相关(鉴权、插件注册)
|
||||
├── docs/ # 插件开发标准文档
|
||||
@@ -118,9 +118,9 @@ poetry run pyright # 类型检查
|
||||
|
||||
新增插件请参考:
|
||||
|
||||
- [插件结构标准](docs/plugin-structure-standard.md)
|
||||
- [插件配置标准](docs/plugin-config-standard.md)
|
||||
- `plugin_template/` 脚手架模板
|
||||
- [插件结构标准](dev/docs/plugin-structure-standard.md)
|
||||
- [插件配置标准](dev/docs/plugin-config-standard.md)
|
||||
- `dev/plugin_template/` 脚手架模板
|
||||
|
||||
## 许可
|
||||
|
||||
|
||||
+16
-9
@@ -63,10 +63,7 @@ def _type_label(annotation: Any) -> str:
|
||||
if origin is Literal:
|
||||
return "enum"
|
||||
if origin in (list, set, tuple):
|
||||
args = get_args(annotation)
|
||||
if args and args[0] is str:
|
||||
return "text"
|
||||
return "text"
|
||||
return "array"
|
||||
return "string"
|
||||
|
||||
|
||||
@@ -119,11 +116,11 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if typ == "text":
|
||||
if typ in {"text", "array"}:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
items = list(value)
|
||||
else:
|
||||
# TextArea 多行/逗号分隔都接受
|
||||
# 数组字段的字符串输入:多行/逗号分隔都接受
|
||||
import re as _re
|
||||
parts = _re.split(r"[,\n]+", str(value))
|
||||
items = [p.strip() for p in parts if p.strip()]
|
||||
@@ -168,6 +165,16 @@ def _coerce_value(value: Any, typ: str, item_type: str = "str") -> Any:
|
||||
|
||||
def _stringify(value: Any, typ: str) -> Any:
|
||||
"""把 python 值转成 Web 表单能显示的值。"""
|
||||
if typ == "array":
|
||||
# 数组字段直接回传数组,前端用行编辑器(增删行)渲染;
|
||||
# 兼容旧数据:换行/逗号分隔的字符串按行拆成数组
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(v) for v in value]
|
||||
import re as _re
|
||||
parts = _re.split(r"[,\n]+", str(value))
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
if typ == "text":
|
||||
if value is None:
|
||||
return ""
|
||||
@@ -240,7 +247,7 @@ def register_model_config(
|
||||
}
|
||||
if typ == "enum":
|
||||
field["options"] = (options or {}).get(key) or _enum_options(annotation)
|
||||
if typ == "text":
|
||||
if typ in {"text", "array"}:
|
||||
field["item_type"] = _text_item_type(annotation)
|
||||
schema_fields.append(field)
|
||||
|
||||
@@ -353,7 +360,7 @@ def register_config_items(
|
||||
}
|
||||
if item.get("options"):
|
||||
field["options"] = item["options"]
|
||||
if item.get("type") == "text":
|
||||
if item.get("type") in {"text", "array"}:
|
||||
field["item_type"] = item.get("item_type", "str")
|
||||
if item.get("type") == "object_set" and item.get("item_schema"):
|
||||
field["item_schema"] = item["item_schema"]
|
||||
@@ -435,7 +442,7 @@ def register_object_set(
|
||||
"description": sf.get("description", ""),
|
||||
"secret": sf.get("secret", _is_secret(sf["key"])),
|
||||
}
|
||||
if sf.get("type") == "text":
|
||||
if sf.get("type") in {"text", "array"}:
|
||||
field["item_type"] = sf.get("item_type", "str")
|
||||
opts = (options_by_key or {}).get(sf["key"]) or sf.get("options")
|
||||
if opts:
|
||||
|
||||
@@ -24,6 +24,16 @@ BLOCK_CODE = ["350235", "350234"]
|
||||
WHITE_LIST = ["1019165018"]
|
||||
|
||||
|
||||
def _jm_codes_getter() -> list[str]:
|
||||
from .services.store import sync_get_jm_codes
|
||||
return sync_get_jm_codes()
|
||||
|
||||
|
||||
def _jm_codes_setter(codes) -> None:
|
||||
from .services.store import sync_set_jm_codes
|
||||
sync_set_jm_codes(codes)
|
||||
|
||||
|
||||
def register_config() -> None:
|
||||
register_config_items(
|
||||
_PLUGIN_ID,
|
||||
@@ -31,24 +41,35 @@ def register_config() -> None:
|
||||
{
|
||||
"key": "TEXT_TEMPLATES",
|
||||
"label": "随机文案模板",
|
||||
"type": "text",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": TEXT_TEMPLATES,
|
||||
},
|
||||
{
|
||||
"key": "BLOCK_CODE",
|
||||
"label": "屏蔽码",
|
||||
"type": "text",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": BLOCK_CODE,
|
||||
},
|
||||
{
|
||||
"key": "WHITE_LIST",
|
||||
"label": "白名单群号",
|
||||
"type": "text",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": WHITE_LIST,
|
||||
},
|
||||
{
|
||||
"key": "JM_CODES",
|
||||
"label": "JM 码列表",
|
||||
"type": "array",
|
||||
"item_type": "str",
|
||||
"default": [],
|
||||
"description": "一行一个(或逗号分隔)。保存会写回 hexi/data/random_jm_code/jm_code.json,机器人随改随用。",
|
||||
"getter": _jm_codes_getter,
|
||||
"setter": _jm_codes_setter,
|
||||
"nosave": True,
|
||||
},
|
||||
],
|
||||
store=_sys.modules[__name__],
|
||||
)
|
||||
|
||||
@@ -136,7 +136,7 @@ register_model_config(
|
||||
"proxy",
|
||||
],
|
||||
types={
|
||||
"steam_api_key": "text",
|
||||
"steam_api_key": "array",
|
||||
"steam_broadcast_type": "enum",
|
||||
"steam_playtime_theme": "enum",
|
||||
},
|
||||
|
||||
@@ -54,8 +54,13 @@ basic_path = Path(__file__).resolve().parent
|
||||
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
|
||||
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
|
||||
|
||||
# 管理台自身版本号(展示在设置页「关于」处)
|
||||
HUB_VERSION = "0.1.0"
|
||||
def _hub_version() -> str:
|
||||
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
|
||||
try:
|
||||
pkg = WEB_DIST.parent / "package.json"
|
||||
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
|
||||
except Exception: # noqa: BLE001
|
||||
return "0.1.0"
|
||||
|
||||
# 仓库根目录(bot.py 所在)与 bot 端口
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
@@ -118,6 +123,65 @@ def _archive_log() -> None:
|
||||
logger.warning(f"归档日志失败: {type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def _tail_log_lines(
|
||||
path: Path, limit: int, before: int | None
|
||||
) -> tuple[list[str], int, int]:
|
||||
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
|
||||
|
||||
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
|
||||
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
|
||||
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
|
||||
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
|
||||
"""
|
||||
size = path.stat().st_size
|
||||
end = size if before is None or before <= 0 or before > size else int(before)
|
||||
if end <= 0:
|
||||
return [], 0, size
|
||||
start = 0
|
||||
with open(path, "rb") as f:
|
||||
pos = end
|
||||
newline_count = 0
|
||||
while pos > 0 and newline_count <= limit:
|
||||
step = min(pos, 8192)
|
||||
f.seek(pos - step)
|
||||
chunk = f.read(step)
|
||||
idx = chunk.rfind(b"\n")
|
||||
if idx == -1:
|
||||
pos -= step
|
||||
continue
|
||||
nl_abs = pos - step + idx
|
||||
newline_count += 1
|
||||
if newline_count > limit:
|
||||
start = nl_abs + 1
|
||||
break
|
||||
pos = nl_abs
|
||||
f.seek(start)
|
||||
raw = f.read(end - start)
|
||||
raw_lines = raw.split(b"\n")
|
||||
if raw_lines and raw_lines[-1] == b"":
|
||||
raw_lines.pop()
|
||||
lines = []
|
||||
for raw_line in raw_lines:
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
line = raw_line.decode("gbk", errors="replace")
|
||||
lines.append(line.rstrip("\r"))
|
||||
return lines, start, size
|
||||
|
||||
|
||||
def _count_log_lines(path: Path) -> int:
|
||||
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
|
||||
count = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(1 << 20)
|
||||
if not chunk:
|
||||
break
|
||||
count += chunk.count(b"\n")
|
||||
return count
|
||||
|
||||
|
||||
def _schedule_bot_action(action: str) -> None:
|
||||
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
|
||||
|
||||
@@ -205,7 +269,7 @@ def build_hub_app() -> FastAPI:
|
||||
"created_at": user.get("created_at", ""),
|
||||
},
|
||||
"plugins": list_web_plugins(),
|
||||
"version": HUB_VERSION,
|
||||
"version": _hub_version(),
|
||||
}
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
@@ -360,9 +424,15 @@ def build_hub_app() -> FastAPI:
|
||||
@app.get("/api/logs")
|
||||
async def logs(
|
||||
limit: int = 500,
|
||||
before: int | None = None,
|
||||
_: dict = Depends(get_current_user),
|
||||
):
|
||||
"""实时读取 bot 运行日志(优先 _bot_run.log),返回最近 limit 行,供前端轮询。"""
|
||||
"""读取 bot 运行日志(优先 _bot_run.log)。
|
||||
|
||||
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
|
||||
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
|
||||
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
|
||||
"""
|
||||
root = Path(__file__).resolve().parents[3] # 仓库根目录
|
||||
candidates = [
|
||||
root / "_bot_run.log",
|
||||
@@ -372,20 +442,20 @@ def build_hub_app() -> FastAPI:
|
||||
if path is None:
|
||||
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
|
||||
total = _count_log_lines(path)
|
||||
except OSError as e: # noqa: BLE001
|
||||
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
|
||||
# 逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与切换为 UTF-8 后的新日志混存
|
||||
# 保留 ANSI 颜色码,交给前端渲染颜色
|
||||
lines = []
|
||||
for raw_line in raw.split(b"\n"):
|
||||
try:
|
||||
line = raw_line.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
line = raw_line.decode("gbk", errors="replace")
|
||||
lines.append(line.rstrip("\r"))
|
||||
tail = lines[-max(1, int(limit)) :]
|
||||
return {"ok": True, "file": path.name, "size": len(raw), "offset": len(raw), "lines": tail, "total": len(lines)}
|
||||
return {
|
||||
"ok": True,
|
||||
"file": path.name,
|
||||
"size": size,
|
||||
"end": size,
|
||||
"offset": start,
|
||||
"has_more": start > 0,
|
||||
"lines": lines,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
@app.get("/api/dashboard/stream")
|
||||
async def dashboard_stream(
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
|
||||
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
|
||||
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
|
||||
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
|
||||
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,17 +28,21 @@ _MODULE_START = time.time()
|
||||
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
|
||||
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
|
||||
|
||||
# 采集结果短缓存(多标签页 / SSE 每 2s 循环共用一份结果)
|
||||
_DASH_CACHE: dict = {"time": 0.0, "data": None}
|
||||
_DASH_CACHE_TTL = 1.0
|
||||
|
||||
async def _collect_cpu() -> dict:
|
||||
|
||||
def _cpu_sync() -> dict:
|
||||
try:
|
||||
per_core = await asyncio.to_thread(psutil.cpu_percent, 0.3, True)
|
||||
per_core = psutil.cpu_percent(0.3, True)
|
||||
except TypeError:
|
||||
# 兼容部分平台不接受 percpu 参数
|
||||
per_core = []
|
||||
if per_core:
|
||||
percent = sum(per_core) / len(per_core)
|
||||
else:
|
||||
percent = await asyncio.to_thread(psutil.cpu_percent, 0.3)
|
||||
percent = psutil.cpu_percent(0.3)
|
||||
info: dict = {
|
||||
"percent": round(float(percent), 1),
|
||||
"per_core": [round(float(x), 1) for x in (per_core or [])],
|
||||
@@ -57,7 +63,7 @@ async def _collect_cpu() -> dict:
|
||||
return info
|
||||
|
||||
|
||||
async def _collect_memory() -> dict:
|
||||
def _memory_sync() -> dict:
|
||||
mem = psutil.virtual_memory()
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
@@ -76,10 +82,10 @@ async def _collect_memory() -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _collect_process() -> dict:
|
||||
def _process_sync() -> dict:
|
||||
try:
|
||||
proc = psutil.Process()
|
||||
cpu = await asyncio.to_thread(proc.cpu_percent, 0.2)
|
||||
cpu = proc.cpu_percent(0.2)
|
||||
info = {
|
||||
"pid": proc.pid,
|
||||
"name": proc.name(),
|
||||
@@ -94,7 +100,7 @@ async def _collect_process() -> dict:
|
||||
return info
|
||||
|
||||
|
||||
async def _collect_network() -> dict:
|
||||
def _network_sync() -> dict:
|
||||
"""采集网络流量累计值 + 实时速率(bytes/s)。"""
|
||||
global _NET_LAST
|
||||
now = time.time()
|
||||
@@ -157,7 +163,7 @@ async def _collect_network() -> dict:
|
||||
}
|
||||
|
||||
|
||||
async def _collect_backend() -> dict:
|
||||
def _backend_sync() -> dict:
|
||||
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
|
||||
from nonebot import get_loaded_plugins
|
||||
|
||||
@@ -183,7 +189,7 @@ async def _collect_backend() -> dict:
|
||||
return info
|
||||
|
||||
|
||||
async def _collect_system() -> dict:
|
||||
def _system_sync() -> dict:
|
||||
boot = psutil.boot_time()
|
||||
now = time.time()
|
||||
return {
|
||||
@@ -206,7 +212,7 @@ def _nonebot_version() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
async def _collect_disk() -> list:
|
||||
def _disk_sync() -> list:
|
||||
parts = []
|
||||
for part in psutil.disk_partitions():
|
||||
try:
|
||||
@@ -271,18 +277,25 @@ async def _collect_bots() -> list:
|
||||
|
||||
|
||||
async def collect_dashboard() -> dict:
|
||||
"""聚合采集,失败字段降级,不阻塞整体返回。"""
|
||||
cpu, memory, process, system, disk, bots, network, backend = await asyncio.gather(
|
||||
_collect_cpu(),
|
||||
_collect_memory(),
|
||||
_collect_process(),
|
||||
_collect_system(),
|
||||
_collect_disk(),
|
||||
_collect_bots(),
|
||||
_collect_network(),
|
||||
_collect_backend(),
|
||||
"""聚合采集,失败字段降级,不阻塞整体返回。
|
||||
|
||||
同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞),
|
||||
结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。
|
||||
"""
|
||||
now = time.time()
|
||||
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
|
||||
return _DASH_CACHE["data"]
|
||||
cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
|
||||
asyncio.to_thread(_cpu_sync),
|
||||
asyncio.to_thread(_memory_sync),
|
||||
asyncio.to_thread(_process_sync),
|
||||
asyncio.to_thread(_system_sync),
|
||||
asyncio.to_thread(_disk_sync),
|
||||
asyncio.to_thread(_network_sync),
|
||||
asyncio.to_thread(_backend_sync),
|
||||
_collect_bots(), # 需要走 bot API,必须留在事件循环
|
||||
)
|
||||
return {
|
||||
data = {
|
||||
"bots": bots,
|
||||
"system": system,
|
||||
"cpu": cpu,
|
||||
@@ -292,3 +305,6 @@ async def collect_dashboard() -> dict:
|
||||
"network": network,
|
||||
"backend": backend,
|
||||
}
|
||||
_DASH_CACHE["time"] = now
|
||||
_DASH_CACHE["data"] = data
|
||||
return data
|
||||
|
||||
+64
-63
@@ -1,8 +1,17 @@
|
||||
const TOKEN_KEY = 'hexi_web_token'
|
||||
const USER_KEY = 'hexi_web_username'
|
||||
export const TOKEN_KEY = 'hexi_web_token'
|
||||
export const USER_KEY = 'hexi_web_username'
|
||||
|
||||
export const AUTH_EXPIRED_EVENT = 'hexi:auth-expired'
|
||||
|
||||
export function getToken() { return localStorage.getItem(TOKEN_KEY) || '' }
|
||||
|
||||
export function handleAuthExpired() {
|
||||
clearSession()
|
||||
// 通知 React AuthProvider 同步清空内存态,AppLayout 会通过 React Router
|
||||
// 客户端跳转到登录页(内容区局部切换,不整页刷新)。
|
||||
try { window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
interface ReqOpts { method?: string; body?: any; headers?: Record<string,string> }
|
||||
|
||||
async function request(url: string, opts: ReqOpts = {}) {
|
||||
@@ -12,6 +21,9 @@ async function request(url: string, opts: ReqOpts = {}) {
|
||||
const res = await fetch(url, { method: opts.method || 'GET', headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined })
|
||||
let data: any = null
|
||||
try { data = await res.json() } catch { data = null }
|
||||
if (res.status === 401) {
|
||||
handleAuthExpired()
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = (data && (data.detail || data.msg)) || ('HTTP ' + res.status)
|
||||
throw new Error(msg)
|
||||
@@ -40,7 +52,8 @@ export const authLogin = async (username: string, password: string) => {
|
||||
if (data && data.access_token === undefined) throw new Error('登录失败')
|
||||
return { token: data.access_token, username: data.username || username }
|
||||
}
|
||||
export const authMe = async () => await request('/hub/api/auth/me')
|
||||
// 登出时吊销服务端令牌(避免已登出的 token 在 24h 内仍可复用)
|
||||
export const authLogout = async () => await request('/hub/api/auth/logout', { method: 'POST' })
|
||||
export const hubPlugins = async () => await request('/hub/api/plugins')
|
||||
export const hubDashboard = async () => await request('/hub/api/dashboard')
|
||||
export const hubPluginCatalog = async () => await request('/hub/api/plugins/catalog')
|
||||
@@ -58,82 +71,39 @@ export const hubPluginSaveConfig = async (id: string, values: Record<string, any
|
||||
export const hubSettings = async () => await request('/hub/api/settings')
|
||||
export const hubChangePassword = async (old_password: string, new_password: string) =>
|
||||
await request('/hub/api/settings/password', { method: 'POST', body: { old_password, new_password } })
|
||||
export const hubLogs = async (limit = 500) => await request('/hub/api/logs?limit=' + limit)
|
||||
export const hubLogs = async (limit = 500, before?: number) =>
|
||||
await request('/hub/api/logs?limit=' + limit + (before != null ? '&before=' + before : ''))
|
||||
export const hubBotAction = async (action: 'stop' | 'restart') =>
|
||||
await request('/hub/api/system/bot', { method: 'POST', body: { action } })
|
||||
export const hubBotConfig = async () => await request('/hub/api/system/bot_config')
|
||||
export const hubBotConfigSave = async (values: Record<string, any>) =>
|
||||
await request('/hub/api/system/bot_config', { method: 'POST', body: values })
|
||||
|
||||
export function hubLogStream(opts: {
|
||||
since: number
|
||||
onLine: (line: string) => void
|
||||
onStatus: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
const controller = new AbortController()
|
||||
let stopped = false
|
||||
let since = opts.since
|
||||
|
||||
const read = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const res = await fetch('/hub/api/logs/stream?since=' + since, {
|
||||
headers: { Authorization: 'Bearer ' + getToken() },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
|
||||
opts.onStatus('open')
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
let i
|
||||
while ((i = buf.indexOf('\n\n')) !== -1) {
|
||||
const block = buf.slice(0, i)
|
||||
buf = buf.slice(i + 2)
|
||||
for (const ln of block.split('\n')) {
|
||||
if (ln.startsWith('data: ')) {
|
||||
try {
|
||||
const obj = JSON.parse(ln.slice(6))
|
||||
if (obj && obj.line !== undefined) {
|
||||
opts.onLine(obj.line)
|
||||
if (typeof obj.offset === 'number') since = obj.offset
|
||||
}
|
||||
} catch { /* 忽略坏帧 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!stopped) opts.onStatus('reconnecting')
|
||||
} catch (e: any) {
|
||||
if (stopped || (e && e.name === 'AbortError')) return
|
||||
opts.onStatus('reconnecting')
|
||||
}
|
||||
if (!stopped) await new Promise(res => setTimeout(res, 1500))
|
||||
}
|
||||
}
|
||||
|
||||
read()
|
||||
return () => { stopped = true; controller.abort() }
|
||||
interface SSEStreamOpts {
|
||||
url: () => string
|
||||
onData: (obj: any) => void
|
||||
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
retryMs?: number
|
||||
}
|
||||
|
||||
export function hubDashboardStream(opts: {
|
||||
onData: (data: any) => void
|
||||
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
// SSE 统一封装:fetch 流式读取 + 断线重连 + 401 登出通知
|
||||
function makeSSEStream(opts: SSEStreamOpts): () => void {
|
||||
const controller = new AbortController()
|
||||
let stopped = false
|
||||
const retryMs = opts.retryMs ?? 1500
|
||||
|
||||
const read = async () => {
|
||||
while (!stopped) {
|
||||
try {
|
||||
const res = await fetch('/hub/api/dashboard/stream', {
|
||||
const res = await fetch(opts.url(), {
|
||||
headers: { Authorization: 'Bearer ' + getToken() },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (res.status === 401) {
|
||||
handleAuthExpired()
|
||||
opts.onStatus?.('closed')
|
||||
return
|
||||
}
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status)
|
||||
opts.onStatus?.('open')
|
||||
const reader = res.body.getReader()
|
||||
@@ -159,7 +129,7 @@ export function hubDashboardStream(opts: {
|
||||
if (stopped || (e && e.name === 'AbortError')) return
|
||||
opts.onStatus?.('reconnecting')
|
||||
}
|
||||
if (!stopped) await new Promise(res => setTimeout(res, 2000))
|
||||
if (!stopped) await new Promise(res => setTimeout(res, retryMs))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +137,37 @@ export function hubDashboardStream(opts: {
|
||||
return () => { stopped = true; controller.abort() }
|
||||
}
|
||||
|
||||
export function hubLogStream(opts: {
|
||||
since: number
|
||||
onLine: (line: string) => void
|
||||
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
let since = opts.since
|
||||
return makeSSEStream({
|
||||
url: () => '/hub/api/logs/stream?since=' + since,
|
||||
onData: (obj) => {
|
||||
if (obj && obj.line !== undefined) {
|
||||
opts.onLine(obj.line)
|
||||
if (typeof obj.offset === 'number') since = obj.offset
|
||||
}
|
||||
},
|
||||
onStatus: opts.onStatus,
|
||||
retryMs: 1500,
|
||||
})
|
||||
}
|
||||
|
||||
export function hubDashboardStream(opts: {
|
||||
onData: (data: any) => void
|
||||
onStatus?: (s: 'open' | 'reconnecting' | 'closed') => void
|
||||
}): () => void {
|
||||
return makeSSEStream({
|
||||
url: () => '/hub/api/dashboard/stream',
|
||||
onData: opts.onData,
|
||||
onStatus: opts.onStatus,
|
||||
retryMs: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export const api = (plugin: string) => ({
|
||||
get: (path: string) => request(`/api/${plugin}/${path}`),
|
||||
post: (path: string, body: any) => request(`/api/${plugin}/${path}`, { method: 'POST', body }),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@heroui/react'
|
||||
import { useAuth } from './auth'
|
||||
|
||||
export function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// 被 AppLayout 带到登录页时记住原目标,登录后回跳
|
||||
const from = (location.state as any)?.from || '/hub'
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
@@ -15,7 +18,7 @@ export function LoginPage() {
|
||||
setLoading(true); setErr('')
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/hub')
|
||||
navigate(from, { replace: true })
|
||||
} catch (e: any) {
|
||||
setErr(e.message || '登录失败')
|
||||
} finally { setLoading(false) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
import { authLogin, clearSession, getUserName, setSession } from '../api/client'
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { authLogin, authLogout, AUTH_EXPIRED_EVENT, clearSession, getUserName, setSession, TOKEN_KEY, USER_KEY } from '../api/client'
|
||||
|
||||
interface AuthState {
|
||||
token: string
|
||||
@@ -11,8 +11,8 @@ interface AuthState {
|
||||
const Ctx = createContext<AuthState>({ token: '', username: '', login: async () => {}, logout: () => {} })
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState(() => localStorage.getItem('hexi_web_token') || '')
|
||||
const [username, setUsername] = useState(() => localStorage.getItem('hexi_web_username') || '')
|
||||
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
|
||||
const [username, setUsername] = useState(() => localStorage.getItem(USER_KEY) || '')
|
||||
const login = async (u: string, p: string) => {
|
||||
const data = await authLogin(u, p)
|
||||
setSession(data.token, data.username)
|
||||
@@ -20,10 +20,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUsername(data.username)
|
||||
}
|
||||
const logout = () => {
|
||||
// 先吊销服务端令牌(fire-and-forget,失败不阻塞退出),再清理本地
|
||||
authLogout().catch(() => {})
|
||||
clearSession()
|
||||
setToken('')
|
||||
setUsername('')
|
||||
}
|
||||
|
||||
// 任何请求命中 401(登录态无效/过期)时,同步清空内存态,
|
||||
// AppLayout 会立即自动跳转到登录页。
|
||||
useEffect(() => {
|
||||
const onExpired = () => {
|
||||
clearSession()
|
||||
setToken('')
|
||||
setUsername('')
|
||||
}
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, onExpired)
|
||||
return () => window.removeEventListener(AUTH_EXPIRED_EVENT, onExpired)
|
||||
}, [])
|
||||
|
||||
return <Ctx.Provider value={{ token, username, login, logout }}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
|
||||
interface Props { children: ReactNode; compact?: boolean }
|
||||
interface State { error: Error | null }
|
||||
|
||||
/**
|
||||
* 顶层 ErrorBoundary(full)与内容区 ErrorBoundary(compact)共用。
|
||||
* 插件页面/子路由抛异常时不再整页白屏,给出「重试 / 回首页」出口。
|
||||
*/
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children
|
||||
const err = this.state.error
|
||||
if (this.props.compact) {
|
||||
return (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm text-red-600">{err.message || '页面渲染出错'}</p>
|
||||
<button
|
||||
className="mt-2 text-sm text-indigo-600 hover:underline"
|
||||
onClick={() => this.setState({ error: null })}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-slate-200 bg-white p-8 text-center">
|
||||
<div className="text-lg font-semibold text-slate-800">页面出错了</div>
|
||||
<p className="mt-2 break-all text-sm text-slate-500">{err.message || '未知错误'}</p>
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
className="rounded-lg bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-200"
|
||||
onClick={() => { this.setState({ error: null }) }}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-600"
|
||||
onClick={() => { window.location.href = '/hub' }}
|
||||
>
|
||||
回到首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@heroui/react'
|
||||
|
||||
/** 插件页共用样式常量与小组件(表格/选择器/页头/Tabs/分页)。 */
|
||||
|
||||
export const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
|
||||
export const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
|
||||
export const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
|
||||
export function PageHead({ title, actions }: { title: string; actions?: ReactNode }) {
|
||||
return <div className="flex items-center justify-between"><h2 className="text-xl font-semibold text-slate-800">{title}</h2>{actions}</div>
|
||||
}
|
||||
|
||||
export interface TabItem { key: string; label: string }
|
||||
|
||||
export function Tabs({ tabs, active, onChange }: { tabs: TabItem[]; active: string; onChange: (key: string) => void }) {
|
||||
return <div className="flex gap-1 border-b border-slate-200 mb-4">
|
||||
{tabs.map(t => <button key={t.key} onClick={() => onChange(t.key)} className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t.key ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>{t.label}</button>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function Pager({ page, total, perPage, onPage }: { page: number; total: number; perPage: number; onPage: (p: number) => void }) {
|
||||
const pages = Math.max(1, Math.ceil(total / perPage))
|
||||
return <div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
|
||||
<Button size="sm" variant="tertiary" isDisabled={page <= 1} onPress={() => onPage(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {pages} 页 · 共 {total} 条</span>
|
||||
<Button size="sm" variant="tertiary" isDisabled={page >= pages} onPress={() => onPage(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { hubDashboardStream } from './api/client'
|
||||
|
||||
export type ConnStatus = '连接中' | '实时' | '重连中' | '已断开'
|
||||
|
||||
interface ConnState {
|
||||
status: ConnStatus
|
||||
data: any | null
|
||||
error: string
|
||||
lastUpdate: number | null
|
||||
}
|
||||
|
||||
const Ctx = createContext<ConnState>({ status: '连接中', data: null, error: '', lastUpdate: null })
|
||||
|
||||
/**
|
||||
* 全局连接状态:AppLayout 内常驻一个 dashboard SSE(对 /hub/api/dashboard/stream)。
|
||||
* 顶栏徽章显示实时/重连/断开;首页数据也直接消费该流,不再各自订阅。
|
||||
*/
|
||||
export function ConnectionProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<ConnStatus>('连接中')
|
||||
const [data, setData] = useState<any | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [lastUpdate, setLastUpdate] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = hubDashboardStream({
|
||||
onData: (d) => {
|
||||
if (d && d.ok !== false) { setData(d); setError(''); setLastUpdate(Date.now()) }
|
||||
else if (d && d.ok === false) setError(d.msg || '采集失败')
|
||||
},
|
||||
onStatus: (s) => setStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
|
||||
})
|
||||
return dispose
|
||||
}, [])
|
||||
|
||||
return <Ctx.Provider value={{ status, data, error, lastUpdate }}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
||||
export const useConnection = () => useContext(Ctx)
|
||||
@@ -1,21 +1,32 @@
|
||||
import { useLayoutEffect, useEffect, useRef, useState, Suspense } from 'react'
|
||||
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Separator } from '@heroui/react'
|
||||
import { House, Rocket, Gear, FileText } from '@gravity-ui/icons'
|
||||
import { Avatar, Button, Separator, Spinner } from '@heroui/react'
|
||||
import { House, Rocket, Gear, FileText, Bars } from '@gravity-ui/icons'
|
||||
import { useAuth } from '../auth/auth'
|
||||
import { hubPlugins } from '../api/client'
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||
import { useConnection } from '../connection'
|
||||
|
||||
interface NavItem { id: string; label: string; description: string; icon: any }
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
|
||||
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
|
||||
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
|
||||
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
|
||||
]
|
||||
|
||||
// 插件名映射做模块级缓存:布局多次挂载(路由切换)时不必重复拉取
|
||||
let webNamesCache: Record<string, string> | null = null
|
||||
|
||||
export function AppLayout() {
|
||||
const { token, username, logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// 全局连接状态(顶栏徽章):由常驻 dashboard SSE 驱动
|
||||
const { status, lastUpdate } = useConnection()
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'home', label: '首页', description: '主机与服务状态', icon: House },
|
||||
{ id: 'plugins', label: '插件管理', description: '插件总览与分群控制', icon: Rocket },
|
||||
{ id: 'logs', label: '查看日志', description: 'bot 运行日志实时查看', icon: FileText },
|
||||
{ id: 'settings', label: '设置', description: '账号与系统设置', icon: Gear },
|
||||
]
|
||||
const navItems = NAV_ITEMS
|
||||
const path = location.pathname.replace(/\/+$/, '') || '/'
|
||||
const isHome = path === '/hub'
|
||||
const isSettings = path.startsWith('/hub/settings')
|
||||
@@ -26,11 +37,62 @@ export function AppLayout() {
|
||||
const activeId = isPlugins ? 'plugins' : isLogs ? 'logs' : isSettings ? 'settings' : 'home'
|
||||
const pageMeta = navItems.find(it => it.id === activeId) || navItems[0]
|
||||
|
||||
if (!token) return <Navigate to="/hub/login" replace />
|
||||
// 插件子页:从 /hub/<插件id>/... 提取插件 id,并用注册表名字做顶栏面包屑
|
||||
const pluginId = path.split('/')[2] || ''
|
||||
const [webNames, setWebNames] = useState<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
if (webNamesCache) { setWebNames(webNamesCache); return }
|
||||
hubPlugins().then(d => {
|
||||
const m: Record<string, string> = {}
|
||||
for (const p of (d && d.plugins) || []) m[p.id] = p.name
|
||||
webNamesCache = m
|
||||
setWebNames(m)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
const subName = isPluginSub ? (webNames[pluginId] || pluginId) : ''
|
||||
|
||||
const navMain = navItems.filter(it => it.id !== 'settings')
|
||||
const navBottom = navItems.filter(it => it.id === 'settings')
|
||||
const activeMainId = navMain.find(it => it.id === activeId)?.id ?? null
|
||||
|
||||
// 移动端侧栏抽屉:默认收起,点击汉堡按钮展开;切换路由后自动收起
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
useEffect(() => { setSidebarOpen(false) }, [path])
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setSidebarOpen(false) }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
// 滑动指示条:在主导航区中根据高亮项位置平滑移动
|
||||
const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null)
|
||||
const mainNavRef = useRef<HTMLElement>(null)
|
||||
|
||||
const recalcIndicator = () => {
|
||||
const nav = mainNavRef.current
|
||||
if (!nav || !activeMainId) { setIndicator(null); return }
|
||||
const el = nav.querySelector<HTMLElement>('[data-nav-id="' + activeMainId + '"]')
|
||||
if (!el) { setIndicator(null); return }
|
||||
const navRect = nav.getBoundingClientRect()
|
||||
const itemRect = el.getBoundingClientRect()
|
||||
setIndicator({ top: itemRect.top - navRect.top, height: itemRect.height })
|
||||
}
|
||||
|
||||
useLayoutEffect(() => { recalcIndicator() }, [activeMainId, path])
|
||||
useEffect(() => {
|
||||
// 窗口尺寸变化(如移动端调转)时重算指示条位置
|
||||
window.addEventListener('resize', recalcIndicator)
|
||||
return () => window.removeEventListener('resize', recalcIndicator)
|
||||
}, [activeMainId, path])
|
||||
|
||||
if (!token) return <Navigate to="/hub/login" replace state={{ from: location.pathname + location.search }} />
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-slate-50 text-slate-900">
|
||||
<aside className="flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-30 bg-slate-900/40 backdrop-blur-sm md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
<aside className={'fixed inset-y-0 left-0 z-40 flex h-full w-60 shrink-0 flex-col overflow-hidden bg-white border-r border-slate-200 transition-transform duration-300 ease-out md:static md:translate-x-0 ' + (sidebarOpen ? 'translate-x-0' : '-translate-x-full')}>
|
||||
{/* 品牌头 */}
|
||||
<div className="flex h-16 shrink-0 items-center px-4">
|
||||
<div className="grid size-9 place-items-center rounded-xl bg-indigo-100 text-indigo-600"><Gear className="size-5" /></div>
|
||||
@@ -38,29 +100,36 @@ export function AppLayout() {
|
||||
<div className="truncate text-sm font-bold">HeXi 管理系统</div>
|
||||
<div className="truncate text-xs text-slate-400">统一管理台</div>
|
||||
</div>
|
||||
<Button size="sm" variant="tertiary" className="md:hidden" onPress={() => setSidebarOpen(false)} aria-label="关闭菜单">✕</Button>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
|
||||
{navItems.filter(it => it.id !== 'settings').map(it => {
|
||||
{/* 导航(主区,含滑动指示条) */}
|
||||
<nav ref={mainNavRef} className="relative flex-1 min-h-0 space-y-1 overflow-y-auto px-2 py-2">
|
||||
{indicator && (
|
||||
<span
|
||||
className="pointer-events-none absolute left-2 w-0.5 rounded-full bg-indigo-500 transition-all duration-300 ease-out"
|
||||
style={{ top: indicator.top + (indicator.height - 20) / 2, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
{navMain.map(it => {
|
||||
const to = it.id === 'home' ? '/hub' : '/hub/' + it.id
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={to}
|
||||
end={it.id === 'home'}
|
||||
data-nav-id={it.id}
|
||||
className={({ isActive }) => {
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 cursor-pointer'
|
||||
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
|
||||
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
|
||||
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
|
||||
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
|
||||
<span className="w-full truncate leading-tight">{it.label}</span>
|
||||
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
|
||||
<span className={'w-full truncate text-xs font-normal transition-colors duration-200 ' + (isActive ? 'text-indigo-400' : 'text-slate-400')}>{it.description}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -69,23 +138,23 @@ export function AppLayout() {
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部设置 */}
|
||||
{/* 底部设置(单条目时用缩放指示条) */}
|
||||
<nav className="shrink-0 space-y-1 border-t border-slate-100 px-2 py-2">
|
||||
{navItems.filter(it => it.id === 'settings').map(it => {
|
||||
{navBottom.map(it => {
|
||||
const to = '/hub/' + it.id
|
||||
return (
|
||||
<NavLink
|
||||
key={it.id}
|
||||
to={to}
|
||||
className={({ isActive }) => {
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors cursor-pointer'
|
||||
const base = 'group relative flex items-center rounded-lg py-2.5 text-sm font-medium transition-colors duration-200 cursor-pointer'
|
||||
return base + (isActive ? ' bg-indigo-50 text-indigo-700' : ' text-slate-600 hover:bg-slate-100 hover:text-slate-800')
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
{isActive && <span className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-indigo-500" />}
|
||||
<span className="relative z-10 grid w-11 shrink-0 place-items-center"><it.icon className="size-4" /></span>
|
||||
<span className={'absolute left-0 top-1/2 h-5 w-0.5 origin-center -translate-y-1/2 rounded-full bg-indigo-500 transition-all duration-300 ' + (isActive ? 'scale-y-100 opacity-100' : 'scale-y-0 opacity-0')} />
|
||||
<span className={'relative z-10 grid w-11 shrink-0 place-items-center transition-transform duration-200 ' + (isActive ? 'scale-110' : 'group-hover:scale-105')}><it.icon className="size-4" /></span>
|
||||
<span className="relative z-10 flex min-w-0 flex-1 flex-col items-start overflow-hidden pr-3">
|
||||
<span className="w-full truncate leading-tight">{it.label}</span>
|
||||
<span className="w-full truncate text-xs font-normal text-slate-400">{it.description}</span>
|
||||
@@ -99,23 +168,53 @@ export function AppLayout() {
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-slate-200 bg-white px-3 md:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2 md:gap-3">
|
||||
<Button size="sm" variant="tertiary" className="shrink-0 md:hidden" onPress={() => setSidebarOpen(v => !v)} aria-label="打开菜单">
|
||||
<Bars className="size-5" />
|
||||
</Button>
|
||||
{isPluginSub && (
|
||||
<Button size="sm" variant="tertiary" onPress={() => navigate('/hub/plugins')}>← 返回插件管理</Button>
|
||||
<Button size="sm" variant="tertiary" className="max-w-24 shrink-0 truncate" onPress={() => navigate('/hub/plugins')}>← 返回</Button>
|
||||
)}
|
||||
{isPluginSub ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 text-sm text-slate-400">插件管理</span>
|
||||
<span className="shrink-0 text-sm text-slate-300">›</span>
|
||||
<span className="min-w-0 truncate text-sm font-semibold text-slate-800">{subName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pageMeta.icon className="hidden size-4 shrink-0 text-slate-500 sm:block" />
|
||||
<span className="truncate text-sm font-semibold text-slate-800">{pageMeta.label}</span>
|
||||
</>
|
||||
)}
|
||||
<pageMeta.icon className="size-4 text-slate-500" />
|
||||
<span className="text-sm font-semibold text-slate-800">{pageMeta.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex shrink-0 items-center gap-2 md:gap-3">
|
||||
<span
|
||||
title={lastUpdate ? '最后更新 ' + new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false }) : '正在连接后端…'}
|
||||
className={'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors duration-300 ' + (status === '实时' ? 'border-emerald-200 bg-emerald-50 text-emerald-600' : status === '重连中' ? 'border-amber-200 bg-amber-50 text-amber-600' : 'border-slate-200 bg-slate-50 text-slate-400')}
|
||||
>
|
||||
<span className={'size-1.5 rounded-full ' + (status === '实时' ? 'bg-emerald-500' : status === '重连中' ? 'animate-pulse bg-amber-500' : 'bg-slate-300')} />
|
||||
{status}
|
||||
{lastUpdate && <span className="hidden text-[10px] opacity-70 sm:inline">{new Date(lastUpdate).toLocaleTimeString('zh-CN', { hour12: false })}</span>}
|
||||
</span>
|
||||
<Avatar size="sm"><Avatar.Fallback>{(username || 'U').charAt(0).toUpperCase()}</Avatar.Fallback></Avatar>
|
||||
<span className="text-sm text-slate-600">{username || '未登录'}</span>
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<span className="hidden text-sm text-slate-600 sm:inline">{username || '未登录'}</span>
|
||||
<Separator orientation="vertical" className="hidden h-5 sm:block" />
|
||||
<Button size="sm" variant="tertiary" onPress={() => { logout(); navigate('/hub/login') }}>退出</Button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-6"><Outlet /></main>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-4 md:p-6">
|
||||
<div key={path} className="hexi-page-enter">
|
||||
{/* 插件懒加载只替换内容区;子页面异常也不拖垮整个布局 */}
|
||||
<ErrorBoundary compact>
|
||||
<Suspense fallback={<div className="flex justify-center py-10"><Spinner /></div>}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 通用格式化工具(首页/日志页等共用)。 */
|
||||
|
||||
export function fmtBytes(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
export function fmtRate(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '0 B/s'
|
||||
return fmtBytes(n) + '/s'
|
||||
}
|
||||
|
||||
export function fmtUptime(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '—'
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (d) parts.push(d + ' 天')
|
||||
if (h) parts.push(h + ' 小时')
|
||||
parts.push(m + ' 分')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function fmtTime(ts?: string | number): string {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(typeof ts === 'number' ? ts * 1000 : ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { hubLogs, hubLogStream } from './api/client'
|
||||
|
||||
// 保留的最大行数(窗口较大以容纳「加载更早」预置的历史行)
|
||||
const MAX_LINES = 6000
|
||||
|
||||
export interface LogInfo { file: string; size: number; total: number }
|
||||
|
||||
interface LogState {
|
||||
lines: string[]
|
||||
info: LogInfo
|
||||
error: string
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
loadingEarlier: boolean
|
||||
paused: boolean
|
||||
setPaused: (v: boolean) => void
|
||||
refresh: () => Promise<void>
|
||||
loadEarlier: () => Promise<void>
|
||||
}
|
||||
|
||||
const Ctx = createContext<LogState>({
|
||||
lines: [],
|
||||
info: { file: '', size: 0, total: 0 },
|
||||
error: '',
|
||||
loading: false,
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
paused: false,
|
||||
setPaused: () => {},
|
||||
refresh: async () => {},
|
||||
loadEarlier: async () => {},
|
||||
})
|
||||
|
||||
/**
|
||||
* 日志数据与日志流常驻 Provider:首次进入(任意页面)时拉取一次,
|
||||
* SSE 流保持不断,页面之间切换只读缓存数据,不重建连接、不显示加载动画。
|
||||
*/
|
||||
export function LogProvider({ children }: { children: ReactNode }) {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [info, setInfo] = useState<LogInfo>({ file: '', size: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingEarlier, setLoadingEarlier] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
|
||||
const sinceRef = useRef(0)
|
||||
const earliestRef = useRef(0)
|
||||
const pausedRef = useRef(false)
|
||||
const pendingRef = useRef<string[]>([])
|
||||
const disposeRef = useRef<(() => void) | null>(null)
|
||||
const loadingEarlierRef = useRef(false)
|
||||
|
||||
const append = useCallback((line: string) => {
|
||||
setLines(prev => [...prev, line].slice(-MAX_LINES))
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (disposeRef.current) disposeRef.current()
|
||||
disposeRef.current = hubLogStream({
|
||||
since: sinceRef.current,
|
||||
onLine: (line) => {
|
||||
if (pausedRef.current) {
|
||||
pendingRef.current.push(line)
|
||||
// 暂停期间只保留最近若干行,避免恢复时一次性渲染超大数组
|
||||
if (pendingRef.current.length > 500) pendingRef.current.splice(0, pendingRef.current.length - 500)
|
||||
return
|
||||
}
|
||||
append(line)
|
||||
},
|
||||
})
|
||||
}, [append])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await hubLogs(500)
|
||||
if (!d) throw new Error('读取日志失败')
|
||||
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
|
||||
setLines(d.lines || [])
|
||||
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
|
||||
// stream 从文件尾续读(end);「加载更早」从窗口起点向前翻(offset)
|
||||
sinceRef.current = d.end || 0
|
||||
earliestRef.current = d.offset || 0
|
||||
setHasMore((d.offset || 0) > 0)
|
||||
connect()
|
||||
} catch (e: any) {
|
||||
setError(e.message || '读取日志失败')
|
||||
} finally { setLoading(false) }
|
||||
}, [connect])
|
||||
|
||||
const loadEarlier = useCallback(async () => {
|
||||
const before = earliestRef.current
|
||||
if (!before || loadingEarlierRef.current) return
|
||||
loadingEarlierRef.current = true
|
||||
setLoadingEarlier(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await hubLogs(500, before)
|
||||
if (!d || d.ok === false) throw new Error((d && d.msg) || '读取日志失败')
|
||||
setLines(prev => [...(d.lines || []), ...prev].slice(-MAX_LINES))
|
||||
earliestRef.current = d.offset || 0
|
||||
setHasMore((d.offset || 0) > 0)
|
||||
} catch (e: any) {
|
||||
setError(e.message || '读取日志失败')
|
||||
} finally {
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 首次进入就建立并保持;provider 卸载(登出/离开 /hub)时才断开
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
return () => { if (disposeRef.current) disposeRef.current() }
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => { pausedRef.current = paused }, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
// 恢复接收时,把暂停期间暂存的行补进来
|
||||
if (!paused && pendingRef.current.length) {
|
||||
const flush = pendingRef.current
|
||||
pendingRef.current = []
|
||||
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
|
||||
}
|
||||
}, [paused])
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, refresh, loadEarlier }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLogs = () => useContext(Ctx)
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Avatar, Card } from '@heroui/react'
|
||||
import { hubDashboard, hubDashboardStream } from '../../api/client'
|
||||
import { Avatar, Card, Spinner } from '@heroui/react'
|
||||
import { useConnection } from '../../connection'
|
||||
import { fmtBytes, fmtRate, fmtUptime } from '../../lib/format'
|
||||
|
||||
interface BotInfo {
|
||||
self_id: string
|
||||
@@ -87,32 +87,6 @@ interface DashboardData {
|
||||
backend: BackendInfo
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function fmtRate(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '0 B/s'
|
||||
return fmtBytes(n) + '/s'
|
||||
}
|
||||
|
||||
function fmtUptime(sec: number): string {
|
||||
if (!Number.isFinite(sec)) return '—'
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (d) parts.push(d + ' 天')
|
||||
if (h) parts.push(h + ' 小时')
|
||||
parts.push(m + ' 分')
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function Bar({ value, color }: { value: number; color?: string }) {
|
||||
const v = Math.max(0, Math.min(100, value))
|
||||
const c = color || (v >= 80 ? 'bg-rose-500' : v >= 60 ? 'bg-amber-500' : 'bg-indigo-500')
|
||||
@@ -149,37 +123,9 @@ function SectionTitle({ title, desc }: { title: string; desc: string }) {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const refreshingRef = useRef(false)
|
||||
|
||||
const refresh = useCallback(async (silent = false) => {
|
||||
if (refreshingRef.current) return
|
||||
refreshingRef.current = true
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const d = await hubDashboard()
|
||||
if (!d || d.ok === false) throw new Error((d && (d as any).msg) || '采集失败')
|
||||
setData(d)
|
||||
setError('')
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载失败')
|
||||
} finally {
|
||||
refreshingRef.current = false
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh(true) // 初始静默加载,快速出首屏
|
||||
const dispose = hubDashboardStream({
|
||||
onData: (d) => {
|
||||
if (d && d.ok !== false) { setData(d); setError('') }
|
||||
},
|
||||
})
|
||||
return () => { dispose() }
|
||||
}, [refresh])
|
||||
// 数据与连接状态来自全局 ConnectionProvider(顶栏已显示状态徽章)
|
||||
const { data: rawData, error } = useConnection()
|
||||
const data = rawData as DashboardData | null
|
||||
|
||||
const cpu = data?.cpu
|
||||
const mem = data?.memory
|
||||
@@ -190,15 +136,82 @@ export default function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
|
||||
<p className="text-sm text-slate-400">主机与 BOT 综合状态(每 2 秒刷新)</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-800">总览</h2>
|
||||
<p className="text-sm text-slate-400">主机与 BOT 综合状态(实时刷新)</p>
|
||||
</div>
|
||||
|
||||
{error && !data && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
|
||||
|
||||
{/* 顶部:BOT 协议端信息 + BOT 后端信息 一行两列 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-2 items-stretch">
|
||||
{/* BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
|
||||
<Card className="border border-slate-200 bg-white h-full">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{!data ? (
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : !data.bots.length ? (
|
||||
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.bots.map(bot => (
|
||||
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
|
||||
<Avatar size="sm">
|
||||
<Avatar.Image src={bot.avatar} alt={bot.nick} />
|
||||
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
|
||||
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
|
||||
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
|
||||
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
|
||||
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
{/* BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
|
||||
<Card className="border border-slate-200 bg-white h-full">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">NoneBot 版本</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">驱动方式</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
|
||||
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">装载插件数</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">Python</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
|
||||
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
{/* 1. 主机规格信息:系统 / 处理器 / 内存 / 硬盘 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
@@ -234,7 +247,7 @@ export default function HomePage() {
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-medium text-slate-700">硬盘</div>
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-400">加载中…</p>
|
||||
<div className="flex justify-center py-8"><Spinner /></div>
|
||||
) : !data.disk.length ? (
|
||||
<p className="text-sm text-slate-400">未获取到磁盘信息</p>
|
||||
) : (
|
||||
@@ -323,72 +336,6 @@ export default function HomePage() {
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* 3. BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 后端信息" desc="NoneBot 版本 / 驱动方式 / 装载插件数" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">NoneBot 版本</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.nonebot_version || '—'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">驱动方式</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.driver}>{backend?.driver || '—'}</div>
|
||||
<div className="text-xs text-slate-400">{backend?.driver_type || ''}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">装载插件数</div>
|
||||
<div className="mt-1 text-lg font-semibold text-slate-800">{backend?.plugin_count ?? '—'} 个</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs text-slate-400">Python</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-slate-700" title={backend?.python}>{backend?.python || '—'}</div>
|
||||
<div className="text-xs text-slate-400">bot 运行 {sys ? fmtUptime(sys.bot_uptime_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* 4. BOT 协议端信息:登录账号昵称 / 账号 / 适配器类型 */}
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header>
|
||||
<SectionTitle title="BOT 协议端信息" desc="当前登录的账户昵称 / 账号 / 适配器类型" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{!data ? (
|
||||
<p className="text-sm text-slate-400">加载中…</p>
|
||||
) : !data.bots.length ? (
|
||||
<p className="text-sm text-slate-400">暂无在线 QQ 账号</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.bots.map(bot => (
|
||||
<div key={bot.self_id} className="flex items-center gap-3 rounded-xl border border-slate-100 px-4 py-3">
|
||||
<Avatar size="sm">
|
||||
<Avatar.Image src={bot.avatar} alt={bot.nick} />
|
||||
<Avatar.Fallback>{(bot.nick || bot.self_id).charAt(0).toUpperCase()}</Avatar.Fallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-slate-800">{bot.nick}</div>
|
||||
<div className="truncate text-xs text-slate-400">{bot.self_id} · {bot.adapter}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{bot.msg_recv != null && <span className="text-xs text-slate-400">收 {bot.msg_recv}</span>}
|
||||
{bot.msg_sent != null && <span className="text-xs text-slate-400">发 {bot.msg_sent}</span>}
|
||||
{bot.online != null && <span className="text-xs text-slate-400">{bot.online ? '在线' : '离线'}</span>}
|
||||
<span className="inline-flex items-center gap-1.5 text-xs">
|
||||
<span className={'size-2 rounded-full ' + (bot.ws_connected ? 'bg-emerald-500' : 'bg-slate-300')} />
|
||||
<span className={bot.ws_connected ? 'text-emerald-600' : 'text-slate-400'}>{bot.ws_connected ? '已连接' : '未连接'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { Button, Card } from '@heroui/react'
|
||||
import { hubLogs, hubLogStream } from '../../api/client'
|
||||
|
||||
const MAX_LINES = 2000
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let v = n
|
||||
let i = 0
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
|
||||
return v.toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { Button, Card, Spinner } from '@heroui/react'
|
||||
import { useLogs } from '../../logs'
|
||||
import { fmtBytes } from '../../lib/format'
|
||||
|
||||
// ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志
|
||||
const ANSI_FG: Record<number, string> = {
|
||||
@@ -91,68 +81,10 @@ function AnsiLine({ text }: { text: string }) {
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [lines, setLines] = useState<string[]>([])
|
||||
const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
|
||||
// 数据与日志流由全局 LogProvider 常驻管理:离开/回来不重建连接、不转圈
|
||||
const { lines, info, error, loading, hasMore, loadingEarlier, paused, setPaused, loadEarlier } = useLogs()
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sinceRef = useRef(0)
|
||||
const pausedRef = useRef(false)
|
||||
const pendingRef = useRef<string[]>([])
|
||||
const disposeRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const append = useCallback((line: string) => {
|
||||
setLines(prev => [...prev, line].slice(-MAX_LINES))
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (disposeRef.current) disposeRef.current()
|
||||
disposeRef.current = hubLogStream({
|
||||
since: sinceRef.current,
|
||||
onLine: (line) => {
|
||||
if (pausedRef.current) { pendingRef.current.push(line); return }
|
||||
append(line)
|
||||
},
|
||||
onStatus: (s) => setStreamStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
|
||||
})
|
||||
}, [append])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const d = await hubLogs(500)
|
||||
if (!d) throw new Error('读取日志失败')
|
||||
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
|
||||
setLines(d.lines || [])
|
||||
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
|
||||
sinceRef.current = d.offset || 0
|
||||
connect()
|
||||
} catch (e: any) {
|
||||
setError(e.message || '读取日志失败')
|
||||
} finally { setLoading(false) }
|
||||
}, [connect])
|
||||
|
||||
useEffect(() => {
|
||||
pausedRef.current = paused
|
||||
}, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
// 恢复接收时,把暂停期间暂存的行补进来
|
||||
if (!paused && pendingRef.current.length) {
|
||||
const flush = pendingRef.current
|
||||
pendingRef.current = []
|
||||
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
|
||||
}
|
||||
}, [paused])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
return () => { if (disposeRef.current) disposeRef.current() }
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
if (follow && scrollRef.current) {
|
||||
@@ -160,17 +92,18 @@ export default function LogsPage() {
|
||||
}
|
||||
}, [lines, follow])
|
||||
|
||||
const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500'
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
|
||||
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="tertiary" onPress={() => setPaused(v => !v)}>{paused ? '恢复接收' : '暂停接收'}</Button>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 md:justify-end">
|
||||
{hasMore && (
|
||||
<Button size="sm" variant="tertiary" isDisabled={loadingEarlier} onPress={loadEarlier}>{loadingEarlier ? '加载中…' : '加载更早'}</Button>
|
||||
)}
|
||||
<Button size="sm" variant="tertiary" onPress={() => setPaused(!paused)}>{paused ? '恢复接收' : '暂停接收'}</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,15 +114,14 @@ export default function LogsPage() {
|
||||
<Card.Header>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Card.Title>日志流</Card.Title>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span className={statusClass}>● {streamStatus}</span>
|
||||
{info.file && <span>{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
|
||||
</div>
|
||||
{info.file && <span className="text-xs text-slate-400">{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div ref={scrollRef} className="h-[calc(100vh-320px)] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100">
|
||||
{lines.length ? lines.map((ln, i) => (
|
||||
<div ref={scrollRef} className="h-[60vh] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100 md:h-[calc(100vh-320px)]">
|
||||
{loading && !lines.length ? (
|
||||
<div className="flex h-full items-center justify-center"><Spinner /></div>
|
||||
) : lines.length ? lines.map((ln, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
|
||||
)) : (
|
||||
<div className="text-slate-500">暂无日志…</div>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Input,
|
||||
@@ -17,13 +15,15 @@ import {
|
||||
toast,
|
||||
useOverlayState,
|
||||
} from '@heroui/react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeSlash, TrashBin } from '@gravity-ui/icons'
|
||||
import {
|
||||
hubPluginCatalog,
|
||||
hubPluginGetConfig,
|
||||
hubPluginRemoveGroup,
|
||||
hubPluginSaveConfig,
|
||||
hubPluginSetGlobal,
|
||||
hubPluginSetGroup,
|
||||
hubPluginRemoveGroup,
|
||||
hubGroups,
|
||||
} from '../../api/client'
|
||||
|
||||
@@ -50,6 +50,7 @@ interface ConfigField {
|
||||
description?: string
|
||||
options?: { value: string; label: string }[] | string[]
|
||||
secret?: boolean
|
||||
item_type?: string
|
||||
}
|
||||
interface ConfigData { schema: { fields: ConfigField[] }; values: Record<string, any>; revision: number }
|
||||
|
||||
@@ -81,33 +82,37 @@ function normOptions(opts: any): { value: string; label: string }[] {
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [plugins, setPlugins] = useState<CatalogPlugin[]>([])
|
||||
const [groups, setGroups] = useState<GroupItem[]>([])
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const loadRef = useRef(false)
|
||||
// 加载序号:并发 load 时只落地最后一次结果,避免旧响应覆盖新状态
|
||||
const loadSeqRef = useRef(0)
|
||||
|
||||
const [configPlugin, setConfigPlugin] = useState<CatalogPlugin | null>(null)
|
||||
const [configData, setConfigData] = useState<ConfigData | null>(null)
|
||||
const [configLoading, setConfigLoading] = useState(false)
|
||||
const [configSaving, setConfigSaving] = useState(false)
|
||||
const [form, setForm] = useState<Record<string, any>>({})
|
||||
// 密码/密钥类字段的明文显隐状态(按字段 key 记录)
|
||||
const [revealed, setRevealed] = useState<Record<string, boolean>>({})
|
||||
const configState = useOverlayState()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (loadRef.current) return
|
||||
loadRef.current = true
|
||||
setLoading(true)
|
||||
const load = useCallback(async (silent = false) => {
|
||||
const seq = ++loadSeqRef.current
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const [cat, grp] = await Promise.all([hubPluginCatalog(), hubGroups()])
|
||||
if (seq !== loadSeqRef.current) return
|
||||
setPlugins((cat && cat.plugins) || [])
|
||||
setGroups((grp && grp.items) || [])
|
||||
setError('')
|
||||
} catch (e: any) {
|
||||
if (seq !== loadSeqRef.current) return
|
||||
setError(e.message || '加载失败')
|
||||
} finally {
|
||||
loadRef.current = false
|
||||
setLoading(false)
|
||||
if (seq === loadSeqRef.current) setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -117,6 +122,7 @@ export default function PluginsPage() {
|
||||
setConfigPlugin(p)
|
||||
setConfigData(null)
|
||||
setConfigLoading(true)
|
||||
setRevealed({})
|
||||
configState.open()
|
||||
try {
|
||||
const d = await hubPluginGetConfig(p.id)
|
||||
@@ -139,7 +145,7 @@ export default function PluginsPage() {
|
||||
await hubPluginSaveConfig(configPlugin.id, form, configData?.revision ?? 0)
|
||||
toast.success('配置已保存并热刷新')
|
||||
configState.close()
|
||||
await load()
|
||||
await load(true)
|
||||
} catch (e: any) {
|
||||
toast.danger(e.message || '保存失败')
|
||||
} finally {
|
||||
@@ -153,9 +159,9 @@ export default function PluginsPage() {
|
||||
const v = form[field.key]
|
||||
if (field.type === 'bool') {
|
||||
return (
|
||||
<Checkbox key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
|
||||
<Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>{field.label}</Checkbox.Content>
|
||||
</Checkbox>
|
||||
<Switch key={field.key} isSelected={!!v} onChange={(val) => setField(field.key, val)}>
|
||||
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>{field.label}</Switch.Content>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
if (field.type === 'enum') {
|
||||
@@ -173,33 +179,101 @@ export default function PluginsPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.type === 'array') {
|
||||
// 数组字段:表格式行编辑器(每行一个元素,可增删行),回传时按数组存储
|
||||
const arr = Array.isArray(v) ? v : []
|
||||
const isNum = field.item_type === 'int' || field.item_type === 'float'
|
||||
const setItem = (i: number, val: string) => {
|
||||
const next = [...arr]; next[i] = val; setField(field.key, next)
|
||||
}
|
||||
const removeRow = (i: number) => setField(field.key, arr.filter((_, x) => x !== i))
|
||||
const addRow = () => setField(field.key, [...arr, ''])
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<div className="space-y-1.5">
|
||||
{arr.map((item, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<Input className="min-w-0 flex-1" type={isNum ? 'number' : 'text'} value={String(item ?? '')} onChange={e => setItem(i, e.target.value)} />
|
||||
<Button size="sm" variant="tertiary" isIconOnly className="shrink-0 text-slate-400 hover:text-red-500" aria-label="删除该行" onPress={() => removeRow(i)}>
|
||||
<TrashBin className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!arr.length && <p className="text-xs text-slate-400">暂无内容,点击下方添加</p>}
|
||||
<Button size="sm" variant="tertiary" className="w-full border border-dashed border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700" onPress={addRow}>+ 新增一行</Button>
|
||||
</div>
|
||||
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.type === 'text' || field.type === 'json' || field.type === 'object') {
|
||||
const isJson = field.type === 'json'
|
||||
return (
|
||||
<TextField key={field.key}><Label>{field.label}</Label><TextArea rows={isJson ? 14 : 8} className={isJson ? 'min-h-[320px] font-mono text-xs' : 'min-h-[160px]'} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} /></TextField>
|
||||
)
|
||||
}
|
||||
const inputType = field.type === 'password' ? 'password' : (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
|
||||
const isSecret = field.type === 'password'
|
||||
// 后端回传明文值;前端仅以掩码(小圆点)形式默认展示,点「眼睛」可查看
|
||||
const inputType = isSecret
|
||||
? (revealed[field.key] ? 'text' : 'password')
|
||||
: (field.type === 'int' || field.type === 'float' ? 'number' : 'text')
|
||||
return (
|
||||
<TextField key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Input type={inputType} value={v ?? ''} onChange={e => { const val = inputType === 'number' ? e.target.value : e.target.value; setField(field.key, val) }} />
|
||||
{isSecret ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input className="min-w-0 flex-1" type={inputType} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} />
|
||||
<Button size="sm" variant="tertiary" isIconOnly aria-label={revealed[field.key] ? '隐藏内容' : '显示内容'} onPress={() => setRevealed(prev => ({ ...prev, [field.key]: !prev[field.key] }))}>
|
||||
{revealed[field.key] ? <EyeSlash className="size-4" /> : <Eye className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input type={inputType} value={v ?? ''} onChange={e => setField(field.key, e.target.value)} />
|
||||
)}
|
||||
{field.description && <p className="text-xs text-slate-400">{field.description}</p>}
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
// 乐观更新:先翻转本地状态,失败回滚;成功后静默对齐服务端(不闪加载态)
|
||||
const patch = (id: string, fn: (p: CatalogPlugin) => CatalogPlugin) =>
|
||||
setPlugins(prev => prev.map(p => (p.id === id ? fn(p) : p)))
|
||||
|
||||
const toggleGlobal = async (p: CatalogPlugin, enabled: boolean) => {
|
||||
try { await hubPluginSetGlobal(p.id, { enabled }); await load() } catch (e: any) { toast.danger(e.message || '切换失败') }
|
||||
const prev = p.enabled
|
||||
patch(p.id, x => ({ ...x, enabled }))
|
||||
try { await hubPluginSetGlobal(p.id, { enabled }); await load(true) }
|
||||
catch (e: any) { patch(p.id, x => ({ ...x, enabled: prev })); toast.danger(e.message || '切换失败') }
|
||||
}
|
||||
const changeChat = async (p: CatalogPlugin, v: string) => {
|
||||
try { await hubPluginSetGlobal(p.id, { chat: chatFromValue(v) }); await load() } catch (e: any) { toast.danger(e.message || '修改失败') }
|
||||
const prevChat = p.chat
|
||||
patch(p.id, x => ({ ...x, chat: chatFromValue(v) }))
|
||||
try { await hubPluginSetGlobal(p.id, { chat: chatFromValue(v) }); await load(true) }
|
||||
catch (e: any) { patch(p.id, x => ({ ...x, chat: prevChat })); toast.danger(e.message || '修改失败') }
|
||||
}
|
||||
const toggleGroup = async (p: CatalogPlugin, gid: number, enabled: boolean) => {
|
||||
try { await hubPluginSetGroup(p.id, String(gid), { enabled }); await load() } catch (e: any) { toast.danger(e.message || '分群切换失败') }
|
||||
const prevOv = p.groups[String(gid)]
|
||||
patch(p.id, x => ({ ...x, groups: { ...x.groups, [String(gid)]: { enabled, chat: prevOv?.chat ?? [] } } }))
|
||||
try { await hubPluginSetGroup(p.id, String(gid), { enabled }); await load(true) }
|
||||
catch (e: any) {
|
||||
patch(p.id, x => prevOv ? { ...x, groups: { ...x.groups, [String(gid)]: prevOv } }
|
||||
: { ...x, groups: (() => { const g = { ...x.groups }; delete g[String(gid)]; return g })() })
|
||||
toast.danger(e.message || '分群切换失败')
|
||||
}
|
||||
}
|
||||
const resetGroup = async (p: CatalogPlugin, gid: number) => {
|
||||
try { await hubPluginRemoveGroup(p.id, String(gid)); await load() } catch (e: any) { toast.danger(e.message || '重置失败') }
|
||||
const resetAllGroups = async (p: CatalogPlugin) => {
|
||||
const overridden = Object.keys(p.groups)
|
||||
if (!overridden.length) return
|
||||
patch(p.id, x => ({ ...x, groups: {} }))
|
||||
try {
|
||||
await Promise.all(overridden.map(gid => hubPluginRemoveGroup(p.id, gid)))
|
||||
await load(true)
|
||||
} catch (e: any) {
|
||||
// 部分请求可能已成功:直接拉取服务端真实状态对齐,不做盲目回滚
|
||||
toast.danger((e.message || '恢复默认失败') + '(部分群可能未还原)')
|
||||
await load(true).catch(() => {})
|
||||
}
|
||||
}
|
||||
const effectiveGroupEnabled = (p: CatalogPlugin, gid: number) => {
|
||||
const ov = p.groups[String(gid)]
|
||||
@@ -208,22 +282,23 @@ export default function PluginsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-slate-800">插件管理</h2>
|
||||
<p className="text-sm text-slate-400">在所有插件之上统一控制:全局开关、群聊/私聊使用、分群控制、插件配置</p>
|
||||
</div>
|
||||
<Button size="sm" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
|
||||
<Button size="sm" className="shrink-0" isDisabled={loading} onPress={() => load()}>{loading ? '加载中…' : '刷新'}</Button>
|
||||
</div>
|
||||
|
||||
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
|
||||
{loading && !plugins.length && <div className="flex justify-center py-10"><Spinner /></div>}
|
||||
{!loading && !plugins.length && !error && <p className="text-sm text-slate-400">未发现可管理的插件</p>}
|
||||
|
||||
<div className="space-y-3">
|
||||
{plugins.map(p => {
|
||||
const on = p.enabled
|
||||
return (
|
||||
<Card key={p.id} className="border border-slate-200 bg-white">
|
||||
<Card key={p.id} className="border border-slate-200 bg-white transition-colors hover:border-slate-300">
|
||||
<Card.Content>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -239,7 +314,7 @@ export default function PluginsPage() {
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{p.has_config && <Button size="sm" onPress={() => openConfig(p)}>配置</Button>}
|
||||
{p.has_web && p.web_path && <Link to={p.web_path}><Button size="sm">打开 Web 页面</Button></Link>}
|
||||
{p.has_web && p.web_path && <Button size="sm" onPress={() => navigate(p.web_path!)}>打开 Web 页面</Button>}
|
||||
|
||||
<Select selectedKey={chatValue(p.chat)} onSelectionChange={(k) => changeChat(p, String(k))} aria-label="聊天类型">
|
||||
<Select.Trigger className={chatSelectCls}><Select.Value /><Select.Indicator /></Select.Trigger>
|
||||
@@ -253,17 +328,18 @@ export default function PluginsPage() {
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Popover className="p-2" placement="bottom end">
|
||||
<div className="w-64 max-h-72 overflow-y-auto space-y-1">
|
||||
<div className="px-1 py-1 text-xs font-medium text-slate-500">分群控制(覆盖全局)</div>
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-xs font-medium text-slate-500">分群控制(覆盖全局)</span>
|
||||
<Button size="sm" variant="tertiary" className="text-xs text-slate-500" isDisabled={!Object.keys(p.groups).length} onPress={() => resetAllGroups(p)}>恢复全部</Button>
|
||||
</div>
|
||||
{!groups.length && <div className="px-1 py-2 text-xs text-slate-400">未获取到群列表</div>}
|
||||
{groups.map(g => {
|
||||
const eff = effectiveGroupEnabled(p, g.group_id)
|
||||
const hasOv = !!p.groups[String(g.group_id)]
|
||||
return (
|
||||
<div key={g.group_id} className="flex items-center justify-between gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
|
||||
<div key={g.group_id} className="flex items-center gap-2 rounded-lg px-1.5 py-1 hover:bg-slate-50">
|
||||
<Switch size="sm" isSelected={eff} onChange={(v) => toggleGroup(p, g.group_id, v)}>
|
||||
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control><span className="text-xs text-slate-700">{g.group_name || g.group_id}</span></Switch.Content>
|
||||
</Switch>
|
||||
{hasOv && <Button size="sm" variant="tertiary" onPress={() => resetGroup(p, g.group_id)}>重置</Button>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -285,7 +361,7 @@ export default function PluginsPage() {
|
||||
<Drawer state={configState}>
|
||||
<Drawer.Backdrop>
|
||||
<Drawer.Content placement="right">
|
||||
<Drawer.Dialog className="w-[440px] max-w-[88vw]">
|
||||
<Drawer.Dialog className="w-[600px] max-w-[88vw]">
|
||||
<Drawer.Header>
|
||||
<Drawer.Heading>{configPlugin?.name || ''} · 配置</Drawer.Heading>
|
||||
<Drawer.CloseTrigger />
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
|
||||
interface SettingsData {
|
||||
ok: boolean
|
||||
user: { id: number; username: string; created_at: string }
|
||||
plugins: { id: string; name: string; icon: string }[]
|
||||
version: string
|
||||
}
|
||||
interface BotConfigData {
|
||||
@@ -40,6 +38,7 @@ export default function SettingsPage() {
|
||||
const [restarting, setRestarting] = useState<null | 'stop' | 'restart'>(null)
|
||||
const botState = useOverlayState()
|
||||
const pollRef = useRef(0)
|
||||
const pollTimerRef = useRef<number | null>(null)
|
||||
const restartDisposeRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const [botCfg, setBotCfg] = useState<BotConfigData | null>(null)
|
||||
@@ -76,6 +75,7 @@ export default function SettingsPage() {
|
||||
|
||||
useEffect(() => () => {
|
||||
if (restartDisposeRef.current) restartDisposeRef.current()
|
||||
if (pollTimerRef.current != null) window.clearTimeout(pollTimerRef.current)
|
||||
}, [])
|
||||
|
||||
const changePassword = async () => {
|
||||
@@ -119,6 +119,13 @@ export default function SettingsPage() {
|
||||
botState.open()
|
||||
}
|
||||
|
||||
const clearPollTimer = () => {
|
||||
if (pollTimerRef.current != null) {
|
||||
window.clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const pollForReconnect = async () => {
|
||||
const id = ++pollRef.current
|
||||
// 先等旧进程完全退出(旧的 dashboard SSE 可能仍被旧进程服务,提前收到数据会误判就绪)
|
||||
@@ -132,6 +139,8 @@ export default function SettingsPage() {
|
||||
if (d && d.ok !== false) {
|
||||
restartDisposeRef.current = null
|
||||
dispose()
|
||||
clearPollTimer()
|
||||
pollRef.current += 1 // 让旧定时器即使未被清除也不会误判超时
|
||||
setRestarting(null)
|
||||
toast.success('bot 已重启并回连')
|
||||
load()
|
||||
@@ -139,7 +148,8 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
restartDisposeRef.current = dispose
|
||||
window.setTimeout(() => {
|
||||
pollTimerRef.current = window.setTimeout(() => {
|
||||
pollTimerRef.current = null
|
||||
if (pollRef.current === id) {
|
||||
restartDisposeRef.current = null
|
||||
dispose()
|
||||
@@ -169,7 +179,7 @@ export default function SettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-800">设置</h2>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
||||
<Card className="border border-slate-200 bg-white lg:col-span-2">
|
||||
<Card.Header><Card.Title>修改密码</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
<Form className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Button, Card, Input, Label, Select, ListBox, TextField, toast } from '@heroui/react'
|
||||
import { Button, Card, Input, Label, Select, ListBox, Switch, TextField, toast } from '@heroui/react'
|
||||
import { api } from '../../api/client'
|
||||
import { Tabs, thCls, tdCls, selectCls, type TabItem } from '../../components/ui'
|
||||
|
||||
const gc = api('galgame_card')
|
||||
|
||||
@@ -12,32 +13,23 @@ interface Participant { user_id: number; corpus_count: number }
|
||||
interface LogRow { id: number; user_id: number; nickname?: string; content: string; target_user_id?: number | null; created_at: string }
|
||||
interface ImpressionRow { id: number; user_id: number; content: string; cover_from_id: number; cover_to_id: number; created_at: string }
|
||||
interface SummaryRow { version: number; card_text: string; corpus_count: number; impression_count: number; created_at: string }
|
||||
interface SettingsData { web_username: string; web_enable: boolean; thresholds: Record<string, number> }
|
||||
interface SettingsData { thresholds: Record<string, number> }
|
||||
|
||||
function unpack(res: any, errorLabel: string = '操作失败') {
|
||||
if (res && res.status !== 0) throw new Error(res.msg || errorLabel)
|
||||
return res ? res.data : null
|
||||
}
|
||||
|
||||
const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
|
||||
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
|
||||
|
||||
function fmtTime(ts: string) { return ts || '—' }
|
||||
|
||||
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex gap-1 border-b border-slate-200 mb-4">
|
||||
{tabs.map(t => (
|
||||
<button key={t} onClick={() => onChange(t)}
|
||||
className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
type GalTab = 'participants' | 'logs' | 'impressions' | 'summaries'
|
||||
const GAL_TABS: TabItem[] = [
|
||||
{ key: 'participants', label: '参与者' },
|
||||
{ key: 'logs', label: '语料' },
|
||||
{ key: 'impressions', label: '印象' },
|
||||
{ key: 'summaries', label: '画像' },
|
||||
]
|
||||
const DATA_TABS: TabItem[] = GAL_TABS.slice(1)
|
||||
|
||||
export default function GalgameCardPage() {
|
||||
const [view, setView] = useState<'groups' | 'detail' | 'data' | 'settings'>('groups')
|
||||
@@ -65,12 +57,12 @@ export default function GalgameCardPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold text-slate-800">群聊人设卡</h2>
|
||||
<p className="text-sm text-slate-400">群采集开关 · 参与者 · 语料/印象/画像浏览</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 md:justify-end">
|
||||
<div className="flex gap-1">
|
||||
{(['groups', 'data', 'settings'] as const).map(v => (
|
||||
<Button key={v} size="sm" variant={view === v ? 'primary' : 'tertiary'} onPress={() => setView(v)}>
|
||||
@@ -95,13 +87,12 @@ export default function GalgameCardPage() {
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{groups.map(g => (
|
||||
<tr key={g.group_id}>
|
||||
<tr key={g.group_id} className="transition-colors hover:bg-slate-50">
|
||||
<td className={tdCls}><div className="font-medium text-slate-800">{g.group_name || '(未命名群)'}</div><div className="text-xs text-slate-400">{g.group_id}</div></td>
|
||||
<td className={tdCls}>
|
||||
<button onClick={() => toggleGroup(g, !g.enabled)}
|
||||
className={'relative h-5 w-10 rounded-full transition-colors ' + (g.enabled ? 'bg-emerald-500' : 'bg-slate-300')}>
|
||||
<span className={'absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ' + (g.enabled ? 'left-5' : 'left-0.5')} />
|
||||
</button>
|
||||
<Switch size="sm" isSelected={g.enabled} onChange={(v) => toggleGroup(g, v)}>
|
||||
<Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control></Switch.Content>
|
||||
</Switch>
|
||||
</td>
|
||||
<td className={tdCls}>{g.participants}</td><td className={tdCls}>{g.corpus_count}</td><td className={tdCls}>{g.impression_count}</td><td className={tdCls}>{g.summary_count}</td>
|
||||
<td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" onPress={() => openDetail(g)}>管理</Button></td>
|
||||
@@ -124,7 +115,7 @@ export default function GalgameCardPage() {
|
||||
|
||||
|
||||
function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
const [tab, setTab] = useState<'participants' | 'logs' | 'impressions' | 'summaries'>('participants')
|
||||
const [tab, setTab] = useState<GalTab>('participants')
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [logs, setLogs] = useState<LogRow[]>([])
|
||||
const [logTotal, setLogTotal] = useState(0)
|
||||
@@ -191,7 +182,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Content>
|
||||
<Tabs tabs={['参与者', '语料', '印象', '画像']} active={{ participants: '参与者', logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '参与者': 'participants', '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
||||
<Tabs tabs={GAL_TABS} active={tab} onChange={v => setTab(v as GalTab)} />
|
||||
{tab === 'participants' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -203,7 +194,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
<thead><tr><th className={thCls}>QQ</th><th className={thCls}>语料条数</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{participants.map(p => (
|
||||
<tr key={p.user_id}><td className={tdCls}>{p.user_id}</td><td className={tdCls}>{p.corpus_count}</td><td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeParticipant(p.user_id)}>移除</Button></td></tr>
|
||||
<tr key={p.user_id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{p.user_id}</td><td className={tdCls}>{p.corpus_count}</td><td className={tdCls + ' text-right'}><Button size="sm" variant="tertiary" className="text-red-500" onPress={() => removeParticipant(p.user_id)}>移除</Button></td></tr>
|
||||
))}
|
||||
{!participants.length && <tr><td colSpan={3} className={tdCls + ' text-slate-400 text-center'}>暂无参与者</td></tr>}
|
||||
</tbody>
|
||||
@@ -221,7 +212,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.map(r => (
|
||||
<tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
|
||||
<tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
|
||||
))}
|
||||
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无语料</td></tr>}
|
||||
</tbody>
|
||||
@@ -238,7 +229,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
|
||||
<tbody>
|
||||
{impressions.map(r => (
|
||||
<tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
|
||||
<tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>
|
||||
))}
|
||||
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>暂无印象</td></tr>}
|
||||
</tbody>
|
||||
@@ -264,7 +255,7 @@ function GroupDetail({ gid, onBack }: { gid: number; onBack: () => void }) {
|
||||
|
||||
function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
const [gid, setGid] = useState<number | ''>('')
|
||||
const [tab, setTab] = useState<'logs' | 'impressions' | 'summaries'>('logs')
|
||||
const [tab, setTab] = useState<GalTab>('logs')
|
||||
const [logs, setLogs] = useState<LogRow[]>([])
|
||||
const [impressions, setImpressions] = useState<ImpressionRow[]>([])
|
||||
const [summaries, setSummaries] = useState<SummaryRow[]>([])
|
||||
@@ -295,12 +286,12 @@ function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
</Select>
|
||||
{err && <span className="text-xs text-red-500">{err}</span>}
|
||||
</div>
|
||||
<Tabs tabs={['语料', '印象', '画像']} active={{ logs: '语料', impressions: '印象', summaries: '画像' }[tab]} onChange={v => setTab(({ '语料': 'logs', '印象': 'impressions', '画像': 'summaries' } as any)[v])} />
|
||||
<Tabs tabs={DATA_TABS} active={tab} onChange={v => setTab(v as GalTab)} />
|
||||
{tab === 'logs' && (
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>发送者</th><th className={thCls}>发给</th><th className={thCls}>时间</th><th className={thCls}>内容(已脱敏)</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
|
||||
{logs.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.nickname}<span className="text-xs text-slate-400"> {r.user_id}</span></td><td className={tdCls}>{r.target_user_id ? '→ ' + r.target_user_id : '—'}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
|
||||
{!logs.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -309,7 +300,7 @@ function DataBrowser({ groups }: { groups: Group[] }) {
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>用户</th><th className={thCls}>覆盖语料区间</th><th className={thCls}>时间</th><th className={thCls}>印象内容</th></tr></thead>
|
||||
<tbody>
|
||||
{impressions.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
|
||||
{impressions.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls}>#{r.cover_from_id} ~ #{r.cover_to_id}</td><td className={tdCls}>{fmtTime(r.created_at)}</td><td className={tdCls}>{r.content}</td></tr>)}
|
||||
{!impressions.length && <tr><td colSpan={5} className={tdCls + ' text-slate-400 text-center'}>{gid ? '暂无数据' : '请先选择群'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -332,8 +323,6 @@ function SettingsView() {
|
||||
gc.get('settings').then(res => { try { setData(unpack(res)) } catch { } }).catch(() => {})
|
||||
}, [])
|
||||
const rows = data ? [
|
||||
['Web 登录账号', data.web_username],
|
||||
['Web 后台启用', data.web_enable ? '是' : '否'],
|
||||
['长文截断上限', (data.thresholds?.max_content_len ?? '—') + ' 字'],
|
||||
['复读检测窗口', (data.thresholds?.repeat_window ?? '—') + ' 条'],
|
||||
['刷屏采样间隔', (data.thresholds?.throttle_seconds ?? '—') + ' 秒'],
|
||||
@@ -348,7 +337,7 @@ function SettingsView() {
|
||||
<tbody>{rows.map(([k, v]) => <tr key={k}><td className={tdCls + ' w-48 text-slate-400'}>{k}</td><td className={tdCls}>{v}</td></tr>)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;Web 账号密码在 .env 配置 WEB_USERNAME / WEB_PASSWORD。</p>
|
||||
<p className="mt-3 text-xs text-slate-400">阈值与脱敏规则见插件 processor.py / DESIGN.md;后台访问由统一管理台 /hub 提供。</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button, Card, Checkbox, Form, Input, Label, ListBox, Modal, Pagination, Select, Table, TextArea, TextField, toast, useOverlayState } from '@heroui/react'
|
||||
import { Button, Card, Form, Input, Label, ListBox, Modal, Pagination, Select, Switch, Table, TextArea, TextField, toast, useOverlayState } from '@heroui/react'
|
||||
import { Plus, ArrowUpFromLine, ArrowDownToLine, TrashBin, Pencil } from '@gravity-ui/icons'
|
||||
import { api } from '../../api/client'
|
||||
|
||||
@@ -9,11 +9,21 @@ interface Item {
|
||||
}
|
||||
|
||||
const hd = api('helldivers')
|
||||
const TYPE_LABEL: Record<string, string> = { blue: '蓝·补给', red: '红·飞鹰', green: '绿·设备' }
|
||||
const TYPE_LABEL: Record<string, string> = { blue: '武装·补给', red: '轨道·飞鹰', green: '战术·设备' }
|
||||
|
||||
function iconUrl(icon?: string) {
|
||||
if (!icon) return ''
|
||||
return '/api/helldivers/img/' + encodeURI(icon.replace(/^img\//, ''))
|
||||
// 兼容两种存量路径:res/img/...(插件根相对,新)与 img/...(旧库,res/img 相对),
|
||||
// 统一归一化为 res/img 下的相对路径再拼接 API 前缀。
|
||||
let p = String(icon).replace(/\\/g, '/')
|
||||
if (p.startsWith('res/img/')) p = p.slice('res/img/'.length)
|
||||
else if (p.startsWith('res/')) p = p.slice('res/'.length)
|
||||
else if (p.startsWith('img/')) p = p.slice('img/'.length)
|
||||
return '/api/helldivers/img/' + encodeURI(p)
|
||||
}
|
||||
|
||||
function svgDataUri(svg: string) {
|
||||
return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg)
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: string }) {
|
||||
@@ -22,6 +32,22 @@ function TypeChip({ type }: { type: string }) {
|
||||
}
|
||||
|
||||
const selectCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]
|
||||
const TYPE_OPTIONS = [['all', '全部类型'], ...Object.entries(TYPE_LABEL)]
|
||||
const BACKPACK_OPTIONS = [['all', '全部'], ['0', '否'], ['1', '是']]
|
||||
|
||||
// 页码折叠:1 … current±1 … 末页,避免全量渲染
|
||||
function elidePages(current: number, count: number): (number | '...')[] {
|
||||
if (count <= 7) return Array.from({ length: count }, (_, i) => i + 1)
|
||||
const out: (number | '...')[] = [1]
|
||||
const lo = Math.max(2, current - 1)
|
||||
const hi = Math.min(count - 1, current + 1)
|
||||
if (lo > 2) out.push('...')
|
||||
for (let p = lo; p <= hi; p++) out.push(p)
|
||||
if (hi < count - 1) out.push('...')
|
||||
out.push(count)
|
||||
return out
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try { await navigator.clipboard.writeText(text); return true }
|
||||
@@ -31,6 +57,15 @@ async function copyText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function readSvgFile(f: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const r = new FileReader()
|
||||
r.onload = () => resolve(String(r.result))
|
||||
r.onerror = () => reject(r.error)
|
||||
r.readAsText(f, 'utf-8')
|
||||
})
|
||||
}
|
||||
|
||||
function AddModal({ onSaved }: { onSaved: () => void }) {
|
||||
const state = useOverlayState()
|
||||
const [name, setName] = useState('')
|
||||
@@ -38,17 +73,38 @@ function AddModal({ onSaved }: { onSaved: () => void }) {
|
||||
const [type, setType] = useState('blue')
|
||||
const [backpack, setBackpack] = useState(false)
|
||||
const [alias, setAlias] = useState('')
|
||||
const [iconSvg, setIconSvg] = useState('')
|
||||
const [iconName, setIconName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const iconInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const pickIcon = async (e: any) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
if (!/\.svg$/i.test(f.name)) { toast('请上传 SVG 文件', { variant: 'warning' }); return }
|
||||
try {
|
||||
setIconSvg(await readSvgFile(f))
|
||||
setIconName(f.name)
|
||||
} catch (e2: any) { toast('读取图标失败: ' + (e2?.message || e2), { variant: 'danger' }) }
|
||||
}
|
||||
|
||||
const add = async () => {
|
||||
if (!name) { setErr('名称不能为空'); return }
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await hd.post('stratagems', { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean), icon: '' })
|
||||
setName(''); setNameEn(''); setAlias(''); setBackpack(false)
|
||||
const created = await hd.post('stratagems', { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean), icon: '' })
|
||||
if (iconSvg) {
|
||||
await hd.post(`stratagems/${created.item.id}/icon`, { svg: iconSvg })
|
||||
toast('图标上传成功', { variant: 'success' })
|
||||
}
|
||||
toast('新增成功', { variant: 'success' })
|
||||
setName(''); setNameEn(''); setAlias(''); setBackpack(false); setIconSvg(''); setIconName('')
|
||||
state.close(); onSaved()
|
||||
} catch (e: any) { setErr(e.message || '新增失败') } finally { setBusy(false) }
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '新增失败')
|
||||
toast('新增失败: ' + (e?.message || e), { variant: 'danger' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -71,13 +127,28 @@ function AddModal({ onSaved }: { onSaved: () => void }) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2"><TextField><Label>别名(逗号分隔)</Label><Input value={alias} onChange={e => setAlias(e.target.value)} placeholder="逗号分隔" /></TextField></div>
|
||||
<Checkbox isSelected={backpack} onChange={setBackpack}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>背包位</Checkbox.Content></Checkbox>
|
||||
<Switch isSelected={backpack} onChange={setBackpack}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>背包位</Switch.Content></Switch>
|
||||
<div className="sm:col-span-2">
|
||||
<Label>图标(SVG)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
{iconSvg ? (
|
||||
<>
|
||||
<img src={svgDataUri(iconSvg)} className="w-12 h-12 object-contain rounded border border-slate-200" alt="" />
|
||||
<span className="text-sm text-slate-600">{iconName}</span>
|
||||
<Button size="sm" variant="tertiary" onPress={() => { setIconSvg(''); setIconName('') }}>移除</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onPress={() => iconInputRef.current?.click()}>上传图标(SVG)</Button>
|
||||
)}
|
||||
<input ref={iconInputRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={pickIcon} />
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="sm:col-span-2 text-sm text-red-500">{err}</div>}
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={state.close}>取消</Button>
|
||||
<Button isDisabled={busy} onPress={add}>{busy ? '新增中…' : '新增'}</Button>
|
||||
<Button isDisabled={busy} isPending={busy} onPress={add}>{busy ? '保存中…' : '保存'}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog></Modal.Container></Modal.Backdrop>
|
||||
</Modal>
|
||||
@@ -128,23 +199,46 @@ function ImportModal({ onSaved }: { onSaved: () => void }) {
|
||||
}
|
||||
|
||||
|
||||
function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onSaved: () => void }) {
|
||||
function EditModal({ item, onSaved }: { item: Item; onSaved: () => void }) {
|
||||
const state = useOverlayState()
|
||||
const [name, setName] = useState(item.name)
|
||||
const [nameEn, setNameEn] = useState(item.name_en || '')
|
||||
const [type, setType] = useState(item.type || 'blue')
|
||||
const [backpack, setBackpack] = useState(!!item.backpack)
|
||||
const [alias, setAlias] = useState((item.alias || []).join(', '))
|
||||
const [icon, setIcon] = useState(item.icon || item.path || '')
|
||||
const [iconSvg, setIconSvg] = useState('')
|
||||
const [iconName, setIconName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const iconInputRef = useRef<HTMLInputElement>(null)
|
||||
const currentIcon = item.icon || item.path || ''
|
||||
|
||||
const pickIcon = async (e: any) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
if (!/\.svg$/i.test(f.name)) { toast('请上传 SVG 文件', { variant: 'warning' }); return }
|
||||
try {
|
||||
setIconSvg(await readSvgFile(f))
|
||||
setIconName(f.name)
|
||||
} catch (e2: any) { toast('读取图标失败: ' + (e2?.message || e2), { variant: 'danger' }) }
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true); setErr('')
|
||||
try {
|
||||
await hd.put(`stratagems/${item.id}`, { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean), icon })
|
||||
// 若有新图标,先上传(后端会转换并在 DB 里更新 icon)
|
||||
if (iconSvg) {
|
||||
await hd.post(`stratagems/${item.id}/icon`, { svg: iconSvg })
|
||||
toast('图标上传成功', { variant: 'success' })
|
||||
}
|
||||
// 更新其它字段时不要带 icon,避免覆盖刚上传的新图标
|
||||
await hd.put(`stratagems/${item.id}`, { name, name_en: nameEn, type, backpack: backpack ? 1 : 0, alias: alias.split(/[,,、\s]+/).map(s => s.trim()).filter(Boolean) })
|
||||
toast('保存成功', { variant: 'success' })
|
||||
state.close(); onSaved()
|
||||
} catch (e: any) { setErr(e.message || '保存失败') } finally { setBusy(false) }
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || '保存失败')
|
||||
toast('保存失败: ' + (e?.message || e), { variant: 'danger' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -168,15 +262,38 @@ function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onS
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
<Checkbox isSelected={backpack} onChange={setBackpack}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>背包位</Checkbox.Content></Checkbox>
|
||||
<Switch isSelected={backpack} onChange={setBackpack}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>背包位</Switch.Content></Switch>
|
||||
<div className="sm:col-span-2"><TextField><Label>别名(逗号分隔)</Label><Input value={alias} onChange={e => setAlias(e.target.value)} /></TextField></div>
|
||||
<div className="sm:col-span-2"><TextField><Label>图标路径</Label><Input value={icon} onChange={e => setIcon(e.target.value)} placeholder="img/helldivers/xx.png" /></TextField></div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label>图标</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
{iconSvg ? (
|
||||
<>
|
||||
<img src={svgDataUri(iconSvg)} className="w-12 h-12 object-contain rounded border border-slate-200" alt="" />
|
||||
<span className="text-sm text-slate-600">{iconName}(新)</span>
|
||||
<Button size="sm" variant="tertiary" onPress={() => { setIconSvg(''); setIconName('') }}>取消更换</Button>
|
||||
</>
|
||||
) : currentIcon ? (
|
||||
<>
|
||||
<img src={iconUrl(currentIcon)} className="w-12 h-12 object-contain rounded border border-slate-200" alt="" />
|
||||
<span className="text-sm text-slate-600">更换图标</span>
|
||||
<Button size="sm" variant="tertiary" onPress={() => iconInputRef.current?.click()}>更换</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-slate-400">暂无图标</span>
|
||||
<Button size="sm" onPress={() => iconInputRef.current?.click()}>上传图标(SVG)</Button>
|
||||
</>
|
||||
)}
|
||||
<input ref={iconInputRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={pickIcon} />
|
||||
</div>
|
||||
</div>
|
||||
{err && <div className="sm:col-span-2 text-sm text-red-500">{err}</div>}
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="tertiary" onPress={state.close}>取消</Button>
|
||||
<Button isDisabled={busy} onPress={save}>{busy ? '保存中…' : '保存'}</Button>
|
||||
<Button isDisabled={busy} isPending={busy} onPress={save}>{busy ? '保存中…' : '保存'}</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
@@ -188,44 +305,42 @@ function EditModal({ item, framed, onSaved }: { item: Item; framed: boolean; onS
|
||||
|
||||
export function HelldiversPage() {
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [framed, setFramed] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const uploadRef = useRef<HTMLInputElement>(null)
|
||||
const uploadTarget = useRef<number | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [filterBackpack, setFilterBackpack] = useState('all')
|
||||
const [query, setQuery] = useState({ keyword: '', type: '', backpack: '' })
|
||||
const deleteState = useOverlayState()
|
||||
const [deleteTarget, setDeleteTarget] = useState<Item | null>(null)
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const d = await hd.get(`stratagems?page=${page}&page_size=${pageSize}`)
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) })
|
||||
if (query.keyword) params.set('q', query.keyword)
|
||||
if (query.type) params.set('type', query.type)
|
||||
if (query.backpack) params.set('backpack', query.backpack)
|
||||
const d = await hd.get(`stratagems?${params.toString()}`)
|
||||
const data = (d && d.items) || []
|
||||
setItems(data)
|
||||
setTotal((d && d.total) || 0)
|
||||
if (data.length === 0 && page > 1) { setPage(page - 1); return }
|
||||
} catch (e: any) { toast('加载失败: ' + e.message, { variant: 'danger' }) }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
useEffect(() => { reload() }, [page, pageSize])
|
||||
useEffect(() => { reload() }, [page, pageSize, query])
|
||||
|
||||
const applyQuery = () => { setPage(1); setQuery({ keyword: keyword.trim(), type: filterType === 'all' ? '' : filterType, backpack: filterBackpack === 'all' ? '' : filterBackpack }) }
|
||||
const resetQuery = () => { setKeyword(''); setFilterType('all'); setFilterBackpack('all'); setPage(1); setQuery({ keyword: '', type: '', backpack: '' }) }
|
||||
|
||||
const remove = async (it: Item) => {
|
||||
try { await hd.del(`stratagems/${it.id}`); reload() } catch (e: any) { toast('删除失败: ' + e.message, { variant: 'danger' }) }
|
||||
}
|
||||
const confirmDelete = (it: Item) => { setDeleteTarget(it); deleteState.open() }
|
||||
const clickUpload = (it: Item) => { uploadTarget.current = it.id; uploadRef.current?.click() }
|
||||
const onUploadFile = async (e: any) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
if (!/\.svg$/i.test(f.name)) { toast('请上传 SVG 文件', { variant: 'warning' }); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
try { await hd.post(`stratagems/${uploadTarget.current}/icon`, { svg: reader.result, framed }); reload() }
|
||||
catch (e: any) { toast('图标上传失败: ' + e.message, { variant: 'danger' }) }
|
||||
}
|
||||
reader.readAsText(f, 'utf-8')
|
||||
}
|
||||
const exportJson = async () => {
|
||||
try {
|
||||
const data = await hd.get('export')
|
||||
@@ -240,16 +355,58 @@ export function HelldiversPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-800">战备管理</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox isSelected={framed} onChange={setFramed}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>上传 SVG 时叠加 HD2 边框</Checkbox.Content></Checkbox>
|
||||
<AddModal onSaved={reload} />
|
||||
<ImportModal onSaved={reload} />
|
||||
<Button size="sm" onPress={exportJson}><span className="inline-flex items-center gap-1.5"><ArrowDownToLine className="size-4" /> {copied ? '已复制' : '导出'}</span></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Content>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-500">关键词</span>
|
||||
<Input value={keyword} onChange={e => setKeyword(e.target.value)} placeholder="搜索名称/别名" className="w-52" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-500">类型</span>
|
||||
<div className="w-36">
|
||||
<Select selectedKey={filterType} onSelectionChange={(k) => setFilterType(String(k))}>
|
||||
<Select.Trigger><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>{TYPE_OPTIONS.map(([k, v]) => <ListBox.Item key={k} id={k}>{v}</ListBox.Item>)}</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-500">背包</span>
|
||||
<div className="w-28">
|
||||
<Select selectedKey={filterBackpack} onSelectionChange={(k) => setFilterBackpack(String(k))}>
|
||||
<Select.Trigger><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>{BACKPACK_OPTIONS.map(([k, v]) => <ListBox.Item key={k} id={k}>{v}</ListBox.Item>)}</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" isDisabled={loading} isPending={loading} onPress={applyQuery}>{loading ? '查询中…' : '查询'}</Button>
|
||||
<Button size="sm" variant="tertiary" isDisabled={loading} onPress={resetQuery}>重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>战备列表(共 {total} 条)</Card.Title></Card.Header>
|
||||
<Card.Content>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 py-2 text-sm text-slate-500">
|
||||
<span className="inline-block size-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600" />
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
<Table>
|
||||
<Table.ScrollContainer>
|
||||
<Table.Content aria-label="战备列表">
|
||||
@@ -270,8 +427,7 @@ export function HelldiversPage() {
|
||||
<Table.Cell>{it.backpack ? '背包位' : '—'}</Table.Cell>
|
||||
<Table.Cell><div className="flex flex-wrap gap-1 max-w-[220px]">{(it.alias || []).filter(Boolean).map((a, i) => <span key={i} className="text-xs text-slate-500 bg-slate-100 rounded px-1.5 py-0.5">{a}</span>)}</div></Table.Cell>
|
||||
<Table.Cell><div className="flex gap-1 justify-end">
|
||||
<EditModal key={it.id + '-' + it.name} item={it} framed={framed} onSaved={reload} />
|
||||
<Button size="sm" onPress={() => clickUpload(it)}>上传图标</Button>
|
||||
<EditModal key={it.id + '-' + it.name} item={it} onSaved={reload} />
|
||||
<Button size="sm" variant="tertiary" onPress={() => confirmDelete(it)}><span className="inline-flex items-center gap-1"><TrashBin className="size-3.5" /> 删除</span></Button>
|
||||
</div></Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -280,7 +436,7 @@ export function HelldiversPage() {
|
||||
</Table.Content>
|
||||
</Table.ScrollContainer>
|
||||
</Table>
|
||||
<Pagination className="pt-4">
|
||||
<Pagination className="pt-4 !w-auto !justify-start">
|
||||
<Pagination.Summary>共 {total} 条 · 第 {page} / {Math.max(1, Math.ceil(total / pageSize))} 页</Pagination.Summary>
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
@@ -288,9 +444,11 @@ export function HelldiversPage() {
|
||||
<Pagination.PreviousIcon /><span>上一页</span>
|
||||
</Pagination.Previous>
|
||||
</Pagination.Item>
|
||||
{Array.from({ length: Math.max(1, Math.ceil(total / pageSize)) }, (_, i) => (
|
||||
{elidePages(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) => (
|
||||
<Pagination.Item key={i}>
|
||||
<Pagination.Link isActive={page === i + 1} onPress={() => setPage(i + 1)}>{i + 1}</Pagination.Link>
|
||||
{p === '...'
|
||||
? <span className="px-2 text-slate-400">…</span>
|
||||
: <Pagination.Link isActive={page === p} onPress={() => setPage(p)}>{p}</Pagination.Link>}
|
||||
</Pagination.Item>
|
||||
))}
|
||||
<Pagination.Item>
|
||||
@@ -299,12 +457,19 @@ export function HelldiversPage() {
|
||||
</Pagination.Next>
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="text-sm text-slate-600">每页</span>
|
||||
<Select selectedKey={String(pageSize)} onSelectionChange={(k) => { setPage(1); setPageSize(Number(k)) }} className="w-20">
|
||||
<Select.Trigger><Select.Value /></Select.Trigger>
|
||||
<Select.Popover>
|
||||
<ListBox>{PAGE_SIZE_OPTIONS.map(v => <ListBox.Item key={v} id={String(v)}>{v} 条</ListBox.Item>)}</ListBox>
|
||||
</Select.Popover>
|
||||
</Select>
|
||||
</div>
|
||||
</Pagination>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
<input ref={uploadRef} type="file" accept=".svg,image/svg+xml" className="hidden" onChange={onUploadFile} />
|
||||
|
||||
<Modal isOpen={deleteState.isOpen} onOpenChange={deleteState.setOpen}>
|
||||
<Modal.Backdrop><Modal.Container><Modal.Dialog>
|
||||
<Modal.Header><Modal.Heading>删除战备</Modal.Heading></Modal.Header>
|
||||
@@ -319,4 +484,4 @@ export function HelldiversPage() {
|
||||
)
|
||||
}
|
||||
|
||||
export default HelldiversPage
|
||||
export default HelldiversPage
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Card, Checkbox, Input, Label, Select, ListBox, TextArea, TextField, toast } from '@heroui/react'
|
||||
import { Button, Card, Input, Label, Select, ListBox, Switch, TextArea, TextField, toast } from '@heroui/react'
|
||||
import { api } from '../../api/client'
|
||||
import { PageHead, Pager, Tabs, thCls, tdCls, selectCls, type TabItem } from '../../components/ui'
|
||||
import { fmtTime } from '../../lib/format'
|
||||
|
||||
const lc = api('learning_chat')
|
||||
|
||||
@@ -13,38 +14,17 @@ function data(res: any) {
|
||||
return res
|
||||
}
|
||||
|
||||
const selectCls = 'bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const inputCls = 'w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:border-indigo-400 focus:outline-none'
|
||||
const thCls = 'px-3 py-2 text-left text-xs font-medium text-slate-400'
|
||||
const tdCls = 'px-3 py-2 text-sm text-slate-700 border-t border-slate-100'
|
||||
|
||||
function fmtU(ts?: number) {
|
||||
if (!ts) return '—'
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
function PageHead({ title, actions }: { title: string; actions?: ReactNode }) {
|
||||
return <div className="flex items-center justify-between"><h2 className="text-xl font-semibold text-slate-800">{title}</h2>{actions}</div>
|
||||
}
|
||||
|
||||
function Tabs({ tabs, active, onChange }: { tabs: string[]; active: string; onChange: (v: string) => void }) {
|
||||
return <div className="flex gap-1 border-b border-slate-200 mb-4">
|
||||
{tabs.map(t => <button key={t} onClick={() => onChange(t)} className={'px-3 py-2 text-sm border-b-2 -mb-px ' + (active === t ? 'border-indigo-500 text-indigo-700 font-medium' : 'border-transparent text-slate-500 hover:text-slate-700')}>{t}</button>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
function Pager({ page, total, perPage, onPage }: { page: number; total: number; perPage: number; onPage: (p: number) => void }) {
|
||||
const pages = Math.max(1, Math.ceil(total / perPage))
|
||||
return <div className="mt-3 flex items-center gap-2 text-xs text-slate-400">
|
||||
<Button size="sm" variant="tertiary" isDisabled={page <= 1} onPress={() => onPage(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {pages} 页 · 共 {total} 条</span>
|
||||
<Button size="sm" variant="tertiary" isDisabled={page >= pages} onPress={() => onPage(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
}
|
||||
type MainTab = 'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'
|
||||
const MAIN_TABS: TabItem[] = [
|
||||
{ key: 'config', label: '配置' },
|
||||
{ key: 'messages', label: '消息' },
|
||||
{ key: 'contexts', label: '学习内容' },
|
||||
{ key: 'answers', label: '回复' },
|
||||
{ key: 'blacklist', label: '禁用列表' },
|
||||
]
|
||||
|
||||
interface GlobalConfig {
|
||||
total_enable: boolean; enable_web: boolean; web_username: string; web_password: string; web_secret_key: string
|
||||
total_enable: boolean
|
||||
KEYWORDS_SIZE: number; cross_group_threshold: number; learn_max_count: number
|
||||
ban_words: string[]; ban_users: string[]; dictionary: string[]
|
||||
}
|
||||
@@ -57,11 +37,11 @@ interface GroupConfig {
|
||||
interface GroupOption { label: string; value: number }
|
||||
|
||||
export default function LearningChatPage() {
|
||||
const [tab, setTab] = useState<'config' | 'messages' | 'contexts' | 'answers' | 'blacklist'>('config')
|
||||
const [tab, setTab] = useState<MainTab>('config')
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHead title="群聊学习" actions={<span className="text-sm text-slate-400">学习群友发言 · 复读 · 主动发言</span>} />
|
||||
<Tabs tabs={['配置', '消息', '学习内容', '回复', '禁用列表']} active={{ config: '配置', messages: '消息', contexts: '学习内容', answers: '回复', blacklist: '禁用列表' }[tab]} onChange={v => setTab(({ '配置': 'config', '消息': 'messages', '学习内容': 'contexts', '回复': 'answers', '禁用列表': 'blacklist' } as any)[v])} />
|
||||
<Tabs tabs={MAIN_TABS} active={tab} onChange={v => setTab(v as MainTab)} />
|
||||
{tab === 'config' && <ConfigView />}
|
||||
{tab === 'messages' && <MessagesView />}
|
||||
{tab === 'contexts' && <ContextsView />}
|
||||
@@ -127,11 +107,8 @@ function ConfigView() {
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>基本设置</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<Checkbox isSelected={!!globalCfg.total_enable} onChange={v => setG('total_enable', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>群聊学习总开关</Checkbox.Content></Checkbox>
|
||||
<Checkbox isSelected={!!globalCfg.enable_web} onChange={v => setG('enable_web', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>后台管理总开关</Checkbox.Content></Checkbox>
|
||||
<TextField><Label>后台管理用户名</Label><Input value={globalCfg.web_username || ''} onChange={e => setG('web_username', e.target.value)} /></TextField>
|
||||
<TextField><Label>后台管理密码</Label><Input type="password" value={globalCfg.web_password || ''} onChange={e => setG('web_password', e.target.value)} /></TextField>
|
||||
<TextField><Label>后台管理 Token 密钥</Label><Input value={globalCfg.web_secret_key || ''} onChange={e => setG('web_secret_key', e.target.value)} /></TextField>
|
||||
<Switch isSelected={!!globalCfg.total_enable} onChange={v => setG('total_enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>群聊学习总开关</Switch.Content></Switch>
|
||||
<p className="text-xs text-slate-400">后台访问与账号由统一管理台 /hub 提供。</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
@@ -163,7 +140,7 @@ function ConfigView() {
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>基本设置</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<Checkbox isSelected={!!groupCfg.enable} onChange={v => setGrp('enable', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>群聊学习开关</Checkbox.Content></Checkbox>
|
||||
<Switch isSelected={!!groupCfg.enable} onChange={v => setGrp('enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>群聊学习开关</Switch.Content></Switch>
|
||||
<TextField><Label>回复阈值</Label><Input type="number" value={groupCfg.answer_threshold ?? ''} onChange={e => setGrp('answer_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>复读阈值</Label><Input type="number" value={groupCfg.repeat_threshold ?? ''} onChange={e => setGrp('repeat_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>打断复读概率(%)</Label><Input type="number" value={groupCfg.break_probability ?? ''} onChange={e => setGrp('break_probability', Number(e.target.value))} /></TextField>
|
||||
@@ -172,7 +149,7 @@ function ConfigView() {
|
||||
<Card className="border border-slate-200 bg-white">
|
||||
<Card.Header><Card.Title>主动发言设置</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<Checkbox isSelected={!!groupCfg.speak_enable} onChange={v => setGrp('speak_enable', v)}><Checkbox.Content><Checkbox.Control><Checkbox.Indicator /></Checkbox.Control>主动发言开关</Checkbox.Content></Checkbox>
|
||||
<Switch isSelected={!!groupCfg.speak_enable} onChange={v => setGrp('speak_enable', v)}><Switch.Content><Switch.Control><Switch.Thumb /></Switch.Control>主动发言开关</Switch.Content></Switch>
|
||||
<TextField><Label>主动发言阈值</Label><Input type="number" value={groupCfg.speak_threshold ?? ''} onChange={e => setGrp('speak_threshold', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>主动发言最小间隔(秒)</Label><Input type="number" value={groupCfg.speak_min_interval ?? ''} onChange={e => setGrp('speak_min_interval', Number(e.target.value))} /></TextField>
|
||||
<TextField><Label>连续主动发言概率(%)</Label><Input type="number" value={groupCfg.speak_continuously_probability ?? ''} onChange={e => setGrp('speak_continuously_probability', Number(e.target.value))} /></TextField>
|
||||
@@ -217,12 +194,15 @@ interface Msg { id: number; message_id: string | number; group_id: number; user_
|
||||
function MessagesView() {
|
||||
const [rows, setRows] = useState<Msg[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [g, setG] = useState(''); const [u, setU] = useState(''); const [m, setM] = useState(''); const [loading, setLoading] = useState(false)
|
||||
// 输入框只更新草稿态,点「搜索」/回车才提交,避免每敲一键就打一次接口
|
||||
const [query, setQuery] = useState<{ g: string; u: string; m: string }>({ g: '', u: '', m: '' })
|
||||
const perPage = 10
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { const d = await data(await lc.get(`get_chat_messages?page=${page}&perPage=${perPage}&group_id=${g}&user_id=${u}&message=${m}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) }
|
||||
}, [page, g, u, m])
|
||||
try { const d = await data(await lc.get(`get_chat_messages?page=${page}&perPage=${perPage}&group_id=${query.g}&user_id=${query.u}&message=${query.m}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) }
|
||||
}, [page, query])
|
||||
useEffect(() => { load() }, [load])
|
||||
const search = () => { setPage(1); setQuery({ g, u, m }) }
|
||||
const del = async (id: number) => { if (!window.confirm('确定删除?')) return; try { await lc.del(`delete_chat?type=message&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const ban = async (id: number) => { try { await lc.put(`ban_chat?type=message&id=${id}`, {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const delAll = async () => { if (!window.confirm('确定删除所有消息?不可逆!')) return; try { await lc.put('delete_all?type=message', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
@@ -231,16 +211,16 @@ function MessagesView() {
|
||||
<Card.Header><Card.Title>消息记录</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input className="w-36" placeholder="群ID" value={g} onChange={e => setG(e.target.value)} />
|
||||
<Input className="w-36" placeholder="用户ID" value={u} onChange={e => setU(e.target.value)} />
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="消息内容" value={m} onChange={e => setM(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Input className="w-36" placeholder="群ID" value={g} onChange={e => setG(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Input className="w-36" placeholder="用户ID" value={u} onChange={e => setU(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="消息内容" value={m} onChange={e => setM(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Button size="sm" onPress={search}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>消息ID</th><th className={thCls}>群ID</th><th className={thCls}>用户ID</th><th className={thCls}>消息</th><th className={thCls}>时间</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls}>{r.message_id}</td><td className={tdCls}>{r.group_id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.raw_message || r.message || '—'}</td><td className={tdCls}>{fmtU(r.time)}</td><td className={tdCls + ' text-right'}>
|
||||
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.message_id}</td><td className={tdCls}>{r.group_id}</td><td className={tdCls}>{r.user_id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.raw_message || r.message || '—'}</td><td className={tdCls}>{fmtTime(r.time)}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-amber-600" onPress={() => ban(r.id)}>禁用</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
|
||||
</td></tr>)}
|
||||
@@ -258,8 +238,10 @@ interface Ctx { id: number; keywords: string; time: number; count: number }
|
||||
function ContextsView() {
|
||||
const [rows, setRows] = useState<Ctx[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [kw, setKw] = useState(''); const [loading, setLoading] = useState(false); const perPage = 10
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_contexts?page=${page}&perPage=${perPage}&orderBy=time&orderDir=desc&keywords=${kw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, kw])
|
||||
const [qkw, setQkw] = useState('')
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_contexts?page=${page}&perPage=${perPage}&orderBy=time&orderDir=desc&keywords=${qkw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, qkw])
|
||||
useEffect(() => { load() }, [load])
|
||||
const search = () => { setPage(1); setQkw(kw) }
|
||||
const del = async (id: number) => { if (!window.confirm('确定删除该学习内容?')) return; try { await lc.del(`delete_chat?type=context&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const ban = async (id: number) => { try { await lc.put(`ban_chat?type=context&id=${id}`, {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const delAll = async () => { if (!window.confirm('确定删除所有学习内容?不可逆!')) return; try { await lc.put('delete_all?type=context', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
@@ -268,14 +250,14 @@ function ContextsView() {
|
||||
<Card.Header><Card.Title>学习内容</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Button size="sm" onPress={search}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>内容/关键词</th><th className={thCls}>最后学习时间</th><th className={thCls}>已学次数</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{fmtU(r.time)}</td><td className={tdCls}>{r.count}</td><td className={tdCls + ' text-right'}>
|
||||
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{fmtTime(r.time)}</td><td className={tdCls}>{r.count}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-amber-600" onPress={() => ban(r.id)}>禁用</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
|
||||
</td></tr>)}
|
||||
@@ -293,8 +275,10 @@ interface Ans { id: number; group_id: number; keywords: string; time: number; co
|
||||
function AnswersView() {
|
||||
const [rows, setRows] = useState<Ans[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [kw, setKw] = useState(''); const [loading, setLoading] = useState(false); const perPage = 10
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_answers?page=${page}&perPage=${perPage}&orderBy=count&orderDir=desc&keywords=${kw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, kw])
|
||||
const [qkw, setQkw] = useState('')
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_answers?page=${page}&perPage=${perPage}&orderBy=count&orderDir=desc&keywords=${qkw}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, qkw])
|
||||
useEffect(() => { load() }, [load])
|
||||
const search = () => { setPage(1); setQkw(kw) }
|
||||
const del = async (id: number) => { if (!window.confirm('确定删除这条回复?')) return; try { await lc.del(`delete_chat?type=answer&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const ban = async (id: number) => { try { await lc.put(`ban_chat?type=answer&id=${id}`, {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const delAll = async () => { if (!window.confirm('确定删除所有回复?不可逆!')) return; try { await lc.put('delete_all?type=answer', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
@@ -303,14 +287,14 @@ function AnswersView() {
|
||||
<Card.Header><Card.Title>学习到的回复</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Input className="flex-1 min-w-[200px]" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Button size="sm" onPress={search}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={delAll}>删除所有</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>ID</th><th className={thCls}>内容/关键词</th><th className={thCls}>群ID</th><th className={thCls}>次数</th><th className={thCls}>消息预览</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[220px] truncate'}>{r.keywords}</td><td className={tdCls}>{r.group_id}</td><td className={tdCls}>{r.count}</td><td className={tdCls + ' max-w-[260px]'}>{((r.messages || []).map(m => m.msg).join(';') || '—').slice(0, 120)}</td><td className={tdCls + ' text-right'}>
|
||||
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls}>{r.id}</td><td className={tdCls + ' max-w-[220px] truncate'}>{r.keywords}</td><td className={tdCls}>{r.group_id}</td><td className={tdCls}>{r.count}</td><td className={tdCls + ' max-w-[260px]'}>{((r.messages || []).map(m => m.msg).join(';') || '—').slice(0, 120)}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-amber-600" onPress={() => ban(r.id)}>禁用</Button>
|
||||
<Button size="sm" variant="tertiary" className="text-red-500" onPress={() => del(r.id)}>删除</Button>
|
||||
</td></tr>)}
|
||||
@@ -328,8 +312,10 @@ interface Bl { id: number; keywords: string; bans: string; global_ban?: boolean;
|
||||
function BlacklistView() {
|
||||
const [rows, setRows] = useState<Bl[]>([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1)
|
||||
const [kw, setKw] = useState(''); const [bans, setBans] = useState(''); const [loading, setLoading] = useState(false); const perPage = 10
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_blacklist?page=${page}&perPage=${perPage}&keywords=${kw}&bans=${bans}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, kw, bans])
|
||||
const [query, setQuery] = useState({ kw: '', bans: '' })
|
||||
const load = useCallback(async () => { setLoading(true); try { const d = await data(await lc.get(`get_chat_blacklist?page=${page}&perPage=${perPage}&keywords=${query.kw}&bans=${query.bans}`)); setRows((d && d.items) || []); setTotal((d && d.total) || 0) } catch (e: any) { toast.danger(e.message) } finally { setLoading(false) } }, [page, query])
|
||||
useEffect(() => { load() }, [load])
|
||||
const search = () => { setPage(1); setQuery({ kw, bans }) }
|
||||
const unban = async (id: number) => { if (!window.confirm('确定取消禁用?')) return; try { await lc.del(`delete_chat?type=blacklist&id=${id}`); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
const clearAll = async () => { if (!window.confirm('确定取消所有禁用?')) return; try { await lc.put('delete_all?type=blacklist', {}); load() } catch (e: any) { toast.danger(e.message) } }
|
||||
return (
|
||||
@@ -337,15 +323,15 @@ function BlacklistView() {
|
||||
<Card.Header><Card.Title>禁用列表</Card.Title></Card.Header>
|
||||
<Card.Content className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="w-40" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} />
|
||||
<Input className="w-40" placeholder="搜索禁用群" value={bans} onChange={e => setBans(e.target.value)} />
|
||||
<Button size="sm" onPress={() => { setPage(1); load() }}>搜索</Button>
|
||||
<Input className="w-40" placeholder="搜索关键词" value={kw} onChange={e => setKw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Input className="w-40" placeholder="搜索禁用群" value={bans} onChange={e => setBans(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') search() }} />
|
||||
<Button size="sm" onPress={search}>搜索</Button>
|
||||
<Button size="sm" variant="tertiary" onPress={clearAll}>取消所有禁用</Button>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead><tr><th className={thCls}>内容/关键词</th><th className={thCls}>已禁用的群</th><th className={thCls}></th></tr></thead>
|
||||
<tbody>
|
||||
{rows.map(r => <tr key={r.id}><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{r.global_ban ? '全局禁用' : (r.bans || '—')}</td><td className={tdCls + ' text-right'}>
|
||||
{rows.map(r => <tr key={r.id} className="transition-colors hover:bg-slate-50"><td className={tdCls + ' max-w-[320px] truncate'}>{r.keywords}</td><td className={tdCls}>{r.global_ban ? '全局禁用' : (r.bans || '—')}</td><td className={tdCls + ' text-right'}>
|
||||
<Button size="sm" variant="tertiary" className="text-emerald-600" onPress={() => unban(r.id)}>取消禁用</Button>
|
||||
</td></tr>)}
|
||||
{!rows.length && <tr><td colSpan={3} className={tdCls + ' text-center text-slate-400'}>{loading ? '加载中…' : '暂无禁用项目'}</td></tr>}
|
||||
|
||||
+21
-16
@@ -4,6 +4,9 @@ import { Toast } from '@heroui/react'
|
||||
import { AuthProvider } from './auth/auth'
|
||||
import { LoginPage } from './auth/LoginPage'
|
||||
import { AppLayout } from './layout/AppLayout'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { ConnectionProvider } from './connection'
|
||||
import { LogProvider } from './logs'
|
||||
import { pluginModules } from './plugins'
|
||||
import HomePage from './pages/home'
|
||||
import PluginsPage from './pages/plugins'
|
||||
@@ -14,22 +17,24 @@ export function AppRoutes() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Toast.Provider />
|
||||
<Suspense fallback={<div className="p-12 text-slate-400">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/hub" replace />} />
|
||||
<Route path="/hub/login" element={<LoginPage />} />
|
||||
<Route path="/hub" element={<AppLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
{Object.entries(pluginModules).map(([id, Comp]) => (
|
||||
<Route key={id} path={`${id}/*`} element={<Comp />} />
|
||||
))}
|
||||
<Route path="*" element={<div className="text-neutral-400">插件未接入或不存在</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<div className="p-12 text-slate-400">加载中…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/hub" replace />} />
|
||||
<Route path="/hub/login" element={<LoginPage />} />
|
||||
<Route path="/hub" element={<ConnectionProvider><LogProvider><AppLayout /></LogProvider></ConnectionProvider>}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="plugins" element={<PluginsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<LogsPage />} />
|
||||
{Object.entries(pluginModules).map(([id, Comp]) => (
|
||||
<Route key={id} path={`${id}/*`} element={<Comp />} />
|
||||
))}
|
||||
<Route path="*" element={<div className="text-neutral-400">插件未接入或不存在</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/layout/applayout.tsx","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/connection.tsx","./src/logs.tsx","./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/auth/loginpage.tsx","./src/auth/auth.tsx","./src/components/errorboundary.tsx","./src/components/ui.tsx","./src/layout/applayout.tsx","./src/lib/format.ts","./src/pages/home/index.tsx","./src/pages/logs/index.tsx","./src/pages/plugins/index.tsx","./src/pages/settings/index.tsx","./src/plugins/index.ts","./src/plugins/galgame_card/index.tsx","./src/plugins/helldivers/index.tsx","./src/plugins/learning_chat/index.tsx"],"version":"5.9.3"}
|
||||
+75
-2
@@ -9,6 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
@@ -104,11 +105,78 @@ def init_auth_db() -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _env_has_password_key() -> bool:
|
||||
""".env 中是否显式配置了 hexi_web_password(区别于代码默认值 admin)。"""
|
||||
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
if not env_path.exists():
|
||||
return False
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("hexi_web_password=") or line.startswith(
|
||||
"hexi_web_password ="
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _write_env_password(password: str) -> None:
|
||||
"""把 Web 端修改后的密码回写 .env,保持 .env 永远是权威源(重启后不还原)。"""
|
||||
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||
try:
|
||||
lines = (
|
||||
env_path.read_text(encoding="utf-8").splitlines()
|
||||
if env_path.exists()
|
||||
else []
|
||||
)
|
||||
replaced = False
|
||||
out: list[str] = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("hexi_web_password=") or stripped.startswith(
|
||||
"hexi_web_password ="
|
||||
):
|
||||
if not replaced:
|
||||
# JSON 字符串写法可安全转义 # 等特殊字符,dotenv 解析后与裸值一致
|
||||
out.append("hexi_web_password=" + json.dumps(password))
|
||||
replaced = True
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
out.append("hexi_web_password=" + json.dumps(password))
|
||||
env_path.write_text(
|
||||
"\n".join(out).rstrip("\n") + "\n", encoding="utf-8"
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _seed_admin_if_missing(username: str, password: str) -> None:
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM web_users WHERE username=?", (username,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"INSERT INTO web_users(username,password_hash,is_active,created_at) VALUES(?,?,1,?)",
|
||||
(username, _hash_password(password), _now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def sync_admin() -> None:
|
||||
"""启动时调用:以 .env 为准创建/同步超级管理员(含改密码场景)。"""
|
||||
"""启动时调用:以 .env 为准创建/同步超级管理员。
|
||||
|
||||
仅当 .env 显式配置了 hexi_web_password 时才会在每次启动同步密码
|
||||
(此时 .env 是权威源,重启始终以 .env 覆盖);若 .env 未显式配置,
|
||||
则只在用户不存在时播种默认账号,保证 Web 端「修改密码」能持久生效、
|
||||
不被每次启动静默还原。
|
||||
"""
|
||||
init_auth_db()
|
||||
username = _read_env("hexi_web_username", "admin")
|
||||
password = _read_env("hexi_web_password", "admin")
|
||||
if not _env_has_password_key():
|
||||
_seed_admin_if_missing(username, password)
|
||||
return
|
||||
with _conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM web_users WHERE username=?", (username,)
|
||||
@@ -155,7 +223,11 @@ def revoke_user_tokens(user_id: int, except_token: Optional[str] = None) -> None
|
||||
def change_password(
|
||||
user_id: int, old_password: str, new_password: str, keep_token: Optional[str] = None
|
||||
) -> bool:
|
||||
"""校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。"""
|
||||
"""校验旧密码后改新密码,并吊销旧令牌(保留当前令牌可选)。成功返回 True。
|
||||
|
||||
成功后会回写 .env(hexi_web_password),保证重启后仍是新密码:
|
||||
否则 sync_admin 会按 .env 默认值把密码还原。
|
||||
"""
|
||||
if not new_password:
|
||||
return False
|
||||
init_auth_db()
|
||||
@@ -170,6 +242,7 @@ def change_password(
|
||||
(_hash_password(new_password), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
_write_env_password(new_password)
|
||||
revoke_user_tokens(user_id, except_token=keep_token)
|
||||
return True
|
||||
|
||||
|
||||
+33
-7
@@ -11,7 +11,7 @@
|
||||
"default": ..., # 默认值
|
||||
"description": "...", # 提示
|
||||
"env": "WEB_PASSWORD", # 环境变量名(缺省=key.upper())
|
||||
"secret": true, # 是否敏感(前端脱敏/掩码)
|
||||
"secret": true, # 是否敏感(前端默认掩码显示,回传明文)
|
||||
"options": [...], # enum 可选项
|
||||
"required": false,
|
||||
}
|
||||
@@ -28,6 +28,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
@@ -39,7 +40,7 @@ DATA_DIR = _ROOT / "config"
|
||||
STORE_PATH = DATA_DIR / "plugin_config.json"
|
||||
ENV_PATH = _ROOT.parent / ".env" # 仓库根 .env
|
||||
|
||||
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set"}
|
||||
_TYPES = {"string", "text", "password", "int", "float", "bool", "enum", "json", "object", "path", "object_set", "array"}
|
||||
|
||||
# plugin_id -> schema
|
||||
_schemas: dict[str, dict[str, Any]] = {}
|
||||
@@ -209,6 +210,23 @@ def _coerce(field: dict[str, Any], raw: Any) -> Any:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"字段 {field['key']} 必须是数字") from e
|
||||
if typ == "array":
|
||||
item_type = str(field.get("item_type") or "str")
|
||||
if isinstance(raw, str):
|
||||
# 防御:兼容旧前端提交的多行/逗号分隔文本
|
||||
raw = [p.strip() for p in re.split(r"[,\n]+", raw) if p.strip()]
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError(f"字段 {field['key']} 必须是数组")
|
||||
out = []
|
||||
for item in raw:
|
||||
if item_type in {"int", "float"}:
|
||||
try:
|
||||
out.append(int(item) if item_type == "int" else float(item))
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ValueError(f"字段 {field['key']} 含非法数字: {item}") from e
|
||||
else:
|
||||
out.append(str(item))
|
||||
return out
|
||||
if typ in {"string", "text", "password"}:
|
||||
return "" if raw is None else str(raw)
|
||||
if typ == "enum":
|
||||
@@ -240,9 +258,16 @@ def get_config(plugin_id: str) -> Optional[dict[str, Any]]:
|
||||
values[key] = f["default"]
|
||||
else:
|
||||
values[key] = None
|
||||
for field in schema.get("fields", []):
|
||||
if field.get("secret") and values.get(field["key"]) not in (None, ""):
|
||||
values[field["key"]] = MASKED_SECRET
|
||||
# 密钥类字段回传明文:该管理台仅本地局域网/本机使用,无外泄面;
|
||||
# 明文由前端默认以掩码(小圆点)形式展示,用户点「眼睛」才可查看。
|
||||
# array 字段兼容旧存储:text 时期保存的是换行/逗号分隔字符串,归一化为数组
|
||||
for f in schema.get("fields", []):
|
||||
key = f["key"]
|
||||
if f.get("type") == "array" and isinstance(values.get(key), str):
|
||||
try:
|
||||
values[key] = _coerce(f, values[key])
|
||||
except ValueError:
|
||||
values[key] = []
|
||||
return {"schema": schema, "values": values, "revision": _revisions.get(plugin_id, 0)}
|
||||
|
||||
|
||||
@@ -272,9 +297,9 @@ def _update_env(plugin_id: str, values: dict[str, Any]) -> None:
|
||||
key = f["key"]
|
||||
if key not in values:
|
||||
continue
|
||||
# text/json/object/path 这类非标量不适合写进 .env(重启解析会出错),
|
||||
# text/json/object/path/array 这类非标量不适合写进 .env(重启解析会出错),
|
||||
# 这类字段仅在运行期热更新,不落盘 .env(见 config_standard 说明)。
|
||||
if f.get("type") in {"text", "json", "object", "path"}:
|
||||
if f.get("type") in {"text", "json", "object", "path", "array"}:
|
||||
continue
|
||||
env_name = _field_env(f)
|
||||
raw = values[key]
|
||||
@@ -340,6 +365,7 @@ def save_config(
|
||||
if key not in values:
|
||||
continue
|
||||
raw = values[key]
|
||||
# 兼容旧版本前端回传的掩码占位:不覆盖已保存的明文
|
||||
if f.get("secret") and raw == MASKED_SECRET:
|
||||
continue
|
||||
if raw in (None, ""):
|
||||
|
||||
Reference in New Issue
Block a user