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:
1 parent
9371a28e35
commit
c53b210413
45 files changed
+1312
-576
No files matched your search
@@ -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/` 脚手架模板
|
||||
|
||||
## 许可
|
||||
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Whitespace-only changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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