feat: 网页界面与 API 总体布局

This commit is contained in:
2026-05-20 23:58:33 +08:00
parent 2415c1afdb
commit 31996f2532
29 changed files with 2260 additions and 2073 deletions
+15
View File
@@ -31,6 +31,21 @@ def tui():
tui_module.main()
@cli.command()
@click.option("--host", default="127.0.0.1", help="监听地址")
@click.option("--port", default=8821, help="监听端口", type=int)
@click.option("--reload", is_flag=True, help="开发模式热重载")
def serve(host, port, reload):
"""启动 API 服务 (unifront)"""
from heurams.unifront.server import create_app
app = create_app()
click.echo(f"unifront API 服务启动: http://{host}:{port}")
import uvicorn
uvicorn.run(app, host=host, port=port, reload=reload, log_level="info")
def _print_version():
click.echo(
f"HeurAMS {ver} ({codename}/{codename_cn}), 阶段: {stage}"
+6
View File
@@ -0,0 +1,6 @@
"""unifront - HeurAMS API 前端模块"""
from .server import create_app
from .session import Session
__all__ = ["create_app", "Session"]
+92
View File
@@ -0,0 +1,92 @@
"""unifront 依赖注入"""
from __future__ import annotations
from pathlib import Path
import heurams.kernel.particles as pt
from heurams.context import config_var, workdir
from heurams.kernel.repolib.repo import Repo
from heurams.services.logger import get_logger
from .schemas import RepoInfo
logger = get_logger(__name__)
_repo_cache: dict[str, Repo] = {}
def get_data_repo_dir() -> Path:
"""获取仓库目录"""
return workdir / "data" / "repo"
def compute_repo_progress(repo: Repo) -> dict:
"""计算 repo 的学习进度"""
progress = {
"total": repo.data_length,
"touched": 0,
}
for i in range(repo.data_length):
try:
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict[i],
algo_name=repo.config["algorithm"],
)
if e.is_activated():
progress["touched"] += 1
except Exception:
pass
return progress
def get_repo(package: str) -> Repo | None:
"""按包名获取 Repo"""
if package in _repo_cache:
return _repo_cache[package]
repo_dir = get_data_repo_dir() / package
if not repo_dir.exists():
return None
try:
repo = Repo.from_repodir(repo_dir)
_repo_cache[package] = repo
return repo
except Exception as e:
logger.error("加载仓库 %s 失败: %s", package, e)
return None
def get_config():
"""获取配置对象"""
return config_var.get()
def list_repos() -> list[RepoInfo]:
"""列举可用仓库"""
from .schemas import RepoInfo
repo_dir = get_data_repo_dir()
valid_repos = Repo.probe_valid_repos_in_dir(repo_dir)
result = []
for rp in valid_repos:
if rp.name in _repo_cache:
repo = _repo_cache[rp.name]
else:
repo = Repo.from_repodir(rp)
_repo_cache[rp.name] = repo
progress = compute_repo_progress(repo)
result.append(
RepoInfo(
package=repo.manifest.get("package", rp.name),
title=repo.manifest.get("title", rp.name),
author=repo.manifest.get("author", ""),
desc=repo.manifest.get("desc", ""),
source=str(rp),
total=progress["total"],
touched=progress["touched"],
)
)
return result
+9
View File
@@ -0,0 +1,9 @@
"""unifront 路由"""
from .repos import router as repos_router
from .config import router as config_router
from .atoms import router as atoms_router
from .review import router as review_router
from .misc import router as misc_router
__all__ = ["repos_router", "config_router", "atoms_router", "review_router", "misc_router"]
+192
View File
@@ -0,0 +1,192 @@
"""原子信息 REST 路由"""
from fastapi import APIRouter, HTTPException
import heurams.kernel.particles as pt
import heurams.services.timer as timer
from heurams.context import config_var
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from ..dependencies import get_repo, compute_repo_progress
logger = get_logger(__name__)
router = APIRouter(prefix="/api/repos/{package}", tags=["atoms"])
def _safe_lastdate(e) -> int:
try:
return e.lastdate()
except (KeyError, TypeError):
return 0
@router.get("/atoms")
def list_atoms(package: str, page: int = 1, page_size: int = 50) -> dict:
"""获取仓库的原子列表"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
idents = list(repo.ident_index)
total = len(idents)
start = (page - 1) * page_size
end = start + page_size
page_idents = idents[start:end]
items = []
for ident in page_idents:
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(ident),
algo_name=repo.config["algorithm"],
)
items.append({
"ident": ident,
"activated": bool(e.is_activated()),
"due": e.is_due(),
"rept": e.rept(real_rept=True),
"interval": e["interval"],
"next_date": e.nextdate(),
"last_date": _safe_lastdate(e),
})
return {"total": total, "page": page, "page_size": page_size, "items": items}
@router.get("/atoms/{ident}")
def get_atom(package: str, ident: str) -> dict:
"""获取单个原子的完整信息"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
if ident not in repo.ident_index:
raise HTTPException(status_code=404, detail=f"原子 '{ident}' 未找到")
n = pt.Nucleon.from_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(ident)
)
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(ident),
algo_name=repo.config["algorithm"],
)
nucleon_data = {}
for key in n:
if key in ("puzzles",):
continue
try:
nucleon_data[key] = n[key]
except Exception:
pass
return {
"ident": ident,
"electron": {
"activated": bool(e.is_activated()),
"due": e.is_due(),
"rept": e.rept(real_rept=True),
"interval": e["interval"],
"next_date": e.nextdate(),
"last_date": _safe_lastdate(e),
"last_modify": e.last_modify(),
"algodata": e.algodata.get(e.algoname, {}),
},
"nucleon": nucleon_data,
}
@router.get("/prepare")
def prepare_repo(package: str) -> dict:
"""获取仓库复习准备数据(repo 信息 + 所有原子状态预览)"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
progress = compute_repo_progress(repo)
today = timer.get_daystamp()
atoms = []
review_count = 0
new_count = 0
for i in repo.ident_index:
e = pt.Electron.from_data(
electronic_data=repo.electronic_data_lict.get_itemic_unit(i),
algo_name=repo.config["algorithm"],
)
activated = bool(e.is_activated())
due = e.is_due()
if activated and due:
status = "R"
review_count += 1
elif activated:
status = "A"
else:
status = "U"
new_count += 1
n = pt.Nucleon.from_data(
nucleonic_data=repo.nucleonic_data_lict.get_itemic_unit(i)
)
content = n.get("content", "") or n.get("tts_text", "") or i
atoms.append({
"ident": i,
"status": status,
"rept": e.rept(real_rept=True),
"interval": e["interval"],
"next_date": e.nextdate(),
"content": str(content)[:60],
})
schedule_num = repo.config["scheduled_num"]
return {
"repo": {
"package": repo.manifest.get("package", package),
"title": repo.manifest.get("title", package),
"author": repo.manifest.get("author", ""),
"desc": repo.manifest.get("desc", ""),
"source": str(repo.source),
"algorithm": repo.config["algorithm"],
"scheduled_num": schedule_num,
},
"progress": progress,
"preview": {"review": review_count, "new": new_count},
"today": today,
"atoms": atoms,
"total_atoms": len(atoms),
}
@router.get("/atoms/{ident}/favorite")
def get_favorite(package: str, ident: str) -> dict:
"""检查原子是否已收藏"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404)
rel_path = _rel_repo_path(repo)
return {"favorited": favorite_manager.has(rel_path, ident)}
@router.post("/atoms/{ident}/favorite")
def toggle_favorite(package: str, ident: str) -> dict:
"""切换收藏状态"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404)
rel_path = _rel_repo_path(repo)
if favorite_manager.has(rel_path, ident):
favorite_manager.remove(rel_path, ident)
return {"favorited": False, "action": "removed"}
else:
favorite_manager.add(rel_path, ident)
return {"favorited": True, "action": "added"}
def _rel_repo_path(repo) -> str:
"""获取仓库相对路径"""
from heurams.context import workdir
data_repo = workdir / "data" / "repo"
try:
return str(repo.source.relative_to(data_repo))
except (ValueError, AttributeError):
return str(repo.source)
+99
View File
@@ -0,0 +1,99 @@
"""配置查询与修改 REST 路由"""
from fastapi import APIRouter, HTTPException
from heurams.services.config import ConfigDict
from heurams.services.epath import epath
from ..dependencies import get_config
router = APIRouter(prefix="/api/config", tags=["config"])
@router.get("")
def get_config_tree():
"""获取完整配置树"""
config = get_config()
return _build_tree(config)
def _build_tree(node, prefix: str = "") -> list[dict]:
"""通过 ConfigDict 的 __getitem__ 展开配置树"""
result = []
for key in node:
if key.startswith("_"):
continue
full_key = f"{prefix}.{key}" if prefix else key
child = node[key]
if isinstance(child, ConfigDict):
children = _build_tree(child, full_key)
result.append({"key": full_key, "type": "branch", "children": children})
# 收集_meta数据
for mk, mv in node.items():
if mk == f"_{key}_desc" and isinstance(mv, str):
if children:
children[0]["_meta_desc"] = mv
else:
item = {"key": full_key, "type": type(child).__name__, "value": child}
cand_key = f"_{key}_candidate"
desc_key = f"_{key}_desc"
if desc_key in node:
item["desc"] = node[desc_key]
if cand_key in node:
item["candidates"] = node[cand_key]
result.append(item)
return result
@router.get("/{section:path}")
def get_config_section(section: str):
"""获取指定配置节的详细数据(含元数据)"""
config = get_config()
keys = section.split("/")
current = config
for key in keys:
if key not in current:
return {}
current = current[key]
result = {}
for k, v in current.items():
if k.startswith("_"):
continue
item = {"value": v, "type": type(v).__name__}
cand_key = f"_{k}_candidate"
desc_key = f"_{k}_desc"
if cand_key in current:
item["candidates"] = current[cand_key]
if desc_key in current:
item["desc"] = current[desc_key]
result[k] = item
return result
@router.put("/{section:path}")
def update_config(section: str, body: dict):
"""更新单个配置项
body: {"value": ..., "path": "..."}
path 是可选的完整路径,默认为 section
"""
config = get_config()
# 将 / 分隔的路径转为 . 分隔的 epath
path = body.get("path", section.replace("/", "."))
new_value = body.get("value")
if new_value is None:
raise HTTPException(status_code=400, detail="缺少 value")
# 尝试类型转换:保持原类型
old_val = epath(config, path)
if old_val is not None and type(old_val) != type(new_value):
try:
new_value = type(old_val)(new_value)
except (ValueError, TypeError):
pass
epath(config, path, enable_modify=True, new_value=new_value)
# 自动持久化
config.persist()
return {"ok": True, "path": path, "value": new_value}
+196
View File
@@ -0,0 +1,196 @@
"""收藏夹、系统信息、缓存管理与分析统计 API"""
import platform
import shutil
import sys
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from heurams.context import config_var
from heurams.services.attic import Attic
from heurams.services.favorite_service import favorite_manager
from heurams.services.logger import get_logger
from heurams.services.version import ver, stage, codename, codename_cn
logger = get_logger(__name__)
router = APIRouter(prefix="/api", tags=["misc"])
def _get_cache_dir() -> Path:
paths = config_var.get()["global"]["paths"]
cache = Path(paths.get("cache", str(Path(paths["data"]) / "cache"))) / "voice"
cache.mkdir(parents=True, exist_ok=True)
return cache
def _human_size(b: int) -> str:
for unit in ["B", "KB", "MB", "GB", "TB"]:
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} PB"
@router.get("/cache")
def cache_info() -> dict:
"""缓存统计信息"""
cache_dir = _get_cache_dir()
total_size = 0
file_count = 0
if cache_dir.exists():
for f in cache_dir.rglob("*"):
if f.is_file():
total_size += f.stat().st_size
file_count += 1
return {
"path": str(cache_dir),
"file_count": file_count,
"total_size": total_size,
"human_size": _human_size(total_size),
"exists": cache_dir.exists(),
}
@router.delete("/cache")
def clear_cache() -> dict:
"""清空语音缓存"""
cache_dir = _get_cache_dir()
count = 0
if cache_dir.exists():
for f in cache_dir.rglob("*"):
if f.is_file():
f.unlink()
count += 1
# 移除空目录
for d in sorted(cache_dir.rglob("*"), key=lambda p: str(p), reverse=True):
if d.is_dir():
try:
d.rmdir()
except OSError:
pass
logger.info("清空缓存: 删除 %d 个文件", count)
return {"removed": count}
@router.get("/favorites")
def list_favorites() -> list[dict]:
"""获取所有收藏"""
items = []
for fav in favorite_manager.get_all():
items.append({
"repo_path": fav.repo_path,
"ident": fav.ident,
"added": fav.added,
"tags": fav.tags or [],
})
return items
@router.delete("/favorites/{repo_path}/{ident}")
def remove_favorite(repo_path: str, ident: str) -> dict:
"""移除收藏"""
if favorite_manager.remove(repo_path, ident):
return {"removed": True}
raise HTTPException(status_code=404, detail="收藏未找到")
@router.get("/analysis")
def analysis_stats() -> dict:
"""复习统计信息"""
a = Attic("ana", {"totaltime": 0, "openpuzzles": 0, "puzzles_err": 0})
total = a.data.get("openpuzzles", 0)
errs = a.data.get("puzzles_err", 0)
t = a.data.get("totaltime", 0)
rate = round(100 * (1 - errs / total), 2) if total else None
speed = round(total / t, 2) if t else None
return {
"total_puzzles": total,
"errors": errs,
"total_time": t,
"accuracy_pct": rate,
"speed_pps": speed,
}
@router.post("/cache/precache")
def trigger_precache() -> dict:
"""触发全量 TTS 缓存生成(后台异步)"""
import threading
from heurams.kernel.repolib import Repo
import heurams.kernel.particles as pt
from heurams.services.tts_service import convertor
from heurams.services.hasher import get_md5
paths_cfg = config_var.get()["global"]["paths"]
cache_dir = Path(paths_cfg.get("cache", str(Path(paths_cfg["data"]) / "cache"))) / "voice"
cache_dir.mkdir(parents=True, exist_ok=True)
repo_dir = Path(paths_cfg["data"]) / "repo"
def _run():
repos = Repo.probe_valid_repos_in_dir(repo_dir)
count = 0
for rp in repos:
try:
repo = Repo.from_repodir(rp)
for ident in repo.ident_index:
n = pt.Nucleon.from_data(repo.nucleonic_data_lict.get_itemic_unit(ident))
text = n.get("tts_text", "")
if not text:
continue
cache_file = cache_dir / f"{get_md5(text)}.wav"
if not cache_file.exists():
try:
convertor(text, cache_file)
count += 1
except Exception:
pass
except Exception:
continue
logger.info("预缓存完成: 生成 %d 个文件", count)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
return {"started": True, "message": "缓存生成已启动"}
@router.get("/tts")
def generate_tts(text: str):
"""生成 TTS 音频并返回 wav 文件"""
from heurams.services.hasher import get_md5
from heurams.services.tts_service import convertor
paths_cfg = config_var.get()["global"]["paths"]
cache_dir = Path(paths_cfg.get("cache", str(Path(paths_cfg["data"]) / "cache"))) / "voice"
cache_dir.mkdir(parents=True, exist_ok=True)
cache_file = cache_dir / f"{get_md5(text)}.wav"
if not cache_file.exists():
try:
convertor(text, cache_file)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return FileResponse(str(cache_file), media_type="audio/wav", filename="tts.wav")
@router.get("/about")
def about_info() -> dict:
"""系统信息"""
disk = shutil.disk_usage("/")
is_venv = hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
return {
"version": ver,
"stage": stage,
"codename": codename,
"codename_cn": codename_cn,
"python_version": platform.python_version(),
"python_path": sys.executable,
"os": f"{platform.system()} {platform.release()}",
"platform": platform.platform(),
"disk_free_gb": round(disk.free / (1024**3), 1),
"disk_total_gb": round(disk.total / (1024**3), 1),
"disk_free_pct": round(disk.free / disk.total * 100, 1),
"in_virtualenv": is_venv,
}
+36
View File
@@ -0,0 +1,36 @@
"""仓库信息 REST 路由"""
from fastapi import APIRouter, HTTPException
from heurams.services.logger import get_logger
from ..dependencies import compute_repo_progress, get_repo, list_repos
from ..schemas import RepoInfo
logger = get_logger(__name__)
router = APIRouter(prefix="/api/repos", tags=["repos"])
@router.get("")
def list_all_repos() -> list[RepoInfo]:
"""获取所有可用仓库"""
return list_repos()
@router.get("/{package}")
def get_repo_info(package: str) -> RepoInfo:
"""获取单个仓库详细信息"""
repo = get_repo(package)
if repo is None:
raise HTTPException(status_code=404, detail=f"仓库 '{package}' 未找到")
progress = compute_repo_progress(repo)
return RepoInfo(
package=repo.manifest.get("package", package),
title=repo.manifest.get("title", package),
author=repo.manifest.get("author", ""),
desc=repo.manifest.get("desc", ""),
source=str(repo.source),
total=progress["total"],
touched=progress["touched"],
)
+123
View File
@@ -0,0 +1,123 @@
"""复习 WebSocket 路由"""
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from heurams.services.logger import get_logger
from ..dependencies import get_repo
from ..session import Session, SessionError
logger = get_logger(__name__)
router = APIRouter()
# 活跃会话存储 {repo_name: Session}
_active_sessions: dict[str, Session] = {}
@router.websocket("/api/review/{package}")
async def review_websocket(ws: WebSocket, package: str):
"""复习会话 WebSocket 端点
消息协议 (JSON):
客户端 -> 服务端:
{"action": "start", "scheduled_num": 10}
{"action": "rate", "rating": 4}
服务端 -> 客户端:
{"type": "puzzle", "data": {...}}
{"type": "progress", "data": {"phase": "...", "current": 1, "total": 10}}
{"type": "finished", "data": {"repo_name": "...", "total_atoms": 10}}
{"type": "error", "message": "..."}
"""
await ws.accept()
logger.debug("WebSocket 连接: package=%s", package)
session: Session | None = None
try:
while True:
raw = await ws.receive_text()
msg = json.loads(raw)
action = msg.get("action")
if action == "start":
if session is not None:
await ws.send_json({"type": "error", "message": "会话已开始"})
continue
repo = get_repo(package)
if repo is None:
await ws.send_json({
"type": "error",
"message": f"仓库 '{package}' 未找到",
})
continue
try:
session = Session(repo)
scheduled_num = msg.get("scheduled_num", -1)
session.start(scheduled_num)
_active_sessions[session.id] = session
except SessionError as e:
await ws.send_json({"type": "error", "message": str(e)})
continue
# 发送初始进度和第一个谜题
await ws.send_json({
"type": "progress",
"data": session.progress,
})
puzzle = session.get_current_puzzle()
if puzzle:
await ws.send_json({"type": "puzzle", "data": puzzle})
else:
await ws.send_json({
"type": "finished",
"data": {"repo_name": package, "total_atoms": 0},
})
elif action == "rate":
if session is None or session.finished:
await ws.send_json({
"type": "error",
"message": "没有活跃的复习会话",
})
continue
rating = msg.get("rating", 3)
continuing = session.rate(rating)
if not continuing:
# 复习完成
await ws.send_json({
"type": "finished",
"data": {
"repo_name": package,
"total_atoms": session.repo.data_length,
},
})
else:
await ws.send_json({
"type": "progress",
"data": session.progress,
})
puzzle = session.get_current_puzzle()
if puzzle:
await ws.send_json({"type": "puzzle", "data": puzzle})
else:
await ws.send_json({
"type": "error",
"message": f"未知动作: {action}",
})
except WebSocketDisconnect:
logger.debug("WebSocket 断开: package=%s", package)
except Exception as e:
logger.error("WebSocket 错误: %s", e)
finally:
if session:
session.cleanup()
_active_sessions.pop(session.id, None)
+67
View File
@@ -0,0 +1,67 @@
"""unifront Pydantic 模型"""
from pydantic import BaseModel
class RepoInfo(BaseModel):
"""仓库信息"""
package: str
title: str
author: str
desc: str
source: str
total: int
touched: int
class AtomInfo(BaseModel):
"""原子信息"""
ident: str
activated: bool
due: bool
rept: int
interval: int
next_date: int
last_date: int
class ConfigTree(BaseModel):
"""配置树节点"""
key: str
type: str
value: object = None
children: list["ConfigTree"] | None = None
class PuzzlePayload(BaseModel):
"""谜题数据载荷"""
category: str
alia: str
atom_ident: str
puzzle: dict
class ReviewProgress(BaseModel):
"""复习进度"""
phase: str
current: int
total: int
class WSMessage(BaseModel):
"""WebSocket 消息基类"""
type: str
data: dict | None = None
message: str | None = None
class RateAction(BaseModel):
"""评分动作"""
rating: int # 0-5
+71
View File
@@ -0,0 +1,71 @@
"""unifront FastAPI 应用工厂"""
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from heurams.services.logger import get_logger
from heurams.services.version import ver
from heurams.context import rootdir
from .routes import repos_router, config_router, atoms_router, review_router, misc_router
logger = get_logger(__name__)
def create_app() -> FastAPI:
"""创建并配置 FastAPI 应用"""
app = FastAPI(
title="HeurAMS API",
version=ver,
description="HeurAMS 启发式辅助记忆调度器 - unifront API",
)
# CORS — 允许本地开发前端
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件 (Web 前端)
static_dir = rootdir / "unifront" / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 注册路由
app.include_router(repos_router)
app.include_router(config_router)
app.include_router(atoms_router)
app.include_router(review_router)
app.include_router(misc_router)
@app.get("/api")
def api_root():
return {
"service": "HeurAMS",
"version": ver,
"endpoints": {
"repos": "/api/repos",
"config": "/api/config",
"atoms": "/api/repos/{package}/atoms",
"review_ws": "/api/review/{package}",
},
}
@app.get("/api/health")
def health():
return {"status": "ok", "version": ver}
@app.get("/")
def index():
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/index.html")
logger.info("unifront API 应用已创建")
return app
+240 -2
View File
@@ -1,5 +1,243 @@
"""会话模块"""
"""复习会话管理"""
import uuid
import inspect
import heurams.kernel.particles as pt
import heurams.kernel.reactor as rt
import heurams.kernel.puzzles as puz
from heurams.context import config_var
from heurams.kernel.repolib.repo import Repo
from heurams.services.logger import get_logger
logger = get_logger(__name__)
# 已知谜题类型及其构造参数白名单
_PUZZLE_PARAM_MAP = {
"MCQPuzzle": {"mapping", "jammer", "max_riddles_num", "prefix"},
"ClozePuzzle": {"text", "min_denominator", "delimiter"},
"RecognitionPuzzle": set(),
}
class SessionError(Exception):
"""会话错误"""
class Session:
pass
"""管理单次复习会话的生命周期"""
def __init__(self, repo: Repo):
self.id = str(uuid.uuid4())[:8]
self.repo = repo
self.router: rt.Router | None = None
self.procession: rt.Procession | None = None
self.expander: rt.Expander | None = None
self.finished = False
def start(self, scheduled_num: int = -1):
"""开始复习,创建 Router and 处理第一个阶段"""
if scheduled_num == -1:
scheduled_num = config_var.get()["interface"]["global"]["scheduled_num"]
atoms = self._build_atoms()
atoms_to_provide = self._filter_atoms(atoms, scheduled_num)
if not atoms_to_provide:
raise SessionError("没有待复习的原子")
self.router = rt.Router(atoms_to_provide)
self._advance()
logger.debug("会话 %s 启动,原子数 %d", self.id, len(atoms_to_provide))
def _build_atoms(self) -> list[pt.Atom]:
"""从 repo 构建所有原子"""
atoms = []
for i in self.repo.ident_index:
n = pt.Nucleon.from_data(
nucleonic_data=self.repo.nucleonic_data_lict.get_itemic_unit(i)
)
e = pt.Electron.from_data(
electronic_data=self.repo.electronic_data_lict.get_itemic_unit(i),
algo_name=self.repo.config["algorithm"],
)
a = pt.Atom(n, e, self.repo.orbitic_data)
atoms.append(a)
return atoms
def _filter_atoms(self, atoms: list[pt.Atom], scheduled_num: int) -> list[pt.Atom]:
"""筛选出待复习和新记忆的原子"""
result = []
left_new = scheduled_num
for atom in atoms:
if atom.registry["electron"].is_activated():
if atom.registry["electron"].is_due():
result.append(atom)
else:
left_new -= 1
if left_new >= 0:
result.append(atom)
return result
@property
def progress(self) -> dict:
"""当前复习进度"""
if not self.procession:
return {"phase": "unknown", "current": 0, "total": 0}
return {
"phase": self.procession.route.value,
"current": self.procession.process() + 1,
"total": self.procession.total_length(),
}
def _advance(self) -> bool:
"""推进到下一个阶段/原子。返回 False 表示全部完成"""
if not self.router:
return False
self.procession = self.router.current_procession()
if self.procession.route == rt.RouterState.FINISHED:
# 全部完成
self.finished = True
self._persist()
return False
self.expander = self.procession.get_expander()
return True
def get_current_puzzle(self) -> dict | None:
"""获取当前谜题的可序列化数据"""
if self.finished or not self.expander or not self.procession:
return None
puzzle_inf = self.expander.get_current_puzzle_inf()
puzzle_class = puzzle_inf["puzzle"]
alia = puzzle_inf["alia"]
atom = self.procession.current_atom
if self.expander.state == "retronly":
return {
"category": "recognition",
"alia": alia,
"atom_ident": atom.ident,
"phase": self.procession.route.value,
"puzzle": {
"content": atom.registry["nucleon"].get("content", ""),
"tts_text": atom.registry["nucleon"].get("tts_text", ""),
},
}
# 通过 atom 配置构造并刷新谜题
puzzle_cfg = atom.registry["nucleon"]["puzzles"].get(alia, {})
try:
# 过滤出 puzzle 构造器接受的参数
sig = inspect.signature(puzzle_class.__init__)
valid_params = set(sig.parameters.keys()) - {"self"}
filtered_cfg = {}
for k, v in puzzle_cfg.items():
if k not in valid_params:
continue
# TOML/Evalizer 可能返回字符串,尝试类型转换
if isinstance(v, str):
vs = v.strip()
try:
v = int(vs) if vs.isdigit() or (vs.startswith('-') and vs[1:].isdigit()) else float(vs)
except (ValueError, TypeError):
v = vs
filtered_cfg[k] = v
puz_instance = puzzle_class(**filtered_cfg) if filtered_cfg else puzzle_class()
puz_instance.refresh()
except Exception as e:
logger.warning("谜题生成失败 %s: %s", alia, e)
return {
"category": "unknown",
"alia": alia,
"atom_ident": atom.ident,
"phase": self.procession.route.value,
"puzzle": {"error": str(e)},
}
alias = puzzle_class.__name__.lower().replace("puzzle", "")
data = {
"category": alias,
"alia": alia,
"atom_ident": atom.ident,
"phase": self.procession.route.value,
"puzzle": self._serialize_puzzle(puz_instance),
}
return data
def _serialize_puzzle(self, puz) -> dict:
"""将谜题对象序列化为字典"""
data = {}
if hasattr(puz, "wording"):
data["wording"] = puz.wording
if hasattr(puz, "answer"):
data["answer"] = puz.answer
if hasattr(puz, "options"):
data["options"] = puz.options
if hasattr(puz, "prefix"):
data["primary"] = getattr(puz, "prefix", "")
if hasattr(puz, "primary"):
# 对于 MCQ,从 atom puzzle config 获取 primary
pass
# 补充 primary/提示字段
atom = self.procession.current_atom if self.procession else None
if atom:
alia = self.expander.get_current_puzzle_inf()["alia"] if self.expander else ""
cfg = atom.registry["nucleon"]["puzzles"].get(alia, {})
if "primary" in cfg:
data["primary"] = cfg["primary"]
return data
def rate(self, rating: int) -> bool:
"""评分当前谜题并推进,返回 False 表示所有流程完成"""
if self.finished or not self.expander or not self.procession:
return False
self.expander.report(rating)
# 决定是否向前推进(SM-2 尺度:>=4 表示正确)
if rating >= 4:
self.expander.forward()
# 如果是 retronly 阶段,处理原子完成
if self.expander.state == "retronly":
quality = self.expander.get_quality()
atom = self.procession.current_atom
# 报告评分给原子
if not atom.registry["electron"].is_activated():
atom.registry["electron"].activate()
atom.lock(1)
atom.minimize(5)
else:
atom.minimize(quality)
# 若质量差则放回队列
if quality <= 3 and atom:
self.procession.append()
# 前进到下一个原子
self.procession.forward(1)
self._advance()
# 检查当前阶段的 Procession 是否已完成
if self.procession and self.procession.state == rt.ProcessionState.FINISHED.value:
self._advance()
return not self.finished
def _persist(self):
"""保存 algodata 到文件"""
try:
self.repo.persist_to_repodir()
logger.debug("会话 %s: algodata 已持久化", self.id)
except Exception as e:
logger.warning("持久化失败: %s", e)
def cleanup(self):
"""清理会话"""
self.router = None
self.procession = None
self.expander = None
logger.debug("会话 %s 已清理", self.id)
+301
View File
@@ -0,0 +1,301 @@
/* HeurAMS unifront */
const $ = (s, c) => (c || document).querySelector(s);
const $$ = (s, c) => Array.from((c || document).querySelectorAll(s));
const show = e => e.classList.remove('hidden');
const hide = e => e.classList.add('hidden');
const esc = s => { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
const API = '/api';
const get = async p => { var r = await fetch(API + p); if (!r.ok) throw Error(r.status); return r.json(); };
const post = async (p, d) => { var r = await fetch(API + p, { method:'POST', headers:{'Content-Type':'application/json'}, body:d?JSON.stringify(d):null }); if (!r.ok) throw Error(r.status); return r.json(); };
const del = async p => { var r = await fetch(API + p, { method:'DELETE' }); if (!r.ok) throw Error(r.status); return r.json(); };
const put = async (p, d) => { var r = await fetch(API + p, { method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(d) }); if (!r.ok) throw Error(r.status); return r.json(); };
var App = { pkg:'', prep:null, ws:null, fav:false, prevAtom:'' };
/* === 主题 === */
function theme() { return localStorage.getItem('t') || 'light'; }
function setTheme(t) { document.body.setAttribute('data-theme', t); localStorage.setItem('t', t); $('#theme-btn').textContent = t === 'dark' ? '\u2600' : '\u263E'; }
function toggleTheme() { setTheme(theme() === 'dark' ? 'light' : 'dark'); }
/* === 布局 === */
function toggleSidebar() { $('#sidebar').classList.toggle('open'); }
function switchView(name) {
var titles = { lobby:'仪表盘', prep:'仓库详情', review:'复习中', favs:'收藏夹', cache:'缓存管理', settings:'设置', about:'关于' };
$('#nav-title').textContent = titles[name] || name;
$$('.view').forEach(function(el) { if (el.id === 'view-' + name) show(el); else hide(el); });
$$('.sidenav li').forEach(function(li) { li.classList.toggle('active', li.id === 'nav-' + name); });
$('#sidebar').classList.remove('open');
if (name === 'favs') loadFavs();
if (name === 'cache') loadCache();
if (name === 'settings') loadSettings();
if (name === 'about') loadAbout();
}
function goBack() { if (App.ws) { App.ws.close(); App.ws = null; } switchView('lobby'); loadRepos(); }
/* === 启动 === */
document.addEventListener('DOMContentLoaded', async function() {
setTheme(theme());
try { var i = await get(''); $('#nav-version').textContent = 'v' + i.version; } catch(e) {}
switchView('lobby'); loadRepos();
});
/* === 仪表盘 + 分析统计 === */
async function loadRepos() {
show($('#loading-lobby')); hide($('#empty-lobby'));
$('#repo-list').innerHTML = ''; $('#lobby-analysis').textContent = '';
try {
var repos = await get('/repos');
var tot = 0, toc = 0;
repos.forEach(function(r) { tot += r.total; toc += r.touched; });
$('#lobby-stats').innerHTML =
'<div class="stat-card"><div class="n">' + repos.length + '</div><div class="l">单元集</div></div>' +
'<div class="stat-card"><div class="n">' + tot + '</div><div class="l">总单元</div></div>' +
'<div class="stat-card"><div class="n">' + toc + '</div><div class="l">已学习</div></div>';
// 分析统计
try {
var ana = await get('/analysis');
var parts = [];
if (ana.total_puzzles > 0) {
parts.push('处理 ' + ana.total_puzzles + ' 个谜题');
if (ana.accuracy_pct !== null) parts.push('正确率 ' + ana.accuracy_pct + '%');
if (ana.speed_pps !== null) parts.push('速度 ' + ana.speed_pps + ' 个/秒');
} else { parts.push('暂无复习数据'); }
$('#lobby-analysis').textContent = parts.join(' | ');
} catch(e) {}
if (!repos.length) { hide($('#loading-lobby')); show($('#empty-lobby')); return; }
repos.forEach(function(r) {
var p = r.total > 0 ? Math.round(r.touched / r.total * 100) : 0;
var c = document.createElement('div');
c.className = 'repo-card';
c.innerHTML =
'<div style="cursor:pointer" onclick="openPrep(\'' + r.package + '\')">' +
'<div class="title">' + esc(r.title) + '</div>' +
'<div class="meta">' + esc(r.author) + ' &middot; ' + esc(r.package) + '</div>' +
(r.desc ? '<div class="meta">' + esc(r.desc) + '</div>' : '') +
'<div class="bar-wrap" style="margin:4px 0"><div class="bar-fill" style="width:' + p + '%"></div></div>' +
'<div style="font-size:11px;color:var(--dim)">' + r.touched + '/' + r.total + ' (' + p + '%)</div></div>' +
'<button class="btn sm pri" style="margin-top:6px" onclick="event.stopPropagation();quickStart(\'' + r.package + '\')">直接复习</button>';
$('#repo-list').appendChild(c);
});
hide($('#loading-lobby'));
} catch(e) { hide($('#loading-lobby')); $('#repo-list').innerHTML = '<div class="err-msg">加载失败</div>'; }
}
function quickStart(pkg) { openReview(pkg, 10); }
/* === 仓库详情 === */
async function openPrep(pkg) {
App.pkg = pkg; switchView('prep');
show($('#loading-prep')); hide($('#prep-content')); $('#prep-list').innerHTML = '';
try {
var d = await get('/repos/' + pkg + '/prepare');
App.prep = d;
var r = d.repo, p = d.progress, v = d.preview;
var pct = p.total > 0 ? Math.round(p.touched / p.total * 100) : 0;
$('#prep-title').textContent = r.title;
$('#prep-meta').innerHTML = esc(r.author) + ' &middot; ' + esc(r.package) + '<br>算法: ' + esc(r.algorithm) + ' &middot; 路径: ' + esc(r.source || '');
$('#prep-summary').innerHTML =
'<div class="stat-card"><div class="n">' + p.total + '</div><div class="l">总单元</div></div>' +
'<div class="stat-card"><div class="n">' + p.touched + '</div><div class="l">已学习</div></div>' +
'<div class="stat-card"><div class="n">' + v.review + '</div><div class="l">待复习</div></div>' +
'<div class="stat-card"><div class="n">' + v.new + '</div><div class="l">新识记</div></div>';
$('#prep-bar').style.width = pct + '%';
$('#prep-pct').textContent = '学习完成度: ' + p.touched + '/' + p.total + ' (' + pct + '%)';
$('#prep-num').value = r.scheduled_num || 10;
d.atoms.forEach(function(a) {
var el = document.createElement('div');
el.className = 'a-item';
el.innerHTML = '<span class="chip chip-' + a.status + '">' + a.status + '</span><span class="id">' + esc(a.content || a.ident) + '</span><span class="r">' + (a.rept || 0) + '</span>';
$('#prep-list').appendChild(el);
});
hide($('#loading-prep')); show($('#prep-content'));
} catch(e) { hide($('#loading-prep')); $('#prep-content').innerHTML = '<div class="err-msg">加载失败</div>'; }
}
function startReviewFromPrep() { openReview(App.pkg, parseInt($('#prep-num').value) || 10); }
/* === 复习 === */
function openReview(pkg, num) {
App.pkg = pkg; App.prevAtom = '';
switchView('review');
show($('#screen-start')); hide($('#screen-puzzle')); hide($('#screen-finished'));
$('#error-box').classList.remove('show');
$('#review-bar').style.width = '0%'; $('#review-pos').textContent = '';
$('#review-phase').textContent = ''; setStatus('断开');
App.fav = false; $('#fav-btn').textContent = '\u2606'; $('#fav-btn').classList.remove('faved');
$('#tts-btn').style.display = 'none';
$('#review-repo').textContent = (App.prep && App.prep.repo) ? App.prep.repo.title : pkg;
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
connectWS(proto + '//' + location.host + '/api/review/' + pkg, num);
}
function connectWS(url, num) {
if (App.ws) App.ws.close();
App.ws = new WebSocket(url);
App.ws.onopen = function() {
setStatus('已连接');
App.ws.send(JSON.stringify({ action:'start', scheduled_num:num }));
hide($('#screen-start')); show($('#screen-puzzle')); buildR();
};
App.ws.onclose = function() { setStatus('断开'); };
App.ws.onerror = function() { setStatus('错误'); };
App.ws.onmessage = function(ev) {
try { var m = JSON.parse(ev.data); switch(m.type) {
case 'progress': onProgress(m.data); break;
case 'puzzle': onPuzzle(m.data); break;
case 'finished': onFinished(m.data); break;
case 'error': showError(m.message); break;
}} catch(e) {}
};
}
function setStatus(l) { var el = $('#review-status'); el.textContent = l; el.className = 'tag'; if (l === '已连接') el.className = 'tag pri'; }
function showError(m) { var b = $('#error-box'); b.textContent = m; b.classList.add('show'); setTimeout(function(){ b.classList.remove('show'); }, 5000); }
function onProgress(d) {
var ls = { unsure:'准备', quick_review:'快速复习', recognition:'新记忆', final_review:'总复习', finished:'完成' };
if (d.phase) $('#review-phase').textContent = ls[d.phase] || d.phase;
if (d.total > 0) { $('#review-bar').style.width = Math.round(d.current / d.total * 100) + '%'; $('#review-pos').textContent = d.current + '/' + d.total; }
}
function buildR() {
var g = $('#rating-group'); g.innerHTML = '';
for (var i = 0; i <= 5; i++) { var b = document.createElement('button'); b.className = 'rt-btn'; b.textContent = i; b.onclick = function(){ rate(parseInt(this.textContent)); }; g.appendChild(b); }
}
function rate(r) { $$('.rt-btn').forEach(function(b){b.disabled=true;}); if (App.ws && App.ws.readyState === WebSocket.OPEN) App.ws.send(JSON.stringify({ action:'rate', rating:r })); }
function quickPass() { rate(5); }
function quickFail() { rate(2); }
/* TTS 朗读 */
function playTTS() {
var el = $('#puzzle-card .pz-ident');
var text = el ? el.textContent : '';
if (!text) return;
var audio = new Audio(API + '/tts?text=' + encodeURIComponent(text));
audio.play().catch(function() {});
}
/* 收藏 */
function toggleFav() {
var id = ''; var el = $('#puzzle-card .pz-ident'); if (el) id = el.textContent;
if (!App.pkg || !id) return;
post('/repos/' + App.pkg + '/atoms/' + encodeURIComponent(id) + '/favorite').then(function(r) {
App.fav = r.favorited; var b = $('#fav-btn'); b.textContent = r.favorited ? '\u2605' : '\u2606';
if (r.favorited) b.classList.add('faved'); else b.classList.remove('faved');
}).catch(function(){});
}
function checkFav(id) {
if (!App.pkg || !id) return;
get('/repos/' + App.pkg + '/atoms/' + encodeURIComponent(id) + '/favorite').then(function(r) {
App.fav = r.favorited; var b = $('#fav-btn'); b.textContent = r.favorited ? '\u2605' : '\u2606';
if (r.favorited) b.classList.add('faved'); else b.classList.remove('faved');
}).catch(function(){});
}
function onPuzzle(d) {
$$('.rt-btn').forEach(function(b){b.disabled=false;});
var card = $('#puzzle-card'); card.innerHTML = '';
if (d.phase) { var e = document.createElement('div'); e.className = 'pz-phase'; e.textContent = d.phase; card.appendChild(e); }
// 上一个原子(客户端追踪)
if (App.prevAtom) {
var e = document.createElement('div'); e.className = 'dim sm'; e.style.marginBottom = '6px';
e.textContent = '上一个: ' + esc(App.prevAtom); card.appendChild(e);
}
if (d.atom_ident) {
var e = document.createElement('div'); e.className = 'pz-ident'; e.textContent = d.atom_ident;
card.appendChild(e); checkFav(d.atom_ident);
$('#tts-btn').style.display = 'inline';
App.prevAtom = d.atom_ident;
}
var pz = d.puzzle || {};
if (pz.primary) { var e = document.createElement('div'); e.className = 'pz-primary'; e.textContent = pz.primary; card.appendChild(e); }
var w = wd(pz); if (w) { var e = document.createElement('div'); e.className = 'pz-wording'; e.textContent = w; card.appendChild(e); }
if (pz.options && Array.isArray(pz.options)) {
var od = document.createElement('div'); od.id = 'pz-options'; var ans = Array.isArray(pz.answer) ? pz.answer : [pz.answer];
pz.options.forEach(function(opts, qi) {
if (!Array.isArray(opts)) return; var c = ans[qi] || ans[0];
opts.forEach(function(opt) {
var b = document.createElement('button'); b.className = 'pz-opt'; b.textContent = opt;
b.onclick = function() {
$$('.pz-opt', od).forEach(function(x){x.classList.remove('correct','wrong');});
if (opt === c) { b.classList.add('correct'); } else { b.classList.add('wrong'); $$('.pz-opt', od).forEach(function(x){if(x.textContent===c)x.classList.add('correct');}); }
var a = card.querySelector('.pz-answer'); if (a) a.classList.add('show');
};
od.appendChild(b);
});
});
card.appendChild(od);
}
var at = aw(pz); if (at) { var e = document.createElement('div'); e.className = 'pz-answer'; e.innerHTML = '<strong>正确答案: </strong>' + esc(at); card.appendChild(e); if (!pz.options || !pz.options.length) e.classList.add('show'); }
}
function wd(pz) { if (!pz.wording) return ''; return Array.isArray(pz.wording) ? pz.wording.join('\n') : String(pz.wording); }
function aw(pz) { if (!pz.answer) return ''; return Array.isArray(pz.answer) ? pz.answer.join(' | ') : String(pz.answer); }
function onFinished(d) { hide($('#screen-puzzle')); show($('#screen-finished')); $('#finish-info').textContent = '共复习 ' + (d.total_atoms || 0) + ' 个原子'; $('#finish-saved').textContent = '算法数据已保存'; if (App.ws) { App.ws.close(); App.ws = null; } }
/* === 收藏夹 === */
async function loadFavs() {
show($('#loading-favs')); hide($('#empty-favs')); $('#favs-list').innerHTML = '';
try {
var items = await get('/favorites');
if (!items.length) { hide($('#loading-favs')); show($('#empty-favs')); return; }
items.forEach(function(f) {
var d = new Date(f.added * 1000);
var ts = d.getFullYear() + '-' + String(d.getMonth()+1).padStart(2,'0') + '-' + String(d.getDate()).padStart(2,'0') + ' ' + String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0');
var c = document.createElement('div'); c.className = 'fav-item';
c.innerHTML = '<div class="fav-body"><div class="fav-ident">' + esc(f.ident) + '</div><div class="fav-meta">' + esc(f.repo_path) + ' &middot; ' + ts + '</div></div><button class="btn sm err" onclick="removeFav(\'' + esc(f.repo_path) + '\',\'' + esc(f.ident) + '\',this)">移除</button>';
$('#favs-list').appendChild(c);
});
hide($('#loading-favs'));
} catch(e) { hide($('#loading-favs')); $('#favs-list').innerHTML = '<div class="err-msg">加载失败</div>'; }
}
async function removeFav(rp, id, btn) { btn.disabled = true; try { await del('/favorites/' + encodeURIComponent(rp) + '/' + encodeURIComponent(id)); btn.parentElement.remove(); if (!$('#favs-list').children.length) show($('#empty-favs')); } catch(e) { showError('移除失败'); btn.disabled = false; } }
/* === 缓存管理 === */
async function loadCache() { show($('#loading-cache')); hide($('#cache-content')); $('#cache-msg').textContent = ''; try { var d = await get('/cache'); $('#cache-path').textContent = '路径: ' + esc(d.path); $('#cache-stats-cards').innerHTML = '<div class="stat-card"><div class="n">' + d.file_count + '</div><div class="l">缓存文件</div></div><div class="stat-card"><div class="n">' + d.human_size + '</div><div class="l">总大小</div></div>'; hide($('#loading-cache')); show($('#cache-content')); } catch(e) { hide($('#loading-cache')); $('#cache-content').innerHTML = '<div class="err-msg">加载失败</div>'; } }
async function clearCache() { if (!confirm('确定清空所有语音缓存?')) return; var btn = $('#cache-clear-btn'); btn.disabled = true; btn.textContent = '清空中...'; try { var r = await del('/cache'); $('#cache-msg').textContent = '已删除 ' + r.removed + ' 个缓存文件'; loadCache(); } catch(e) { $('#cache-msg').textContent = '清空失败'; } btn.disabled = false; btn.textContent = '清空缓存'; }
async function startPrecache() { var btn = $('#cache-precache-btn'); btn.disabled = true; btn.textContent = '生成中...'; try { var r = await post('/cache/precache'); $('#cache-msg').textContent = r.message; } catch(e) { $('#cache-msg').textContent = '启动失败'; } btn.disabled = false; btn.textContent = '生成缓存'; }
/* === 设置 === */
async function loadSettings() { show($('#loading-settings')); hide($('#settings-content')); var root = $('#settings-content'); root.innerHTML = ''; try { var tree = await get('/config'); renderSettingsTree(tree, root, 0); hide($('#loading-settings')); show($('#settings-content')); } catch(e) { hide($('#loading-settings')); root.innerHTML = '<div class="err-msg">加载失败</div>'; } }
function renderSettingsTree(nodes, parent, depth) {
nodes.forEach(function(node) {
if (node.type === 'branch') {
var sec = document.createElement('div'); sec.className = 'settings-section';
var display = depth < 2 ? 'block' : 'none';
sec.innerHTML = '<div class="settings-section-title" onclick="toggleSec(this)">' + esc(node.key) + ' <span class="arrow">' + (display === 'block' ? '\u25BC' : '\u25B6') + '</span></div><div class="settings-section-body" style="display:' + display + '"></div>';
parent.appendChild(sec); renderSettingsTree(node.children || [], sec.querySelector('.settings-section-body'), depth + 1);
} else {
var row = document.createElement('div'); row.className = 'settings-item'; var path = node.key; var val = node.value; var typ = node.type;
var label = '<div class="settings-label">' + esc(lastKey(node.key)) + '</div>';
if (node.desc) label += '<div class="settings-desc">' + esc(node.desc).replace(/\n/g,'<br>') + '</div>';
var inp = '';
if (node.candidates) {
inp = '<select class="settings-select" data-path="' + path + '">';
var cand = node.candidates;
if (Array.isArray(cand)) { cand.forEach(function(c){ inp += '<option value="' + esc(String(c)) + '"' + (String(c)===String(val)?' selected':'') + '>' + esc(c) + '</option>'; }); }
else if (typeof cand === 'object') { Object.keys(cand).forEach(function(k){ inp += '<option value="' + esc(k) + '"' + (String(k)===String(val)?' selected':'') + '>' + esc(cand[k]) + '</option>'; }); }
inp += '</select>';
} else if (typ === 'bool') { inp = '<label class="switch-label"><input type="checkbox" class="settings-checkbox" data-path="' + path + '"' + (val?' checked':'') + '><span class="switch-slider"></span></label>'; }
else if (typ === 'int'||typ==='number'||typ==='float') { inp = '<input type="number" class="settings-input" data-path="' + path + '" value="' + esc(String(val)) + '" step="' + (typ==='float'?'0.1':'1') + '">'; }
else { inp = '<input type="text" class="settings-input" data-path="' + path + '" value="' + esc(String(val)) + '">'; }
row.innerHTML = label + '<div class="settings-control">' + inp + '</div>'; parent.appendChild(row);
}
});
parent.querySelectorAll('.settings-input, .settings-select').forEach(function(el){ el.addEventListener('change', saveSetting); });
parent.querySelectorAll('.settings-checkbox').forEach(function(el){ el.addEventListener('change', saveSetting); });
}
function lastKey(p) { var ps = p.split('.'); return ps[ps.length - 1]; }
function toggleSec(h) { var b = h.parentElement.querySelector('.settings-section-body'); var a = h.querySelector('.arrow'); if (b.style.display === 'none') { b.style.display = 'block'; a.innerHTML = '\u25BC'; } else { b.style.display = 'none'; a.innerHTML = '\u25B6'; } }
async function saveSetting(ev) { var el = ev.target; var path = el.dataset.path; var val; if (el.type === 'checkbox') val = el.checked; else if (el.tagName === 'SELECT') val = el.value; else if (el.type === 'number') val = el.value.includes('.') ? parseFloat(el.value) : parseInt(el.value); else val = el.value; try { await put('/config/' + path.replace(/\./g,'/'), { value:val, path:path }); el.style.borderColor = 'var(--suc)'; setTimeout(function(){ el.style.borderColor = ''; }, 1500); } catch(e) { el.style.borderColor = 'var(--err)'; } }
/* === 关于 === */
async function loadAbout() { try { var d = await get('/about'); $('#about-version').textContent = '版本 ' + d.version + ' ' + d.stage; $('#about-codename').textContent = '代号: ' + d.codename + ' (' + d.codename_cn + ')'; $('#about-env').innerHTML = '<tr><td>Python</td><td>' + d.python_version + '</td></tr><tr><td>路径</td><td class="dim">' + esc(d.python_path) + '</td></tr><tr><td>OS</td><td>' + esc(d.os) + '</td></tr><tr><td>平台</td><td class="dim">' + esc(d.platform) + '</td></tr><tr><td>虚拟环境</td><td>' + (d.in_virtualenv?'是':'否') + '</td></tr><tr><td>磁盘</td><td>' + d.disk_free_gb + '/' + d.disk_total_gb + ' GB (' + d.disk_free_pct + '%)</td></tr>'; } catch(e) { $('#about-env').innerHTML = '<tr><td>加载失败</td></tr>'; } }
+168
View File
@@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HeurAMS 潜进</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<!-- 顶栏 -->
<nav id="navbar">
<button id="nav-toggle" onclick="toggleSidebar()">&equiv;</button>
<span id="nav-title">仪表盘</span>
<span id="nav-version"></span>
<button id="theme-btn" onclick="toggleTheme()" class="btn-icon">&#9788;</button>
</nav>
<!-- 主体 -->
<div id="main-wrap">
<!-- 侧边栏 -->
<aside id="sidebar">
<ul class="sidenav">
<li class="active" id="nav-lobby"><a href="#" onclick="switchView('lobby')">仪表盘</a></li>
<li id="nav-favs"><a href="#" onclick="switchView('favs')">收藏夹</a></li>
<li id="nav-cache"><a href="#" onclick="switchView('cache')">缓存管理</a></li>
<li id="nav-settings"><a href="#" onclick="switchView('settings')">设置</a></li>
<li id="nav-about"><a href="#" onclick="switchView('about')">关于</a></li>
</ul>
</aside>
<!-- 内容区 -->
<main id="main">
<!-- 错误 -->
<div id="error-box"></div>
<!-- ===== 仪表盘 ===== -->
<div id="view-lobby" class="view">
<div class="stat-row" id="lobby-stats"></div>
<div id="lobby-analysis" class="dim sm tc" style="margin-bottom:12px;"></div>
<p id="loading-lobby" class="dim tc">加载仓库...</p>
<p id="empty-lobby" class="dim tc hidden">没有找到仓库</p>
<div id="repo-list"></div>
</div>
<!-- ===== 仓库详情 ===== -->
<div id="view-prep" class="view hidden">
<p id="loading-prep" class="dim tc">加载仓库数据...</p>
<div id="prep-content" class="hidden">
<h2 id="prep-title"></h2>
<p class="dim" id="prep-meta"></p>
<div class="stat-row" id="prep-summary"></div>
<div class="bar-wrap"><div class="bar-fill" id="prep-bar"></div></div>
<p class="dim sm" id="prep-pct"></p>
<div class="h-group">
<label>每次复习量:</label>
<input type="number" id="prep-num" value="10" min="1" max="200">
<button class="btn pri" onclick="startReviewFromPrep()">开始记忆</button>
</div>
<div id="prep-list"></div>
</div>
</div>
<!-- ===== 复习 ===== -->
<div id="view-review" class="view hidden">
<div class="h-group sb">
<div class="h-group">
<strong id="review-repo"></strong>
<span id="review-phase" class="tag"></span>
<button id="fav-btn" class="btn-link" onclick="toggleFav()">&#9734;</button>
<button id="tts-btn" class="btn-link" onclick="playTTS()" title="朗读">&#9654;</button>
</div>
<span id="review-status" class="tag">断开</span>
</div>
<div class="bar-wrap sm"><div class="bar-fill" id="review-bar"></div></div>
<p class="dim sm tc" id="review-pos"></p>
<div id="screen-start" class="empty-state">
<h3>准备就绪</h3>
<p class="dim">开始复习</p>
<button class="btn pri" onclick="startReview()">开始复习</button>
</div>
<div id="screen-puzzle" class="hidden">
<div id="puzzle-card"></div>
<div class="tc">
<p class="dim sm">自评记忆程度</p>
<div id="rating-group"></div>
<div class="h-group jc">
<button class="btn sm suc" onclick="quickPass()">正确 (5)</button>
<button class="btn sm err" onclick="quickFail()">错误 (2)</button>
</div>
</div>
</div>
<div id="screen-finished" class="hidden empty-state">
<p class="ok-icon">&#10003;</p>
<h3>本次记忆进程结束</h3>
<p class="dim" id="finish-info"></p>
<p class="dim sm" id="finish-saved"></p>
<button class="btn pri" onclick="goBack()" style="margin-top:16px;">返回</button>
</div>
</div>
<!-- ===== 收藏夹 ===== -->
<div id="view-favs" class="view hidden">
<p id="loading-favs" class="dim tc">加载收藏...</p>
<p id="empty-favs" class="dim tc hidden">暂无收藏</p>
<div id="favs-list"></div>
</div>
<!-- ===== 缓存管理 ===== -->
<div id="view-cache" class="view hidden">
<p id="loading-cache" class="dim tc">加载缓存信息...</p>
<div id="cache-content" class="hidden">
<div class="cache-stats">
<div class="stat-row" id="cache-stats-cards"></div>
</div>
<div class="cache-detail">
<p class="dim sm" id="cache-path"></p>
</div>
<div class="h-group mt">
<button class="btn pri" id="cache-precache-btn" onclick="startPrecache()">生成缓存</button>
<button class="btn" id="cache-refresh-btn" onclick="loadCache()">刷新</button>
<button class="btn err" id="cache-clear-btn" onclick="clearCache()">清空缓存</button>
</div>
<p id="cache-msg" class="dim sm mt"></p>
</div>
</div>
<!-- ===== 设置 ===== -->
<div id="view-settings" class="view hidden">
<p id="loading-settings" class="dim tc">加载设置...</p>
<div id="settings-content" class="hidden"></div>
</div>
<!-- ===== 关于 ===== -->
<div id="view-about" class="view hidden">
<div class="about-section">
<h2>HeurAMS 潜进</h2>
<p class="dim" id="about-version"></p>
<p class="dim sm" id="about-codename"></p>
<p class="mt">一个基于启发式算法与认知科学理论的辅助记忆调度器。</p>
<p class="dim sm mt">以 GNU AGPL-3.0 许可证开放源代码,并含本机 API 调用豁免条款。</p>
</div>
<div class="about-section">
<h3>开发人员</h3>
<p class="dim sm">Wang Zhiyu (<a href="https://github.com/pluvium27" target="_blank">@pluvium27</a>)</p>
<p class="dim sm mt">项目发起与主要开发者</p>
</div>
<div class="about-section">
<h3>运行环境</h3>
<table class="info-table" id="about-env"></table>
</div>
</div>
</main>
</div>
<script src="/static/app.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+153
View File
@@ -0,0 +1,153 @@
/* HeurAMS unifront */
:root {
--bg: #fff; --bg2: #f5f5f7; --bg3: #e8e8ed; --bg-nav: #f0f0f2; --bg-side: #eaeaec;
--fg: #1d1d1f; --dim: #86868b; --bd: #d2d2d7; --accent: #1660A5; --suc: #2ecc71; --err: #e74c3c;
}
[data-theme="dark"] {
--bg: #1a1a2e; --bg2: #16213e; --bg3: #1f3056; --bg-nav: #12122a; --bg-side: #14142e;
--fg: #e0e0e0; --dim: #808080; --bd: #2a2a4a;
}
*,*::before,*::after { box-sizing:border-box; margin:0; padding:0; }
body { font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif; background:var(--bg); color:var(--fg); min-height:100vh; }
a { color:var(--accent); }
.dim { color:var(--dim); }
.sm { font-size:12px; }
.tc { text-align:center; }
.mt { margin-top:12px; }
.hidden { display:none !important; }
/* 顶栏 */
#navbar { height:44px; display:flex; align-items:center; gap:10px; padding:0 16px; background:var(--bg-nav); border-bottom:1px solid var(--bd); position:fixed; top:0; left:0; right:0; z-index:100; }
#nav-title { font-weight:600; font-size:15px; flex:1; }
#nav-version { font-size:12px; color:var(--dim); }
#nav-toggle, #theme-btn { background:none; border:none; font-size:20px; cursor:pointer; padding:4px 8px; color:var(--fg); }
/* 主体 */
#main-wrap { display:flex; margin-top:44px; min-height:calc(100vh - 44px); }
#sidebar { width:180px; flex-shrink:0; background:var(--bg-side); border-right:1px solid var(--bd); padding:12px 0; }
#main { flex:1; padding:24px; max-width:960px; }
/* 侧边栏 */
.sidenav { list-style:none; }
.sidenav li a { display:block; padding:8px 20px; color:var(--dim); font-size:14px; text-decoration:none; }
.sidenav li a:hover { color:var(--fg); background:var(--bg3); }
.sidenav li.active a { color:var(--accent); font-weight:600; }
/* 按钮 */
.btn { display:inline-block; padding:7px 16px; border:1px solid var(--bd); border-radius:6px; font-size:13px; cursor:pointer; background:var(--bg2); color:var(--fg); }
.btn:hover { background:var(--bg3); }
.btn:disabled { opacity:0.4; cursor:default; }
.btn.pri { background:var(--accent); border-color:var(--accent); color:#fff; }
.btn.suc { background:#2e7d32; border-color:#2e7d32; color:#fff; }
.btn.err { background:#c62828; border-color:#c62828; color:#fff; }
.btn.sm { padding:4px 12px; font-size:12px; }
.btn-link { background:none; border:none; cursor:pointer; color:var(--fg); padding:0; font-size:18px; }
/* 标签 */
.tag { display:inline-block; padding:1px 8px; border-radius:4px; font-size:11px; font-weight:600; background:var(--bg2); color:var(--dim); }
.tag.pri { background:var(--accent); color:#fff; }
/* 进度条 */
.bar-wrap { height:4px; background:var(--bd); border-radius:2px; margin:8px 0; overflow:hidden; }
.bar-wrap.sm { height:3px; }
.bar-fill { height:100%; background:var(--accent); border-radius:2px; }
/* 布局 */
.h-group { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.h-group.sb { justify-content:space-between; }
.h-group.jc { justify-content:center; }
.stat-row { display:flex; gap:12px; margin-bottom:16px; flex-wrap:wrap; }
.stat-card { flex:1; min-width:80px; text-align:center; background:var(--bg2); border:1px solid var(--bd); border-radius:8px; padding:14px 10px; }
.stat-card .n { font-size:24px; font-weight:700; color:var(--accent); }
.stat-card .l { font-size:11px; color:var(--dim); }
/* 仓库卡片 */
.repo-card { background:var(--bg2); border:1px solid var(--bd); border-radius:8px; padding:14px; margin-bottom:8px; cursor:pointer; }
.repo-card:hover { background:var(--bg3); }
.repo-card .title { font-size:15px; margin-bottom:2px; }
.repo-card .meta { font-size:12px; color:var(--dim); margin-bottom:4px; }
/* 原子列表 */
#prep-list { max-height:400px; overflow-y:auto; border:1px solid var(--bd); border-radius:6px; margin-top:12px; }
.a-item { display:flex; align-items:center; gap:8px; padding:5px 10px; border-bottom:1px solid var(--bd); font-size:13px; }
.a-item:last-child { border-bottom:none; }
.a-item .id { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.a-item .r { font-size:11px; color:var(--dim); min-width:30px; text-align:right; }
.chip { display:inline-block; padding:1px 7px; border-radius:4px; font-size:11px; font-weight:600; }
.chip.R { background:#f39c12; color:#fff; }
.chip.A { background:var(--accent); color:#fff; }
.chip.U { background:#aeaeb2; color:#fff; }
/* 谜题 */
#puzzle-card { background:var(--bg2); border:1px solid var(--bd); border-radius:8px; padding:18px; margin-bottom:12px; }
.pz-phase { font-size:11px; color:var(--accent); text-transform:uppercase; letter-spacing:1px; margin-bottom:4px; font-weight:600; }
.pz-ident { font-size:12px; color:var(--dim); margin-bottom:10px; }
.pz-primary { font-size:13px; color:var(--dim); margin-bottom:8px; }
.pz-wording { font-size:15px; line-height:1.7; margin-bottom:10px; white-space:pre-wrap; }
.pz-answer { padding:8px 10px; background:rgba(46,204,113,0.08); border:1px solid rgba(46,204,113,0.3); border-radius:6px; font-size:13px; color:var(--suc); margin-bottom:10px; display:none; }
.pz-answer.show { display:block; }
#pz-options { display:flex; flex-direction:column; gap:5px; margin-bottom:10px; }
.pz-opt { padding:8px 12px; background:var(--bg); border:1px solid var(--bd); border-radius:6px; font-size:14px; text-align:left; cursor:pointer; }
.pz-opt:hover { background:var(--bg2); }
.pz-opt.correct { border-color:var(--suc); background:rgba(46,204,113,0.08); }
.pz-opt.wrong { border-color:var(--err); background:rgba(231,76,60,0.08); }
/* 评分 */
.rt-btn { width:40px; height:40px; border:2px solid var(--bd); border-radius:50%; background:var(--bg2); font-size:14px; font-weight:600; cursor:pointer; margin:0 2px; }
.rt-btn:hover { border-color:var(--accent); }
#fav-btn { color:var(--dim); }
#fav-btn.faved { color:#f39c12; }
input[type=number] { padding:6px 8px; border:1px solid var(--bd); border-radius:6px; background:var(--bg); color:var(--fg); font-size:14px; width:64px; text-align:center; }
/* 空状态 */
.empty-state { text-align:center; padding:48px 24px; }
.empty-state .ok-icon { font-size:32px; color:var(--suc); margin-bottom:8px; }
.empty-state h3 { margin-bottom:8px; }
/* 错误 */
#error-box { display:none; padding:8px 12px; background:rgba(231,76,60,0.08); border:1px solid var(--err); border-radius:6px; color:var(--err); font-size:13px; margin-bottom:10px; }
#error-box.show { display:block; }
.err-msg { padding:12px; color:var(--err); font-size:13px; }
#loading-lobby, #loading-prep, #loading-favs, #loading-cache, #loading-settings { padding:32px; }
/* 收藏夹 */
.fav-item { display:flex; align-items:center; gap:12px; background:var(--bg2); border:1px solid var(--bd); border-radius:8px; padding:12px 16px; margin-bottom:8px; }
.fav-item .fav-body { flex:1; }
.fav-item .fav-ident { font-size:14px; }
.fav-item .fav-meta { font-size:12px; color:var(--dim); margin-top:2px; }
/* 设置 */
.settings-section { background:var(--bg2); border:1px solid var(--bd); border-radius:8px; margin-bottom:8px; overflow:hidden; }
.settings-section-title { padding:10px 14px; font-weight:600; font-size:14px; cursor:pointer; display:flex; justify-content:space-between; align-items:center; }
.settings-section-title:hover { background:var(--bg3); }
.settings-section-title .arrow { font-size:10px; color:var(--dim); }
.settings-section-body { padding:0 14px 10px; }
.settings-item { display:flex; align-items:center; gap:12px; padding:6px 0; border-top:1px solid var(--bd); }
.settings-label { font-size:13px; font-weight:500; min-width:140px; }
.settings-desc { font-size:11px; color:var(--dim); }
.settings-control { margin-left:auto; flex-shrink:0; }
.settings-input { padding:4px 8px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); font-size:13px; width:120px; }
.settings-select { padding:4px 8px; border:1px solid var(--bd); border-radius:4px; background:var(--bg); color:var(--fg); font-size:13px; }
.switch-label { position:relative; display:inline-block; width:36px; height:20px; cursor:pointer; }
.settings-checkbox { display:none; }
.switch-slider { position:absolute; top:0; left:0; right:0; bottom:0; background:var(--bd); border-radius:10px; transition:0.15s; }
.settings-checkbox:checked + .switch-slider { background:var(--accent); }
.switch-slider::before { content:''; position:absolute; width:16px; height:16px; border-radius:50%; background:#fff; top:2px; left:2px; transition:0.15s; }
.settings-checkbox:checked + .switch-slider::before { transform:translateX(16px); }
/* 关于 */
.about-section { background:var(--bg2); border:1px solid var(--bd); border-radius:8px; padding:16px; margin-bottom:12px; }
.about-section h2 { font-size:18px; margin-bottom:4px; }
.about-section h3 { font-size:15px; margin-bottom:6px; }
.info-table { width:100%; border-collapse:collapse; font-size:13px; }
.info-table td { padding:6px 0; border-bottom:1px solid var(--bd); }
.info-table td:first-child { width:130px; color:var(--dim); font-weight:600; }
/* 移动端 */
@media (max-width:600px) {
#sidebar { display:none; position:fixed; left:0; top:44px; bottom:0; z-index:99; width:200px; }
#sidebar.open { display:block; }
#main { padding:16px; }
}