feat: 网页界面与 API 总体布局
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""unifront - HeurAMS API 前端模块"""
|
||||
|
||||
from .server import create_app
|
||||
from .session import Session
|
||||
|
||||
__all__ = ["create_app", "Session"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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) + ' · ' + 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) + ' · ' + esc(r.package) + '<br>算法: ' + esc(r.algorithm) + ' · 路径: ' + 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) + ' · ' + 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>'; } }
|
||||
@@ -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()">≡</button>
|
||||
<span id="nav-title">仪表盘</span>
|
||||
<span id="nav-version"></span>
|
||||
<button id="theme-btn" onclick="toggleTheme()" class="btn-icon">☼</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()">☆</button>
|
||||
<button id="tts-btn" class="btn-link" onclick="playTTS()" title="朗读">▶</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">✓</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
+1
File diff suppressed because one or more lines are too long
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user