feat: add find page, breadcrumb components and canvas workbench updates

This commit is contained in:
2026-08-30 14:38:09 +08:00
parent ee3b7745b7
commit bceb3a1542
21 changed files with 2239 additions and 73 deletions
+115 -16
View File
@@ -179,8 +179,21 @@ def storage_summary() -> dict:
@app.get("/api/jobs", response_model=list[JobStatus])
def list_jobs() -> list[JobStatus]:
"""列出全部任务,含仍在 metadata store 登记的管理任务,以及磁盘上已完成但未登记的任务。"""
with manager._lock:
return list(reversed([state.status for state in manager._jobs.values()]))
statuses = [state.status for state in manager._jobs.values()]
# 补充磁盘上已落成的任务(manager 只记住本次运行/登记过的;历史任务目录
# 若未被 metadata store 登记,则从 workspace 扫描补齐,供查找页跨任务使用)。
# 与详情接口的 _scan_disk_job_status 同一套产物推断,保证列表可见即可访问。
tracked = {s.job_id for s in statuses}
for job_id in storage.list_job_ids():
if job_id in tracked:
continue
scanned = _scan_disk_job_status(job_id)
if scanned is not None:
statuses.append(scanned)
return list(reversed(statuses))
@app.post("/api/jobs", response_model=JobCreateResponse)
@@ -527,11 +540,10 @@ def stream_events(job_id: str):
@app.get("/api/jobs/{job_id}/result", response_model=JobResult)
def get_result(job_id: str) -> JobResult:
if not manager.exists(job_id):
status = _resolve_job_status(job_id)
if status is None:
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
def u(kind: str) -> str:
p = status.artifacts.get(kind, "")
if not p:
@@ -559,6 +571,56 @@ def _normalize_orientation(value: object) -> str:
return "horizontal"
def _scan_disk_job_status(job_id: str) -> JobStatus | None:
"""任务未在 manager 登记时,从输出目录合成完整状态。
与 list_jobs 的磁盘扫描同一套产物约定(见 runner.py 产物扫描),
让磁盘任务在详情接口(/find /locations /files/{kind})也能正常工作,
不再因不在内存而 404。找不到产物目录或 db 时返回 None。
"""
output_dir = storage.base_dir / job_id / "output"
if not output_dir.is_dir():
return None
png = next(output_dir.glob("*.png"), None)
svg = next(
(p for p in sorted(output_dir.glob("*.svg")) if not p.name.endswith("_stroke.svg")),
None,
)
svg_stroke = next(output_dir.glob("*_stroke.svg"), None)
# NOTE: 不要用 "*[!_stroke].svg" 形式的字符类去做 svg 排除(见 runner.py 注释)。
db = next(output_dir.glob("*.db"), None)
metrics = next(output_dir.glob("*metrics*.json"), None)
if db is None:
return None
mtime = datetime.fromtimestamp(output_dir.stat().st_mtime, tz=timezone.utc)
return JobStatus(
job_id=job_id,
status="success",
stage="completed",
progress_percent=100,
message="未登记任务(磁盘扫描)",
created_at=mtime,
updated_at=mtime,
artifacts={
"png": str(png) if png else "",
"svg": str(svg) if svg else "",
"svg_stroke": str(svg_stroke) if svg_stroke else "",
"db": str(db) if db else "",
"metrics": str(metrics) if metrics else "",
},
error="",
)
def _resolve_job_status(job_id: str) -> JobStatus | None:
"""优先内存,缺则磁盘回落;两项都无返回 None。详情接口统一用它取状态。"""
if manager.exists(job_id):
return manager.get_status(job_id)
return _scan_disk_job_status(job_id)
def _load_job_config(job_id: str) -> dict:
config_path = storage.base_dir / job_id / "config.json"
if not config_path.exists():
@@ -618,12 +680,17 @@ def _compute_text_box(
return bbox[0], bbox[1], bbox[2] - bbox[0], bbox[3] - bbox[1]
@app.get("/api/jobs/{job_id}/locations", response_model=JobLocationSearchResult)
def search_locations(job_id: str, name: str = Query("", description="需要查找的名字")) -> JobLocationSearchResult:
if not manager.exists(job_id):
def _search_job_locations(job_id: str, name: str, mode: str) -> JobLocationSearchResult:
"""在单个任务 word_locations 库内按名字查找,供各处路由复用。
mode=exact(默认) 精确匹配 name = ?;
mode=contains 子串匹配 name LIKE %q%(通配符转义)。
旧库缺 box_* 列时按字体度量回退计算外框。
"""
status = _resolve_job_status(job_id)
if status is None:
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
db_path_str = status.artifacts.get("db", "")
if not db_path_str:
raise HTTPException(status_code=404, detail="db artifact not ready")
@@ -633,6 +700,9 @@ def search_locations(job_id: str, name: str = Query("", description="需要查
raise HTTPException(status_code=404, detail="db artifact missing on disk")
query_name = name.strip()
match_mode = (mode or "exact").strip().lower()
if match_mode not in {"exact", "contains"}:
match_mode = "exact"
canvas_width, canvas_height = _resolve_canvas_size(status)
job_config = _load_job_config(job_id)
font_path = _resolve_font_path(str(job_config.get("WC_FONT_PATH") or wc_config.WC_FONT_PATH))
@@ -651,8 +721,14 @@ def search_locations(job_id: str, name: str = Query("", description="需要查
sql = f"SELECT {', '.join(select_columns)} FROM word_locations"
params: list[object] = []
if query_name:
sql += " WHERE name = ?"
params.append(query_name)
if match_mode == "contains":
# LIKE 转义通配符,再用 %query% 做子串匹配
escaped = query_name.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
sql += ' WHERE name LIKE ? ESCAPE "\\"'
params.append(f"%{escaped}%")
else:
sql += " WHERE name = ?"
params.append(query_name)
sql += " ORDER BY id ASC"
rows = conn.execute(sql, params).fetchall()
@@ -699,6 +775,7 @@ def search_locations(job_id: str, name: str = Query("", description="需要查
return JobLocationSearchResult(
job_id=job_id,
query=query_name,
mode=match_mode,
total=len(matches),
canvas_width=canvas_width,
canvas_height=canvas_height,
@@ -706,13 +783,35 @@ def search_locations(job_id: str, name: str = Query("", description="需要查
)
@app.get("/api/jobs/{job_id}/locations", response_model=JobLocationSearchResult)
def search_locations(
job_id: str,
name: str = Query("", description="需要查找的名字"),
mode: str = Query("exact", description="匹配方式:exact=精确(默认,向后兼容);contains=包含子串"),
) -> JobLocationSearchResult:
"""在单个任务内查找名字(未鉴权,向后兼容,供词云工作台 FindPanel 使用)。"""
return _search_job_locations(job_id, name, mode)
@app.get("/api/jobs/{job_id}/find", response_model=JobLocationSearchResult)
def find_job_locations(
request: Request,
job_id: str,
name: str = Query("", description="需要查找的名字"),
mode: str = Query("exact", description="匹配方式:exact=精确;contains=包含子串"),
) -> JobLocationSearchResult:
"""在单个任务内查找名字(需管理口令,供独立查找页使用)。"""
_require_orders_auth(request)
return _search_job_locations(job_id, name, mode)
@app.get("/api/jobs/{job_id}/occupancy_mask")
def get_occupancy_mask(job_id: str):
"""生成并返回占位遮罩图:每个已放置词语的 bounding box 以实色方块表示。"""
if not manager.exists(job_id):
status = _resolve_job_status(job_id)
if status is None:
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
db_path_str = status.artifacts.get("db", "")
if not db_path_str:
raise HTTPException(status_code=404, detail="db artifact not ready")
@@ -795,10 +894,10 @@ def get_custom_svg(
from core.layout import OptimizedEfficientWordCloud
from core import config as wc_config
if not manager.exists(job_id):
status = _resolve_job_status(job_id)
if status is None:
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
db_path_str = status.artifacts.get("db", "")
if not db_path_str:
raise HTTPException(status_code=404, detail="db artifact not ready")
@@ -870,10 +969,10 @@ def get_custom_svg(
@app.get("/api/jobs/{job_id}/files/{kind}")
def get_file(job_id: str, kind: str):
if not manager.exists(job_id):
status = _resolve_job_status(job_id)
if status is None:
raise HTTPException(status_code=404, detail="job not found")
status = manager.get_status(job_id)
try:
path = manager.resolve_artifact_path(status, kind)
except KeyError:
+1
View File
@@ -66,6 +66,7 @@ class WordLocation(BaseModel):
class JobLocationSearchResult(BaseModel):
job_id: str
query: str = ""
mode: Literal["exact", "contains"] = "exact"
total: int = 0
canvas_width: int = 0
canvas_height: int = 0