feat: add find page, breadcrumb components and canvas workbench updates
This commit is contained in:
+115
-16
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"template_id": "tmpl_294206b02fd44b2e88e42a8da30c10aa",
|
||||
"name": "画布设计",
|
||||
"description": "",
|
||||
"document": {
|
||||
"width": 559.3700787401575,
|
||||
"height": 793.7007874015749,
|
||||
"background": "#ffffff",
|
||||
"layers": [
|
||||
{
|
||||
"id": "layer-default",
|
||||
"name": "图层 1",
|
||||
"visible": true,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"id": "7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
|
||||
"name": "原图遮罩",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"folderId": "3c987a17-de95-472d-8755-1a1f539675e2"
|
||||
},
|
||||
{
|
||||
"id": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
|
||||
"name": "词云",
|
||||
"visible": true,
|
||||
"locked": false,
|
||||
"folderId": "3c987a17-de95-472d-8755-1a1f539675e2"
|
||||
}
|
||||
],
|
||||
"layerFolders": [
|
||||
{
|
||||
"id": "3c987a17-de95-472d-8755-1a1f539675e2",
|
||||
"name": "词云文件夹 22:13",
|
||||
"layerIds": [
|
||||
"7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
|
||||
"833f7ef1-74ec-4848-b1f6-60e6885ec6a4"
|
||||
],
|
||||
"collapsed": false
|
||||
}
|
||||
],
|
||||
"elements": [
|
||||
{
|
||||
"id": "b0080484-2f6a-4289-b7f3-958ad0543b1d",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_04bc4fa966de45b09a5e2a6b6305e8ef",
|
||||
"layerId": "7a0f1776-a77e-49cd-baf3-d343ca7cb31a",
|
||||
"groupId": "8b2d6bad-e0d4-4243-b079-e0bb7aa0abde",
|
||||
"x": 16,
|
||||
"y": 42,
|
||||
"width": 267,
|
||||
"height": 297,
|
||||
"rotation": 0,
|
||||
"opacity": 0.34
|
||||
},
|
||||
{
|
||||
"id": "bac6086f-7b64-4e81-b78d-ec57f08379e9",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_66d7cf53d2d64d3baf770f292d3bedd1",
|
||||
"layerId": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
|
||||
"groupId": "8b2d6bad-e0d4-4243-b079-e0bb7aa0abde",
|
||||
"x": 16,
|
||||
"y": 42,
|
||||
"width": 267,
|
||||
"height": 297,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"id": "a3e8d52c-50a9-4985-bce4-e10be96e9a63",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_fcbb587f2be14fc698b3876855124ec3",
|
||||
"layerId": "833f7ef1-74ec-4848-b1f6-60e6885ec6a4",
|
||||
"x": 289,
|
||||
"y": 107,
|
||||
"width": 258,
|
||||
"height": 170,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
},
|
||||
{
|
||||
"id": "caae6d4a-42b3-45f3-915c-2dd811c84343",
|
||||
"type": "sticker",
|
||||
"assetId": "asset_3a667459a53d464a9f7b0860a52e3e45",
|
||||
"layerId": "layer-default",
|
||||
"x": 86,
|
||||
"y": 384,
|
||||
"width": 420,
|
||||
"height": 280,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"reference_asset_ids": [
|
||||
"asset_04bc4fa966de45b09a5e2a6b6305e8ef",
|
||||
"asset_66d7cf53d2d64d3baf770f292d3bedd1",
|
||||
"asset_fcbb587f2be14fc698b3876855124ec3",
|
||||
"asset_3a667459a53d464a9f7b0860a52e3e45"
|
||||
],
|
||||
"cover_asset_id": "asset_04bc4fa966de45b09a5e2a6b6305e8ef",
|
||||
"created_at": "2026-08-06T08:22:01.019072+00:00",
|
||||
"updated_at": "2026-08-06T08:22:01.019072+00:00"
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"template_id": "tmpl_dc3dd73555ad4c028125556a13a9fd15",
|
||||
"name": "order-ORD2026TEST2",
|
||||
"description": "下单派单生产设计",
|
||||
"document": {
|
||||
"width": 300,
|
||||
"height": 200,
|
||||
"background": "#ffffff",
|
||||
"layers": [
|
||||
{
|
||||
"id": "l1",
|
||||
"name": "设计",
|
||||
"visible": true,
|
||||
"locked": false
|
||||
}
|
||||
],
|
||||
"layerFolders": [],
|
||||
"elements": [
|
||||
{
|
||||
"id": "e1",
|
||||
"type": "sticker",
|
||||
"name": "贴纸",
|
||||
"assetId": "asset_8eeda20a29754e108a7c6b5de631d0b5",
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
"width": 100,
|
||||
"height": 80,
|
||||
"rotation": 0,
|
||||
"opacity": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"reference_asset_ids": [
|
||||
"asset_8eeda20a29754e108a7c6b5de631d0b5"
|
||||
],
|
||||
"cover_asset_id": "asset_8eeda20a29754e108a7c6b5de631d0b5",
|
||||
"created_at": "2026-08-13T07:39:57.875921+00:00",
|
||||
"updated_at": "2026-08-13T07:39:57.875921+00:00"
|
||||
}
|
||||
Reference in New Issue
Block a user