From bceb3a15422eb07fe69adc02f10e4df7814bda3e Mon Sep 17 00:00:00 2001 From: obroccolio Date: Sun, 30 Aug 2026 14:38:09 +0800 Subject: [PATCH] feat: add find page, breadcrumb components and canvas workbench updates --- .gitignore | 5 + backend/service/app.py | 131 +++- backend/service/schemas.py | 1 + .../template.json | 104 +++ .../template.json | 39 + docs/API.md | 30 +- docs/PROJECT_STANDARD.md | 4 +- docs/info_architecture.md | 86 +++ frontend/src/App.tsx | 19 +- frontend/src/components/Breadcrumb.tsx | 40 ++ frontend/src/components/CanvasArea.tsx | 24 +- frontend/src/components/HighlightBox.tsx | 27 + frontend/src/pages/CanvasStudio.tsx | 4 +- frontend/src/pages/FindPage.tsx | 676 ++++++++++++++++++ frontend/src/pages/HelpPage.tsx | 7 + frontend/src/pages/TemplateHome.tsx | 23 +- frontend/src/pages/TestWorkbench.tsx | 12 +- frontend/src/styles.css | 661 ++++++++++++++++- .../01-project-lifecycle-and-operations.md | 87 +++ learning/README.md | 26 + ...2-safe-update-data-preservation-runbook.md | 306 ++++++++ 21 files changed, 2239 insertions(+), 73 deletions(-) create mode 100644 backend/service_design_templates/tm/tmpl_294206b02fd44b2e88e42a8da30c10aa/template.json create mode 100644 backend/service_design_templates/tm/tmpl_dc3dd73555ad4c028125556a13a9fd15/template.json create mode 100644 docs/info_architecture.md create mode 100644 frontend/src/components/Breadcrumb.tsx create mode 100644 frontend/src/components/HighlightBox.tsx create mode 100644 frontend/src/pages/FindPage.tsx create mode 100644 learning/01-project-lifecycle-and-operations.md create mode 100644 learning/README.md create mode 100644 learning/runbooks/02-safe-update-data-preservation-runbook.md diff --git a/.gitignore b/.gitignore index 7298c82..70dce98 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,8 @@ metrics.json # ── Old / local notes ────────────────────────── EWC_REF_FEATURE_PARITY_PLAN.md GIT_PUSH_指南.md + +# ── Local-only / third-party copies ─────────── +ref/ +backend/service_orders/ +docs/storage-metrics.json diff --git a/backend/service/app.py b/backend/service/app.py index 7915862..3eb25a7 100644 --- a/backend/service/app.py +++ b/backend/service/app.py @@ -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: diff --git a/backend/service/schemas.py b/backend/service/schemas.py index 6e6f009..ce954cd 100644 --- a/backend/service/schemas.py +++ b/backend/service/schemas.py @@ -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 diff --git a/backend/service_design_templates/tm/tmpl_294206b02fd44b2e88e42a8da30c10aa/template.json b/backend/service_design_templates/tm/tmpl_294206b02fd44b2e88e42a8da30c10aa/template.json new file mode 100644 index 0000000..a3cd39e --- /dev/null +++ b/backend/service_design_templates/tm/tmpl_294206b02fd44b2e88e42a8da30c10aa/template.json @@ -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" +} \ No newline at end of file diff --git a/backend/service_design_templates/tm/tmpl_dc3dd73555ad4c028125556a13a9fd15/template.json b/backend/service_design_templates/tm/tmpl_dc3dd73555ad4c028125556a13a9fd15/template.json new file mode 100644 index 0000000..017c11c --- /dev/null +++ b/backend/service_design_templates/tm/tmpl_dc3dd73555ad4c028125556a13a9fd15/template.json @@ -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" +} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index ceee4eb..f0f91d7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -22,7 +22,7 @@ ### GET `/api/jobs` -返回内存中的任务状态列表,最新任务在前。 +返回任务状态列表,最新任务在前。除内存中登记的任务外,还会扫描 `service_workspace` 补充磁盘上已完成但未登记的任务(有 `wordcloud_hd.db` 即认为是成功可查找)。 ### POST `/api/jobs` @@ -128,13 +128,14 @@ SSE 事件流。事件数据模型: ### GET `/api/jobs/{job_id}/locations` -查询词语位置。 +查询词语位置(单任务内查找)。 查询参数: | 参数 | 说明 | |------|------| -| `name` | 可选;为空返回全部,非空精确匹配 | +| `name` | 可选;为空返回全部,非空时按 `mode` 匹配 | +| `mode` | 可选;`exact`(默认,精确匹配)/ `contains`(子串包含,转义通配符后 `LIKE %name%`) | 返回: @@ -142,6 +143,7 @@ SSE 事件流。事件数据模型: { "job_id": "...", "query": "", + "mode": "exact", "total": 1, "canvas_width": 8000, "canvas_height": 4000, @@ -163,6 +165,28 @@ SSE 事件流。事件数据模型: } ``` +### GET `/api/jobs/{job_id}/find` + +单任务内查找名字的**受保护接口**,供独立查找页使用(与 `/locations` 响应结构相同)。 + +- 需要鉴权:`Authorization: Bearer `,token 由 `POST /api/login` 获取 +- 查询参数与 `/locations` 一致:`name`、`mode`(`exact` / `contains`) +- 无 token 或 token 错误返回 `403` + +### POST `/api/login` + +管理口令登录,返回查找页 / 订单页共用的鉴权 token。 + +请求体(JSON): + +```json +{"password": "管理口令"} +``` + +- 口令错误返回 `401` +- 成功返回:`{"token": "..."}`;后续请求带 `Authorization: Bearer ` +- 默认口令 `zhihui2024`,生产可用环境变量 `ORDERS_ADMIN_PASSWORD` 覆盖(见 `backend/service/app.py`) + ### GET `/api/jobs/{job_id}/occupancy_mask` 返回 PNG,显示每个已放置词语的 bounding box。 diff --git a/docs/PROJECT_STANDARD.md b/docs/PROJECT_STANDARD.md index 6949f09..9192020 100644 --- a/docs/PROJECT_STANDARD.md +++ b/docs/PROJECT_STANDARD.md @@ -58,7 +58,7 @@ wordcloud/ │ └── start-dev.sh # 本地后端启动脚本 ├── frontend/ │ ├── src/ -│ │ ├── App.tsx # 页面路由:home → canvas / wordcloud / help +│ │ ├── App.tsx # 页面路由:home → canvas / wordcloud / orders / find / help │ │ ├── main.tsx │ │ ├── types.ts # 全项目 TypeScript 类型 │ │ ├── styles.css @@ -66,6 +66,8 @@ wordcloud/ │ │ │ ├── TemplateHome.tsx # 首页:模板选择 │ │ │ ├── CanvasStudio.tsx # 画布设计页 │ │ │ ├── TestWorkbench.tsx # 词云生成页 +│ │ │ ├── OrdersPage.tsx # 生产订单页(登录保护) +│ │ │ ├── FindPage.tsx # 查找名字页(登录保护,跨任务单任务内查找) │ │ │ └── HelpPage.tsx # 帮助页 │ │ ├── components/ # 可复用组件 │ │ │ ├── AdvancedPanel.tsx diff --git a/docs/info_architecture.md b/docs/info_architecture.md new file mode 100644 index 0000000..bd17309 --- /dev/null +++ b/docs/info_architecture.md @@ -0,0 +1,86 @@ +# 页面信息架构与导航(IA) + +> 本文档定义 wordcloud 前端各页面之间的**层级关系**与**导航路径**,是将来接入 URL 路由 +> (react-router)的依据。先梳理清楚路径模型,再谈实现。 +> +> 相关:`frontend/src/App.tsx`(当前无路由,用 `AppPage` state 切页)。 + +## 1. 页面清单 + +| 页面 | 组件 | 职责 | 访问等级 | +|------|------|------|----------| +| 模板库 | `TemplateHome` | 主入口:选模板 / 新建画布 / 集中展示并列功能 | 公开 | +| 画布设计 | `CanvasStudio` | 把模板 + 贴纸 + 词云装配成成品 | 公开 | +| 词云工具 | `TestWorkbench` | 从名单生成 / 替换词云(画布的子步骤) | 公开 | +| 生产订单 | `OrdersPage` | 下单 / 派单 / 投递(WCD 任务) | **口令登录** | +| 查找名字 | `FindPage` | 跨任务查名字位置 | **口令登录**(与订单令牌互通) | +| 帮助 | `HelpPage` | 使用说明 | 公开 | + +## 2. 层级模型 + +页面不是一层平铺的 tab,而是**两类不同关系**: + +``` +┌─────────────── 并列功能(独立入口)──────────────┐ +│ 生产订单 查找名字 帮助 │ +└───────────────────────────────────────────────────┘ + ▲ 全局可达:从任意页面进入,做完可回原处 + + / 模板库(主入口) + │ + │ 选择模板 / 新建空白 + ▼ + 画布设计 + │ + │ 添加 / 替换词云 + ▼ + 词云工具 +``` + +### 2.1 线性主流程:模板库 → 画布 → 词云 + +- 一条**单向依赖链**:词云工具是画布的编辑步骤,画布是模板库的编辑步骤。 +- 导航用「**面包屑 + 前进/后退**」表达,保持编辑上下文,**不**作为 tab 平铺。 +- 词云工具**仅能从画布进入**(不做独立根路径入口、不做根 tab)。 + +### 2.2 并列功能孤岛:订单 / 查找 / 帮助 + +- 不依附画布,与模板平级、独立存在。 +- 通过**每页常驻薄导航条右侧**的图标入口进入(全局可达)。 +- 订单、查找受口令保护;两者共用同一令牌(`wordcloud-orders-token`),登录态互通。 + +## 3. 导航条规格(每页常驻薄导航条) + +所有页面共用一条顶部薄导航: + +- **左侧**:Logo + 面包屑(主流程当前路径,如 `模板 › 画布 › 词云`),当前步骤高亮; +- **右侧**:并列功能图标入口(订单 / 查找 / 帮助),从任意页面可达; +- 进入并列功能后,提供「**回到原处**」(回到进入前的工作位置 / 或模板库兜底)。 + +## 4. 未来 URL 路由映射 + +当前用 state 切页;接入路由后按此映射: + +| 页面 | 路径 | 说明 | +|------|------|------| +| 模板库 | `/` | 主入口 | +| 画布设计 | `/canvas`
`/canvas/:projectId` | 有工程时带 id | +| 词云工具 | `/canvas/:projectId/wordcloud` | 挂在画布路径下,表达「子步骤」 | +| 生产订单 | `/orders` | 并列根路径,登录保护 | +| 查找名字 | `/find` | 并列根路径,登录保护 | +| 帮助 | `/help` | 并列根路径 | + +好处:URL 本身编码了层级——词云路径带前缀 `/canvas/...`,订单/查找/帮助是根路径, +一眼可知关系;也支持刷新保持、前进后退、可分享链接。 + +## 5. 已确认决策(记录) + +- **D1**:词云工具仅作为画布子步骤,无独立入口。 +- **D2**:并列功能(订单/查找/帮助)入口放在「每页常驻薄导航条」,而非只在首页。 +- **D3**:将来必做 URL 路由(本模型为其落点)。 + +## 6. 待探索 / 未定 + +- 画布多工程时 `/canvas/:projectId` 的工程列表放哪(模板库内 or 独立)。 +- 并列功能「回到原处」的精确记忆(记住上一个主流程步骤即可,暂不引入后端会话)。 +- 帮助页是否并入模板库首页底部,还是保留独立页面。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f4611d..b694c01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useLayoutEffect, useState } from 'react'; import type { ThemeMode } from './components/AppSettingsWindow'; import CanvasStudio from './pages/CanvasStudio'; +import FindPage from './pages/FindPage'; import HelpPage from './pages/HelpPage'; import OrdersPage from './pages/OrdersPage'; import TemplateHome from './pages/TemplateHome'; @@ -8,7 +9,7 @@ import TestWorkbench from './pages/TestWorkbench'; import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types'; import { createDefaultDocument } from './lib/canvasDocument'; -type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'help'; +type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'find' | 'help'; const getStoredTheme = (): ThemeMode => { const stored = window.localStorage.getItem('wordcloud-theme'); @@ -54,6 +55,7 @@ export default function App() { systemTheme={systemTheme} onThemeModeChange={setThemeMode} onOpenCanvas={() => setPage('canvas')} + onOpenHome={() => setPage('home')} onOpenHelp={openHelp} replaceSession={pendingReplaceSession} onConsumeReplaceSession={() => setPendingReplaceSession(null)} @@ -100,6 +102,7 @@ export default function App() { onOpenCanvas={() => setPage('canvas')} onOpenWordcloud={() => setPage('wordcloud')} onOpenOrders={() => setPage('orders')} + onOpenFind={() => setPage('find')} /> ); } @@ -115,6 +118,17 @@ export default function App() { ); } + if (page === 'find') { + return ( + setPage('home')} + /> + ); + } + return ( setPage('canvas')} - onOpenWordcloud={() => setPage('wordcloud')} onOpenOrders={() => setPage('orders')} + onOpenFind={() => setPage('find')} onOpenHelp={openHelp} /> ); diff --git a/frontend/src/components/Breadcrumb.tsx b/frontend/src/components/Breadcrumb.tsx new file mode 100644 index 0000000..0817bb3 --- /dev/null +++ b/frontend/src/components/Breadcrumb.tsx @@ -0,0 +1,40 @@ +interface Crumb { + /** 显示文本 */ + label: string; + /** 是否当前步(高亮) */ + active?: boolean; + /** 点击回退到该级;非当前步才可点 */ + onClick?: () => void; +} + +interface BreadcrumbProps { + crumbs: Crumb[]; +} + +/** + * 线性主流程面包屑(模板库 › 画布 › 词云)。 + * 玻璃 pill + 当前步高亮;旧步可点回退。用于每页薄导航条左侧。 + */ +export default function Breadcrumb({ crumbs }: BreadcrumbProps) { + return ( +
+ {crumbs.map((c, i) => ( + + {i > 0 && } + {c.active ? ( + {c.label} + ) : ( + + )} + + ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/CanvasArea.tsx b/frontend/src/components/CanvasArea.tsx index 0a0bcef..635c1ab 100644 --- a/frontend/src/components/CanvasArea.tsx +++ b/frontend/src/components/CanvasArea.tsx @@ -1,6 +1,7 @@ import { useRef, useEffect, useState } from 'react'; -import { NameLocation, JobResult } from '../types'; +import type { NameLocation, JobResult } from '../types'; import { apiUrl } from '../lib/api'; +import HighlightBox from './HighlightBox'; import { IconCloudy } from './Icons'; interface CanvasAreaProps { @@ -57,29 +58,10 @@ export default function CanvasArea({ style={{ transform: `scale(${zoom})` }} /> {highlightLocation && jobResult && ( - + )} )} ); } - -function HighlightBox({ location, zoom }: { location: NameLocation; zoom: number }) { - const x = location.box_x ?? location.x; - const y = location.box_y ?? location.y; - const width = location.box_width ?? location.width ?? location.font_size ?? 24; - const height = location.box_height ?? location.height ?? location.font_size ?? 24; - - return ( -
- ); -} diff --git a/frontend/src/components/HighlightBox.tsx b/frontend/src/components/HighlightBox.tsx new file mode 100644 index 0000000..10b7095 --- /dev/null +++ b/frontend/src/components/HighlightBox.tsx @@ -0,0 +1,27 @@ +import type { NameLocation } from '../types'; + +interface HighlightBoxProps { + location: NameLocation; + /** 展示缩放系数:把画布坐标的命中框等比缩放到当前预览尺寸。 */ + scale: number; +} + +/** 在词云图上框住命中的名字位置。CanvasArea 与 FindPage 共用。 */ +export default function HighlightBox({ location, scale }: HighlightBoxProps) { + const left = location.box_x ?? location.x; + const top = location.box_y ?? location.y; + const width = location.box_width ?? location.width ?? location.font_size ?? 24; + const height = location.box_height ?? location.height ?? location.font_size ?? 24; + + return ( +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/CanvasStudio.tsx b/frontend/src/pages/CanvasStudio.tsx index 3d6e526..25616c2 100644 --- a/frontend/src/pages/CanvasStudio.tsx +++ b/frontend/src/pages/CanvasStudio.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react'; import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow'; +import Breadcrumb from '../components/Breadcrumb'; import { CanvasDocument, CanvasElement, @@ -1001,7 +1002,7 @@ export default function CanvasStudio({