--- name: atomk-desktop-development description: AtomK-Desktop Electron 应用的开发工作流:spec 驱动开发、Gitea PR 流程、review pipeline、构建触发规则、常见构建问题。 category: browser-automation tags: [atomk, desktop, electron, gitea, workflow, build, pr, development] --- # AtomK-Desktop 开发工作流 AtomK-Desktop (Electron + TypeScript + React) 的开发、审查、提交全流程。 ## 触发条件 - 用户说"开发 Desktop"、"改 AtomK-Desktop"、"提交 Desktop 代码"、"给 Desktop 加功能" - 按 spec 实现新功能 - 修复 Desktop bug - 用户说"下载页面"、"放到下载页"、"WooCommerce 下载"、"us1.atomk.cn" → 加载 `atomk-platform` skill 的 `references/atomk-desktop-upload.md` - 用户说"更新下载页"、"下载页面也更新" → 见 `references/wp-download-page.md` - **Release 发布流程**(build → COS 上传 → manifest 更新 → 下载页更新)→ 见 `references/release-workflow.md` 和 `references/wp-download-page.md` - **Electron-Updater 自动更新**(tools/update/ latest.yml + blockmap COS 上传 + 降级防护)→ 见 `references/electron-updater-cos.md` - **COS 公开访问**(上传后需 set ACL public-read)→ `references/cos-public-read.md` - **Cookie 提取 `Network.enable`**(Chrome CDP 必需先 enable Network domain)→ `references/cookie-network-enable.md` - **新增 API 代理屏幕**(Images 面板开发 + proxyPostForm 文件上传)→ `references/images-panel-development.md` - **Desktop 内置浏览器打开 URL**(cdpNavigate 模式 + ExternalLink UI)→ `references/cdp-navigate-builtin-browser.md` - **Desktop 技能注册表迁移**(Gitea 服务器切换 + registry URL 更新)→ `references/skills-registry-migration.md` ## 常见构建陷阱 ### TypeScript 类型声明未同步 新增 IPC 方法到 `preload/index.ts` 后,**必须同步更新** `preload/index.d.ts` 的 TypeScript 类型声明,否则 `tsc --noEmit` 报 `Property 'xxx' does not exist`。 ```typescript // preload/index.d.ts 同步添加 proxyPostForm: (path: string, body: unknown) => Promise; ``` ### 未使用的变量声明 `tsc --noEmit` 开启了 `noUnusedLocals`,声明但未使用的变量会阻止构建。未使用的 `setXxx` 需改为 `const [xxx] = useState(...)`,未使用的导入直接删除。 --- ## 1. Repo 结构 ``` AtomK-Desktop/ ├── src/main/mcp/ # ChromeDevToolsMCP (Phase 0/1, v4.0.3+) │ ├── BridgeRelayTransport.ts # MCP Transport 经 Bridge WS │ ├── TargetManager.ts # Chrome 多 tab CDP 管理 │ ├── CDPAdapter.ts # CDP domain 命令执行 │ ├── tools.ts # 22 工具 (合法 JSON Schema) │ └── MCPController.ts # McpServer + CallToolResult 分派 ├── src/main/ # Electron 主进程 │ ├── index.ts # 入口 │ ├── chrome-bridge.ts # CDP relay + CloudBridge ├── src/main/bridge-manager.ts # 多 profile Bridge 管理 (JWT auth_type/token 字段 v4.0.13+) │ ├── bridge-message-router.ts # v4.0.0 playwright.*/ziniao.*/hubstudio.*/hubstudio_cdp.* 路由 │ ├── playwright-controller.ts # v4.0.0 Playwright 控制器 │ ├── ziniao-client.ts # v4.0.0 紫鸟 HTTP API 客户端 │ ├── hubstudio-client.ts # v4.0.1+ Hubstudio API 客户端 │ ├── hubstudio-cdp-controller.ts # v4.0.x HubStudio Chrome CDP 中继 (Playwright connectOverCDP) │ └── ... ├── src/renderer/ # React 前端 ├── package.json # v4.0.0, electron ^39.2.6, playwright-core ~1.56.0 ├── docs/ # 项目文档 └── ... ``` --- ## 2. 开发流水线(Spec-Driven + 双 Review) ``` Spec v2.1 → 实现代码 → Claude Code 双端并行审查 → 分类 🔴P0 / 🟡P1 / 🟢P2 → delegate_task 并行修复 (Desktop + Bridge 各一子代理) → 独立 PR → merge ``` ### Claude Code 审查(双端并行) ```bash # Desktop 和 Bridge 并行审查 cat file1.ts ... | claude -p "review prompt" & # Desktop cat server.py | claude -p "review prompt" & # Bridge ``` 详见 `references/mcp-security-review-4.0.3.md`(完整 35 项审查报告 + 三轮修复方案)。 ### 三轮修复顺序 | 轮次 | 级别 | 典型修复 | 分支命名 | |:---:|:---:|------|------| | P0 | 🔴 Critical | CDP白名单、Schema校验、auth绕过、跨用户路由 | `fix/p0-claude-review-mcp` | | P1 | 🟡 Warning | 内存泄漏、并发去重、futures resolve、路径穿越 | `fix/p1-claude-review-mcp` | | S | 🟢 Suggestion | ClientSession池、错误脱敏、注释修正 | `fix/s-claude-review-mcp` | 每轮独立 PR → merge main → 下一轮(避免冲突累积)。 **三审流水线(Claude Code + GLM + 自身 并行,推荐)** 用户要求:「用 Claude Code + GLM 5.1 + 你自身三重审核」。标准流程: 已验证于 v4.1.1→v4.1.2 审查(Claude Code ×3 + GLM-5.1,发现 3🔴+5🟡), 完整报告见 `references/claude-review-v4.1.1-2026-07-06.md`。 v4.1.7 GLM-5.2 审查(11项:2🔴+5🟡+4🟢)见 `references/glm-review-v4.1.7-2026-07-06.md`。 已验证于 v4.1.6 self-review(v4.1.1→v4.1.6 diff,发现 1🔴(批量上限)+1🟡(getImageUrl弱校验)+1🟢(isSafeUrl重复)),详见 pitfall #107。\n\n已验证于 v4.1.6 三件套全量审查(Claude Opus 4.8 单审 Desktop+Bridge+Server,发现 6🔴+21🟡+15🟢),详见 `references/claude-review-v4.1.6-2026-07-06.md`。 **Self-Review 审查清单(自身审查 pass):** 当 Claude Code + GLM 双审完成后,自身审查聚焦以下高风险面: 1. **批量/循环上限**:所有数组/列表操作有无 max 限制(batch 提交、文件列表、URL 列表)。Server 有限制不代表 Desktop 不需要。 2. **URL 协议校验**:`startsWith("http")` 不等于安全校验—必须 `new URL()` + 白名单 `["http:", "https:"]`。检查所有 `src`、`href`、`cdpNavigate`、`openExternal` 调用点。 3. **IPC handler 参数校验**:`as` 类型断言不是校验—检查 `Array.isArray()`、`typeof`、空值守卫是否到位。 4. **Auth gate 一致性**:所有面板是否用 `authChecked` + `authReady` 双状态 gating(见 pitfall #102)。新面板是否从 Submit/Products 复制完整 auth block。 5. **错误消息 XSS**:用户输入(URL、CSV 行)是否拼接进 error message 后直插 JSX `{error}`。React 自动转义所以通常安全,但如果用了 `dangerouslySetInnerHTML` 则高危。 6. **proxyGet 白名单同步**:`index.ts` 和 `atomlisting.ts` 两处 `PROXY_ALLOWED_PREFIXES` 是否一致(pitfall #100)。 **Self-Review 快速扫描命令**: ```bash # 差量文件列表 git diff ..HEAD --stat # URL 校验覆盖度 rg 'startsWith\("http"\)' src/renderer/ # 潜在弱校验 rg 'isSafeUrl|safeUrl|ALLOWED_PROTOCOL' src/renderer/ # 已有防护 # Batch/loop 上限 rg '\.length\s*(>|===?\s*0)' src/main/ --type ts | grep -v node_modules # Auth gate 模式 grep -l 'authChecked' src/renderer/src/screens/*/**.tsx | sort grep -l 'authReady' src/renderer/src/screens/*/**.tsx | sort # diff 两者输出看哪些面板有 authChecked ``` 已验证于 v4.1.1→v4.1.2 审查(Claude Code ×3 + GLM-5.1,发现 3🔴+5🟡), 完整报告见 `references/claude-review-v4.1.1-2026-07-06.md`。 v4.1.7 GLM-5.2 审查(11项:2🔴+5🟡+4🟢)见 `references/glm-review-v4.1.7-2026-07-06.md`。 已验证于 v4.1.6 self-review(v4.1.1→v4.1.6 diff,发现 1🔴(批量上限)+1🟡(getImageUrl弱校验)+1🟢(isSafeUrl重复)),详见 pitfall #107。\n\n已验证于 v4.1.6 三件套全量审查(Claude Opus 4.8 单审 Desktop+Bridge+Server,发现 6🔴+21🟡+15🟢),详见 `references/claude-review-v4.1.6-2026-07-06.md`。 **Self-Review 审查清单(自身审查 pass):** 当 Claude Code + GLM 双审完成后,自身审查聚焦以下高风险面: 1. **批量/循环上限**:所有数组/列表操作有无 max 限制(batch 提交、文件列表、URL 列表)。Server 有限制不代表 Desktop 不需要。 2. **URL 协议校验**:`startsWith("http")` 不等于安全校验—必须 `new URL()` + 白名单 `["http:", "https:"]`。检查所有 `src`、`href`、`cdpNavigate`、`openExternal` 调用点。 3. **IPC handler 参数校验**:`as` 类型断言不是校验—检查 `Array.isArray()`、`typeof`、空值守卫是否到位。 4. **Auth gate 一致性**:所有面板是否用 `authChecked` + `authReady` 双状态 gating(见 pitfall #102)。新面板是否从 Submit/Products 复制完整 auth block。 5. **错误消息 XSS**:用户输入(URL、CSV 行)是否拼接进 error message 后直插 JSX `{error}`。React 自动转义所以通常安全,但如果用了 `dangerouslySetInnerHTML` 则高危。 6. **proxyGet 白名单同步**:`index.ts` 和 `atomlisting.ts` 两处 `PROXY_ALLOWED_PREFIXES` 是否一致(pitfall #100)。 **Self-Review 快速扫描命令**: ```bash # 差量文件列表 git diff ..HEAD --stat # URL 校验覆盖度 rg 'startsWith\("http"\)' src/renderer/ # 潜在弱校验 rg 'isSafeUrl|safeUrl|ALLOWED_PROTOCOL' src/renderer/ # 已有防护 # Batch/loop 上限 rg '\.length\s*(>|===?\s*0)' src/main/ --type ts | grep -v node_modules # Auth gate 模式 grep -l 'authChecked' src/renderer/src/screens/*/**.tsx | sort grep -l 'authReady' src/renderer/src/screens/*/**.tsx | sort # diff 两者输出看哪些面板有 authChecked ``` **模型选择**:GLM-5.1 稳定可靠;GLM-5.2 (2026-07 起可用) 是 reasoning 模型, 审查质量更高但需 2-3x token 预算(`max_tokens≥8000` 防 reasoning 吃掉全部 content)。 对超大文件 (>4000 行) 审查,GLM-5.2 的 reasoning 优势在分段提取后更明显。 详见 `zhipu-coding-subagent` skill。 **R1(首轮三重并行审查):** ```python # 三个审查同时启动 terminal("cat changed.ts | claude -p 'review...'", background=true, notify=true) # Claude delegate_task(goal="审查全部新增代码...", toolsets=["terminal","file"]) # GLM-5.1 # 自身审查 — 直接在当前上下文中审查 ``` **输出**:三个独立审查报告 → 合并去重 → 生成统一 🔴🟡🟢 分类表 → 逐级修复。 **三重审查合并报告格式**: | # | 级别 | 问题 | GLM-5.1 | Claude | Self | |---|---|---|---|---|---| 每行标注哪方发现了此问题,便于评估置信度。三方都发现的 🔴 → 最高优先级。 **R1→修复→R2(二轮验证修复):** 修复所有 🔴 后再次并行审查,验证修复正确性且无回归。 R2 用同模式,prompt 改为: - GLM: "R2审查:检查R1修复是否正确,是否有回归或新问题" - Claude: "R2 review: check if R1 fixes were correctly implemented" **每轮输出格式**:🔴 CRITICAL / 🟡 WARNING / 🟢 SUGGESTION,文件+行号+描述+修复建议。 ### 双审流水线(GLM-5.1 + Claude Code 并行,旧模式) 用户单独要求双审时使用。流程同三审但去掉自身审查。 ### delegate_task 并行修复模式(推荐) 审查报告生成后,P0/P1 问题用两个并行子代理分别修复 Desktop 和 Bridge: ```python delegate_task(tasks=[ {"goal": "Fix Desktop P0 issues...", "toolsets": ["terminal", "file"]}, {"goal": "Fix Bridge P0 issues...", "toolsets": ["terminal", "file"]}, ]) ``` 每个子代理独立:读文件 → patch → git commit → push → Gitea API PR + merge。 优点:隔离上下文,避免跨仓库混乱;并行执行,效率高。 ### GLM-5.1 审查(第二阶段 — delegate_task) ```python # 用 delegate_task 调用智谱 GLM-5.1 做代码审查 delegate_task( goal="审查 Desktop ChromeDevToolsMCP 实现的全部代码", context="项目: AtomK-Desktop v4.0.3 ...", toolsets=["terminal", "file"] ) ``` GLM-5.1 通过 `file` + `terminal` 工具集可读全部源码,输出完整审查报告含 🔴🟡🟢 分级。 ### Claude Code 审查 — 完整提示词模板 当审查 MCP/CDP/Bridge 安全相关代码时,使用以下详细提示词。 **⚠️ 大文件安全审计完整流水线**(GLM-5.1 审查 → Spec → Claude Code 三阶段实现): 详见 `references/glm-claude-review-pipeline.md`。已验证于 Bridge server.py (3628行) 全量审计, 发现 5🔴+11🟡+10🟢,三阶段 Claude Code 实现,总变更 +722/-169,零回归。 **Desktop + Bridge 应并行审查**(两个 `terminal(background=true)` + `notify_on_complete=true`),各自 `timeout=300`。 **Claude Code 从 Spec 实现代码**:完整流水线见 `references/claude-code-spec-implementation.md`(验证于 2026-06-18: GLM-5.1 审查 → Spec → Claude Code 实现 Phase 1,5 CRITICAL 修复)。 **Desktop 审查提示词:** ``` You are a senior TypeScript/Electron security engineer. Review the following code. Output in Chinese. Use format: 🔴 CRITICAL — security, memory leak, data corruption 🟡 WARNING — logic bugs, race conditions, edge cases 🟢 SUGGESTION — code quality, performance, maintainability For each finding: file, line number, severity, problem description, fix suggestion. Focus on: 1. Security: can an attacker through Bridge WS execute arbitrary commands, read files, or bypass auth? 2. Protocol correctness: does MCP JSON-RPC 2.0 compliance hold? 3. CDP safety: are dangerous CDP domains restricted? 4. Resource leaks: any unclosed CDP connections, listeners, or memory leaks? 5. Error handling: do failures cascade or get swallowed? 6. Race conditions: in multi-tab, multi-Bridge scenarios ``` **Bridge 审查提示词:** ``` You are a senior Python/security engineer. Review the following code. Output in Chinese. Use format: 🔴 CRITICAL — security, data loss, auth bypass 🟡 WARNING — logic bugs, race conditions, edge cases 🟢 SUGGESTION — code quality, performance, maintainability For each finding: function/route name, line number, severity, problem description, fix suggestion. Focus on: 1. Auth: can JWT or API key checks be bypassed? Are all sensitive endpoints protected? 2. Path traversal: can an attacker manipulate file paths in upload/download endpoints? 3. WS security: any injection, message spoofing, or unauthorized routing? 4. CDP tunnel: can an attacker hijack another user's CDP session? 5. Race conditions: in multi-user, multi-Desktop scenarios 6. Error handling: do unhandled exceptions crash the server? 7. Resource management: connections, file descriptors, memory ``` **多文件审查方法:** ```bash # ✅ 正确:pipe stdin,安全且 Claude 可看到全部文件 cat file1.ts file2.ts file3.ts | claude -p "review prompt here" # ✅ 错误:$(...) 先被 bash 展开,TypeScript 关键字被当命令执行 claude -p "review $(cat file.ts)" # 禁止! ``` --- ## 3. Git 工作流(Gitea 受保护 main 分支 → frp.9webs.online:3000) main 分支受保护,禁止直接 push。必须走 PR: ```bash # 1. 从 main 切 release 分支 cd /home/ubuntu/AtomK-Desktop git checkout main && git pull origin main git checkout -b release/vX.Y.Z # 2. 修改代码 + commit git add -A git commit -m "feat: ..." # 3. Push release 分支 git push origin release/vX.Y.Z ``` **通过 Gitea API 创建 PR + 合并**(Basic Auth `admin9webs:Tt123456!`): > ⚠️ `execute_code` 在部分环境下被阻止。直接用 `terminal` + Python inline 替代,不要走 cron 脚本模式: ```python import urllib.request, json, base64, ssl auth = base64.b64encode(b'admin9webs:Tt123456!').decode() ctx = ssl._create_unverified_context() gitea = 'http://frp.9webs.online:3000' owner, repo = '9webs', 'AtomK-Desktop' head = 'release/vX.Y.Z' # 创建 PR pr_data = json.dumps({'title': '...', 'head': head, 'base': 'main'}).encode() pr_req = urllib.request.Request(f'{gitea}/api/v1/repos/{owner}/{repo}/pulls', data=pr_data, method='POST', headers={'Authorization': f'Basic {auth}', 'Content-Type': 'application/json'}) pr = json.loads(urllib.request.urlopen(pr_req, context=ctx).read()) # 合并 PR merge_data = json.dumps({'Do': 'merge'}).encode() merge_req = urllib.request.Request( f'{gitea}/api/v1/repos/{owner}/{repo}/pulls/{pr["number"]}/merge', data=merge_data, method='POST', headers={'Authorization': f'Basic {auth}', 'Content-Type': 'application/json'}) urllib.request.urlopen(merge_req, context=ctx) ``` **清理:** ```bash git checkout main && git pull origin main git branch -d release/vX.Y.Z git push origin --delete release/vX.Y.Z ``` --- ## 4. 构建规则(重要!) **只有用户明确说 "build" 时才构建。** 永远不要在代码提交后自动构建。 **用户偏好:先 build 验证再提交 PR。** 当代码改动就绪后的标准流程: ```bash # ① stash → pull → pop(对齐 origin/main) # ② typecheck 双验证(web + node 并行) npx tsc --noEmit -p tsconfig.web.json --composite false npx tsc --noEmit -p tsconfig.node.json --composite false # ③ 修复 typecheck 错误(若有) # ④ build 验证 rm -f dist/*.exe dist/*.blockmap NODE_OPTIONS=--max-old-space-size=4096 npm run build:win # ⑤ 确认产物存在(ls -lh dist/*.exe dist/latest.yml) # ⑥ 然后才 commit → release 分支 → PR → merge ``` 构建命令: ```bash NODE_OPTIONS=--max-old-space-size=4096 npm run build:win ``` ⚠️ **构建用 foreground 模式**:`terminal(background=true)` + `notify_on_complete=true` 在 npm build 场景下输出捕获不可靠(typecheck 阶段无输出导致进程看起来死掉)。用 `terminal(timeout=600)` 前台运行,输出正常流式显示,构建完成即可见结果。 构建产物上传 COS: ```bash coscmd upload /path/to/dist/*.exe desktop/ ``` > Desktop 当前 v4.0.1(main @ `ec24404`),已 build → `dist/atomk-desktop-4.0.1-setup.exe` (240MB),已上传 COS 三个路径。 --- ## 5. Spec 文档编写 新功能开发前先写 spec,路径 `docs/superpowers/specs/YYYY-MM-DD-title.md`。 **模板结构:** ```markdown # Title **Status:** Draft — pending review **Date:** YYYY-MM-DD **Branch target:** `feat/xxx` → PR `main` ## Goal ## Non-goals ## Architecture ## Implementation ## Phases ## Open Questions ## References ``` **提交流程:** 切分支 → 写 spec → commit → push → Gitea API 创建 PR → merge。spec 不需要 build。 **示例**: `docs/superpowers/specs/2026-06-17-desktop-chrome-mcp-design.md` **跨仓库架构改进 spec 模板**:当审查覆盖 Desktop + Bridge + Server 三端时,用 `references/cross-repo-architecture-spec.md` 的模板(Part A/B/C 结构、Phase 表含 LOC 估算、多批次追加策略、跨层总结矩阵)。 ### 5d. Spec 编写 → Claude Code Opus 审查 → 修 Spec(架构设计,推荐) 架构级设计 spec 最有效审查方式:写 spec → 直接 pipe 给 Claude Code Opus。 ```bash # 1. 写 Spec → /home/ubuntu/docs/spec-.md # 模板: Status/Date/Goal/Non-goals/Architecture/Implementation/Test Plan # 2. Claude Code Opus 审查 spec cat /home/ubuntu/docs/spec-b2-s2-s4-architecture.md | claude -p --model opus --permission-mode acceptEdits \ "You are a senior security architect. Review this spec. Output in Chinese. Format: 🔴 CRITICAL / 🟡 WARNING / 🟢 SUGGESTION Focus on: Attack surface, protocol correctness, failure modes, backward compat, key management" \ > /tmp/spec-review.md 2>&1 & # 3. 审查结果通常发现: # - 概念性错误(如 SameSite 是响应头不是入站属性) # - 端点缺鉴权 # - 威胁模型自相矛盾 # - 上线顺序/回滚方案缺失 # 4. 按反馈逐项修 Spec → 再次审查 → 确认无 🔴 后开始实现 ``` **优势**:Opus 4.8 发现概念性错误的能力远超代码审查——在设计阶段纠错成本最低。 **⚠️ 关键前置步骤**:送审前必须用 `search_files` 核实 spec 中所有 API 调用名与实际代码签名一致。Opus 发现的最高频错误就是臆造的 API 名。详见 pitfall #95 和 `references/hermes-desktop-vs-atomk-analysis.md`(spec v1→v2 完整修订实录)。 完整 spec v1→v2 示例见 `references/spec-b2-s2-s4-architecture-v2.md`(B2 JWT/S2 Bridge key/S4 CSRF 三方案,源文件 `/home/ubuntu/docs/spec-b2-s2-s4-architecture-v2.md`)。 **注意**: - `claude -p --model opus` 在 13KB+ 大 spec 上约需 90–120s 才有输出(pipe 模式无流式预览,全部缓冲到完成) - 用 `terminal(background=true, notify_on_complete=true)` 后台运行,避免阻塞当前 turn - 等待期间用 `process(action='poll')` **低频轮询**(每隔 10–15s),不要每秒 poll——用户会不耐烦 - `--model` 用 `opus`(不是 `opus-4.8`,Claude Code 别名映射会自动选择最新 opus) - 若 `notify_on_complete` 已收到通知但 `output_preview` 为空,用 `process(action='log')` 获取完整输出 完整的 spec 驱动开发流水线(SG7 已验证): ``` 1. 写 Spec v1 → /home/ubuntu/docs/spec-.md 2. GLM-5.1 审查 → Python 脚本直调 API(非 delegate_task) python3 /tmp/glm-review-sg7.py # 后台运行,spec 全文喂入 3. 读审查结果 → /tmp/glm-sg7-review.md 4. 修 Spec v2 → 逐项修复 P0/P1,patch spec 文件 5. 自审脚本 → Python 自动化检查所有修复项是否落地 6. 实现代码 → 迁移 + 模型 + 端点 + 路由注册 → push ``` ### GLM-5.1 审查 — 直接用 API(推荐,优于 delegate_task) `delegate_task` 在长输出场景下经常截断(只返回一句话)。对于 spec/code review,写入 Python 脚本直调 Coding Plan API: ```python # 脚本模板:/tmp/glm-review-*.py API_KEY = os.environ.get('GLM_CODING_API_KEY', '') # ... (从 ~/.hermes/config.yaml fallback) body = json.dumps({"model": "glm-5.1", "messages": [...], "max_tokens": 4000, "temperature": 0.3}) # ... ``` **大文件分批策略(关键)**:GLM-5.1 是思考模型,`max_tokens` 需 ≥4000(推理占用大量 token)。 大文件(>3000行)一次性发送会超时(180s+无响应)。策略: - 单文件 ≤1500 行:直接审计,120s 超时 - 单文件 1500-3000 行:截取前 12000 字符 - 多文件总量 >6000 行:按文件大小拆成 2-3 批并行发送,每批 `notify_on_complete=true` ``` **自审脚本模板**: ```python # 逐项检查 spec 中是否包含特定关键词 checks = { "P0-1: XX": [("条件描述", "关键词" in spec), ...], } # 输出 ✅/❌ 矩阵 ``` **关键点**: - GLM-5.1 API key 通过 heredoc 读取(避免 shell 明文) - `terminal(background=true, notify_on_complete=true)` 后台运行审查 - 审查结果存 `/tmp/glm-review-*.md`,后续可直接引用 - 所有 P0 必须修完才能开始编码 - 修完 spec 后跑自审脚本确认无遗漏 - ⚠️ GLM-5.1 是思考模型,`max_tokens < 4000` 会导致 reasoning 耗尽全部 token → `content=""`, `finish_reason="length"` - ⚠️ 大批量文件(6+文件/10K+行)单次请求可能超时 180s → 拆分为单文件或 ≤1500 行/批次,后台并行 - 完整审计实录见 `references/glm-audit-desktop-2026-06-24.md` ### 5e. Desktop 新增面板模式(SG7: Store + Posts 双 tab) ```typescript // 步骤 1: i18n keys(zh-CN + en) // src/shared/i18n/locales/zh-CN/navigation.ts store: "网店", // 步骤 2: Layout.tsx 改动(最小化) // - import { ShoppingBag } from "lucide-react" // - View type 加 "store" // - NAV_GROUPS[1] 加 { view: "store", icon: ShoppingBag, labelKey: "navigation.store" } // - render: visitedViews.has("store") && // 步骤 3: 新建 Store.tsx — 全状态矩阵 // loading → error → not-provisioned → provisioning → suspended → active+tabs // 每个 tab (products/orders/reports) 有独立空态+错误态 // 步骤 4: 增强 Posts.tsx — 双 tab 模式 // contentTab state + localStorage 持久化 // Tab bar: [社媒帖子 | 博客文章] // Blog tab: 独立状态矩阵 (loading/error/not-provisioned/provisioning/suspended/empty/list)"> // 步骤 5: proxyGet IPC(四件套模式) // main/atomlisting.ts → proxyGet(path) 实现 // main/index.ts → ipcMain.handle("atomlisting-proxy-get", ...) // preload/index.ts → ipcRenderer.invoke("atomlisting-proxy-get", path) // preload/index.d.ts → proxyGet: (path: string) => Promise ``` **常见坑**: - `window.hermesAPI.atomlisting.get()` 不存在 — 需加 `proxyGet` 通用方法 - 双 tab 要做 `localStorage` 持久化,否则刷新丢状态 - 新面板必须包裹 ``,否则 WC 代理失败会崩整个页面 --- ### 5c. Desktop 面板重组模式(含 SG7 Posts 双 tab + Store 新增) 当需要在 Desktop 面板间移动功能或新增面板时,遵循以下模式: ### 移动功能到其他面板(A2A Inbox: Tools → Mail) ```bash # 1. 目标面板 (Mail.tsx) 新增 imports patch: 在 lucide-react import 中加 Inbox/RefreshCw/Check/ChevronDown/ChevronRight # 2. 加 tab 类型 patch: tab type 从 "webmail" | "accounts" 扩展为 "webmail" | "accounts" | "inbox" # 3. 用 Python heredoc 插入 A2A state + functions + useEffect python3 << 'PYEOF' # 精确字符串替换插入完整代码块 content = open("Mail.tsx").read() # 在 marker 后插入 state content = before + marker + a2a_state + after # 在 marker 前插入 functions(在 Auth 之前) content = before + a2a_functions + marker + after # 在 Render 前插入 useEffect(必须在条件 return 之前!) content = before + a2a_effect + marker + after PYEOF # 4. 加 Inbox tab 按钮 + 内容 python3 << 'PYEOF' # 按钮追加到 Accounts 按钮后 content.replace(accounts_end, accounts_end + inbox_btn) # 内容插入到 Webmail Tab 之前 content.replace(webmail_marker, inbox_content + webmail_marker) PYEOF # 5. 源面板 (Tools.tsx) 清理 python3 << 'PYEOF' # 移除 A2A imports (ChevronDown/ChevronRight/Inbox/RefreshCw/Check/Trash2) # 移除 A2A state 块 # 移除 A2A functions (loadA2aInbox/handleA2aMarkRead/handleA2aDelete) # 移除 A2A useEffect # 移除 A2A JSX 段 PYEOF ``` ### 面板重命名(文件 + 组件 + import + i18n,v4.1.8 验证) 当需要重命名已有面板时(如 Store → Assignment),需改 4 层: ``` 步骤 1: 目录 + 文件重命名 mv src/renderer/src/screens/Store src/renderer/src/screens/Assignment mv Assignment/Store.tsx Assignment/Assignment.tsx 步骤 2: 组件内部改名 - function Store() → function Assignment() - export default Store → export default Assignment - 错误日志 tag: [Store] → [Assignment] - catch 块中的 console.error tag 同步更新 步骤 3: Layout.tsx import + JSX - import Store from "../Store/Store" → import Assignment from "../Assignment/Assignment" - 步骤 4: i18n keys(zh-CN + en) - navigation.ts: store → assignment ``` **验证**: `grep -rn 'Store\|store' src/renderer/src/screens/Layout/Layout.tsx` 应无匹配。 ### i18n 品牌名称统一(atomlisting.com → AtomK Server,v4.1.9 验证) Settings 页面及其他 UI 中的品牌名称需统一为 `AtomK Server`(替代 `atomlisting.com` / `Atomlisting.com`)。改 3 个 locale 文件 + Welcome 提示: ```bash # 受影响的 i18n keys(共 4 处/语言): # atomlistingSection: "AtomK Server" # 区块标题 # atomlistingHint: "Manage your AtomK Server..." # 描述 # v4bridgeHint: "...AtomK Server..." # Bridge 提示 # loginRequiredForBridge: "...AtomK Server..." # 登录提示 # 修改文件: src/shared/i18n/locales/en/settings.ts # English src/shared/i18n/locales/zh-CN/settings.ts # 中文 src/shared/i18n/locales/pt-BR/settings.ts # 葡萄牙语 src/shared/i18n/locales/en/welcome.ts # 登录页提示(en only) ``` **验证**:改完跑双 typecheck (`tsconfig.web.json` + `tsconfig.node.json`) 确认无 TS 错误。 ### i18n 品牌名称统一(atomlisting.com → AtomK Server,v4.1.9 验证) Settings 页面及其他 UI 中的品牌名称需统一为 `AtomK Server`。改 3 locale + welcome: | locale | 文件 | keys | |--------|------|------| | en | `settings.ts`, `welcome.ts` | `atomlistingSection`, `atomlistingHint`, `v4bridgeHint`, `loginRequiredForBridge`, `loginHint` | | zh-CN | `settings.ts` | 同 4 keys | | pt-BR | `settings.ts` | 同 4 keys | 将所有 `atomlisting.com` / `Atomlisting.com` 文本替换为 `AtomK Server`(保留 API URL placeholder 不动)。 ### 侧边栏菜单项合并到已有面板(sidebar → tab) 当需要减少侧边栏菜单项时,将独立的面板合并到逻辑相关的已有面板中作为 tab。 **示例**:Schedules 合并到 Browser Agent(PR @ `0378db5`) ``` 步骤 1: 目标面板 (BrowserAgent.tsx) 改动 - SubTab type 扩展: "agent" | "sessions" → "agent" | "sessions" | "schedules" - import Schedules from "../Schedules/Schedules" - import Timer icon from lucide-react - 复制 agent/sessions 的 tab 按钮模板,新增 schedules 按钮 - 新增 if (activeTab === "schedules") 返回块(含 tab bar + ) - 注意:三个 tab 块各自有完整的 sub-tab bar,重复但有独立 inactive/active 样式 步骤 2: Layout.tsx 清理 - 删除 import Schedules - 删除 View type 中的 "schedules" - 删除 NAV_GROUPS 中的 schedules 项 - 删除 visitedViews.has("schedules") 渲染块 - 删除 Timer 图标 import(如仅 schedules 使用) - ⚠️ 确认删除后 group items 数组不为空(否则只剩 `items: []`) 步骤 3: typecheck 验证 - npx tsc --noEmit -p tsconfig.web.json (renderer 侧) - npx tsc --noEmit -p tsconfig.node.json (main 侧) ``` ### 新增工具卡片到 Tools ```tsx // ... ### 轻量面板模式(proxyGet,无需 IPC 四件套) 当快速原型开发且 Server 端点可能变化时,跳过完整的 IPC 四件套 + atomlisting.ts 类型方法, 直接用 `proxyGet` 从 renderer 调用 atomlisting API: ```typescript // 直接调任意 Server 端点,无需新增 IPC handler const resp = await window.hermesAPI.atomListing.proxyGet( `/api/v1/products/remote/by-code/${encodeURIComponent(code)}` ); ``` **适用场景**: - 新面板快速原型,Server 端点可能变 - 简单 GET 查询/提交(query params 传参) - 不想为每个面板添加 4 个文件的 IPC 链路 **限制**:仅支持 GET 请求。需要 POST/PUT/DELETE 时仍需完整 IPC 模式。 完整示例见 `references/submit-panel-development.md`(Submit 页面,PR #23)。 // 1. Import 图标 import { Rocket, DollarSign, Search, Shield, ChevronDown, ChevronRight } from "lucide-react"; // 2. State: 当前展开的工具卡片 const [ecoTool, setEcoTool] = useState(null); // 3. 卡片数据(可放组件外避免重复创建) const ECO_TOOLS = [ { key: "listing", icon: Rocket, label: "智能刊登", desc: "1688→Ozon" }, // ... ]; // 4. 卡片 JSX(点击展开/收起) {ECO_TOOLS.map(tool => (
setEcoTool(isExpanded ? null : tool.key)}> {isExpanded ? : } {isExpanded &&
{tool.desc_detail}
}
))} ``` ### 5f. 新增 proxyGet/proxyPost IPC 四件套模式 通用 API 代理层,让任意 Desktop 面板直调 atomlisting Server API: ```bash # 四文件必须同步(漏一即崩): # ① atomlisting.ts — 实际 HTTP client 调用 + D1 白名单 # ② index.ts — ipcMain.handle() + normalizeProxyPath 防路径穿越 # ③ preload/index.ts — ipcRenderer.invoke() 暴露给 renderer # ④ preload/index.d.ts — TypeScript 类型声明 # proxyGet (GET): atomlisting.ts: async proxyGet(rawPath) → normalizeProxyPath → 白名单 check → createClient().get() index.ts: ipcMain.handle("atomlisting-proxy-get", ...) preload/index.ts: proxyGet: (path) => ipcRenderer.invoke("atomlisting-proxy-get", path) preload/index.d.ts: proxyGet: (path: string) => Promise # proxyPost (POST, v4.1.7+): atomlisting.ts: async proxyPost(rawPath, body) → normalizeProxyPath → 白名单 check → createClient().post() index.ts: ipcMain.handle("atomlisting-proxy-post", ...) preload/index.ts: proxyPost: (path, body) => ipcRenderer.invoke("atomlisting-proxy-post", path, body) preload/index.d.ts: proxyPost: (path: string, body: unknown) => Promise # proxyPatch (PATCH, v4.1.8+): atomlisting.ts: async proxyPatch(rawPath, body) → normalizeProxyPath → 白名单 check → createClient().patch() index.ts: ipcMain.handle("atomlisting-proxy-patch", ...) preload/index.ts: proxyPatch: (path, body) => ipcRenderer.invoke("atomlisting-proxy-patch", path, body) preload/index.d.ts: proxyPatch: (path: string, body: unknown) => Promise # proxyDelete (DELETE, v4.1.8+): atomlisting.ts: async proxyDelete(rawPath) → normalizeProxyPath → 白名单 check → createClient().delete() index.ts: ipcMain.handle("atomlisting-proxy-delete", ...) preload/index.ts: proxyDelete: (path) => ipcRenderer.invoke("atomlisting-proxy-delete", path) preload/index.d.ts: proxyDelete: (path: string) => Promise ``` **双白名单守卫(D1 双层防护)**:`index.ts` 的 `PROXY_ALLOWED_PREFIXES` 和 `atomlisting.ts` 的 `PROXY_ALLOWED_PREFIXES` 必须同步更新。漏了任一处 → 请求被拦截。 **新增 proxy 方法时**:atomlisting.ts 中每个 proxy 方法(proxyGet/poxyPost/poxyPatch/proxyDelete)有**独立的**白名单数组,新增路径必须全部更新(当前 4 个方法 × 各 1 处 = index.ts 1 处 + atomlisting.ts 4 处 = 共 5 处)。 **renderer 用法**: ```typescript // GET const data = await window.hermesAPI.atomListing.proxyGet('/api/v1/products/remote?limit=10'); // POST await window.hermesAPI.atomListing.proxyPost('/api/v1/products/remote/claim', { code: '82AB6133' }); // PATCH await window.hermesAPI.atomListing.proxyPatch('/api/v1/premium-products/by-code/ABC123', { name: 'Updated' }); // DELETE await window.hermesAPI.atomListing.proxyDelete('/api/v1/premium-products/by-code/ABC123'); ``` 但 index.ts 中所有方法共用同一个数组。新增路径时必须 5 处同步(index.ts ×1 + atomlisting.ts ×4)。 **双白名单守卫(D1 双层防护)**:`index.ts` 的 `PROXY_ALLOWED_PREFIXES` 和 `atomlisting.ts` 的 `PROXY_ALLOWED_PREFIXES` 必须同步更新。漏了任一处 → 请求被拦截。 ⚠️ atomlisting.ts 中有 4 个独立白名单数组(proxyGet/proxyPost/proxyPatch/proxyDelete),全部需要同步。 **renderer 用法**: ```typescript // GET — list/search const data = await window.hermesAPI.atomListing.proxyGet('/api/v1/premium-products?search=xxx&skip=0&limit=15'); // POST — create await window.hermesAPI.atomListing.proxyPost('/api/v1/premium-products', { name: '...', reference_urls: [...] }); // PATCH — update await window.hermesAPI.atomListing.proxyPatch('/api/v1/premium-products/by-code/AB123456', { name: '...' }); // DELETE — soft-delete await window.hermesAPI.atomListing.proxyDelete('/api/v1/premium-products/by-code/AB123456'); ``` **常见坑**: - **Python heredoc 优于 patch 工具**:TSX 文件大且含复杂字符串时,`patch` 工具频繁 escape-drift。用 `python3 << 'PYEOF'` heredoc + 精确字符串替换更可靠。 - **🔴 `proxyPost` 四文件必须同步**:与 `proxyGet` 同模四件套,atomlisting.ts + index.ts + preload/index.ts + preload/index.d.ts 缺一即 TS2339。D1 白名单要双端一致。 - **🔴 Server product_code 格式**:Server 端生成 `string.ascii_uppercase + string.digits` 随机 8 位字母数字(如 `82AB6133`)。Desktop 用 `toDisplayCode()` 提取纯 8 位码显示。MongoDB 统一格式,不存 SUBMIT- 前缀。 - **🔴 批量提交上限**:Desktop IPC handler + Server API 双重限制,默认 50 条。改一处必须改另一处。 - **useEffect 必须在条件 return 之前**:React rules-of-hooks 违规 → 运行时崩溃。移到所有条件 return 之前。 - **R1 修复可能引入新 bug**:`patch` 工具批量修改 imports 时容易产生重复/错误 import。每次修改后验证 imports 行。 - **ECO_TOOLS 去重**:向组件外移动时可能产生两份定义,用 `grep -c` 确认。 ## 5b. 本地仓库路径 | 项目 | 路径 | SG5 Repo | 说明 | |------|------|------|------| | Desktop | `/home/ubuntu/AtomK-Desktop` | `9webs/AtomK-Desktop` | Electron 桌面端 | | Bridge | `/home/ubuntu/AtomK_Bridge` | `9webs/AtomK_Bridge` | Cloud Bridge 服务端 (曾用名 atomk-page-bridge → AtomK-Cloud-Bridge) | | Server | `/home/ubuntu/AtomK_Server` | `9webs/Atomlisting_Server` | 后端 API | > 旧 Gitea `gitea9webs.sh3.ikuai7.com` 已下线。远程操作全部走 `frp.9webs.online:3000`。 > TAT 远程命令执行(无需 SSH 读生产配置/调试 MongoDB):见 `references/tat-remote-execution.md` > 所有仓库预配 `sg5` remote。迁移细节见 `references/sg5-gitea-migration.md`。 ## 6. 关键端口与版本 | 项目 | 值 | 说明 | |------|-----|------| | CDP_PORT | **9322** | chrome-bridge.ts line 373(从 9222 迁移,避免紫鸟冲突) | | Electron | ^39.2.6 | Chromium 142.0.7444.226 | | playwright-core | ~1.56.0 | 匹配 Chromium 142 | | 紫鸟 WebDriver 默认端口 | 9222 | 与 Desktop 9322 不冲突 | --- ## 6. 新模块架构(v4.0.0) ### playwright-controller.ts - `BrowserContext` 隔离:`Map` 区分自助(`self`)/紫鸟(`ziniao`)通道 - 通道互斥锁:异步排队 Mutex(Promise 链式等待),不再同步 throw - 操作队列:同 slot 串行化,`prev.then(fn, fn)` 模式(失败不阻塞后续) - `screenshot()` 支持 `useZiniao`/`ziniaoPort` 参数 - `SELF_CDP_PORT = 9322` 常量(替代硬编码) - `selfConnect()` 有友好错误(Chromium not running) - `ziniaoConnect()` 重连前清理 stale slotPages + `disconnected` 清理 - `execute()` 加 `source: 'local' | 'bridge'` 守卫,Bridge 来源拒绝 ### ziniao-client.ts - `request()` 有 AbortController 15s 超时 - `findStore()` 精确匹配 + 前缀唯一匹配 - `startBrowserWithTTL` 返回 `{result, cancelTtl}` - `markStoreActive`/`markStoreInactive` + `startOrphanSweep()` ### hubstudio-client.ts (v4.0.2+) ### hubstudio-cdp-controller.ts (v4.0.x NEW) - HubStudio 独立 Chrome CDP 中继控制器 - Playwright `connectOverCDP(port)` 连接 HubStudio Chrome(动态端口,如 58289) - 每个环境独立 `BrowserContext`,支持多环境并行 - 7 个方法: `connect/disconnect/navigate/evaluate/click/screenshot/snapshot` - `hubstudio.open_env` 成功后自动连接 CDP;`hubstudio.close_env` 自动断开 ### hubstudio-client.ts (v4.0.2+) - Hubstudio 浏览器 Local API 客户端(`http://127.0.0.1:6873`) - 认证:`Authorization: Bearer *** + Accept-Language: zh-CN` - 完整方法:`listEnvs()` / `findEnv()` / `startBrowser()` / `stopBrowser()` / `getOpenedEnvs()` / `browserStatus()` - `startBrowserWithTTL()` 超时自动关闭、`markEnvActive`/`markEnvInactive` + `startOrphanSweep()` - 凭证存储在 `~/.atomk/hubstudio.json`(safeStorage 加密) - Desktop UI 入口:Chrome Bridge 页面 → 「紫鸟 & Hubstudio API」配置区 ### bridge-message-router.ts - 路由 `playwright.*` (7 methods) + `ziniao.*` (5 methods) + `hubstudio.*` (5 methods, v4.0.2+) - `playwright.execute` **不在路由中**(安全) - `ziniaoGuard()` + `validateCoreVersion()` 前置守卫 - `HubstudioClient` 集成:`hubstudio.status` / `list_envs` / `open_env` / `close_env` / `opened_envs` --- --- ## X. Desktop UI 功能速查 ### 紫鸟 & Hubstudio API 凭证输入 | 项目 | 说明 | |------|------| | **位置** | **Chrome Bridge 页面** → 底部「紫鸟 & Hubstudio API」配置区 | | **紫鸟字段** | API Key + Base URL(默认 `http://127.0.0.1:19481`) | | **Hubstudio 字段** | App ID + App Secret + Base URL(默认 `http://127.0.0.1:6873`) | | **存储** | `~/.atomk/ziniao.json` / `~/.atomk/hubstudio.json`(safeStorage 加密) | | **后端** | `src/main/ziniao-client.ts` / `src/main/hubstudio-client.ts` | | **IPC** | `ziniao:*` / `hubstudio:*` channels | ### Products 面板(premiumproducts CRUD,v4.1.8+ 重写) 走 AtomK Server API (`/api/v1/premium-products`) 对 MongoDB premiumproducts 做全 CRUD。 - 列表:`proxyGet` 带 search/status/skip/limit 参数,分页展示 - 创建:`proxyPost`,编辑:`proxyPatch`,删除:`proxyDelete`(软删除 → archived) - 列表行展示 product_code、SKU、tags、categories、创建日期 - 创建/编辑用 ``,删除用 `` - auth gate:`authChecked` + `authReady` 双状态 - Server 端点:`backend/app/api/v1/premium_products.py`(JWT auth) - 旧版 Products(Remote Pool + Claimed 双 tab)已被完全替代 常见坑: - 新增 proxy 方法(patch/delete)必须走完整四件套,白名单 5 处同步 - `PremiumProductCreate` 统一用于创建和编辑(edit 时额外传 status) - Server PATCH 只更新非 None 字段,delete 是软删除 ### 检查 Desktop 更新 | 项目 | 说明 | |------|------| | **位置** | **Settings 页面** → Hermes 版本区 →「检查Desktop更新」按钮(运行诊断 旁边) | | **原理** | electron-updater 读 COS `tools/update/latest.yml`,对比版本号 | | **有新版本** | 自动下载,应用重启后安装 | | **无更新** | 显示「已是最新版本 ✅」 | | **IPC** | `check-for-updates` → `autoUpdater.checkForUpdates()` | ### Cloud Bridge prompt(hermes.ts 内置提示词) | 项目 | 说明 | |------|------| | **文件** | `src/main/hermes.ts` → `buildCloudBridgePrompt()` | | **内容** | v4.0.8 起精简为上下文触发消息(英文 ~1.2KB):告知 agent 这是 Cloud Bridge 会话、Desktop ID、当前用户,并指引加载 `bridge-cdp-agent` skill 获取完整 CDP 指令 | | **旧版** | v4.0.7 之前为中文全文 ~4KB,含 CDP 操作流程、平台特殊处理(已移除)、通用模板 | | **维护** | 平台特定操作指南放在对应 skill 中(ozon-operations、miaoshou-erp 等),无需改 hermes.ts。改 skill 即时生效,无需 rebuild Desktop | | **注意** | ⚠️ `quickPrompts.ts` 也有硬编码账号信息,详见 pitfall #60 | ### LLM 模型选择器(已移除 v4.0.2+) | 项目 | 说明 | |------|------| | **原位置** | **Chat 页面底部** — `src/renderer/src/screens/Chat/Chat.tsx` L238-246 | | **组件** | `` — 下拉框选择模型(Sonnet 4.6 等) | | **关联 hook** | `useModelConfig()` — `src/renderer/src/screens/Chat/hooks/useModelConfig.ts` | | **状态** | **已移除**(PR #43)。如用户要求恢复,恢复 import + hook 调用 + JSX 三处 | ## 常见坑 1. **`getPageForSlot` channel 参数**:传 `'self'` 或 `'ziniao'`,key 格式 `${slotId}:${channel}` 2. **版本比较降级 Bug**:`check-for-updates` 中不能用 `latest !== current` 字符串不等比较。如果 latest.yml 写入旧版本,equality check 会触发降级提示。必须用 `compareSemverLike(latest, current) > 0` 只通知升级。 3. **useCallback 闭包陈旧**:`useEffect([], [])` 里注册 DOM 事件若调用非 memoized 函数,拖拽等操作会使用挂载时的旧闭包。将 handler 包 `useCallback`,effect deps 设为 `[handler]`。 2. **`$$` shell 变量展开**:`--key Bing2026Cao$$$` 中的 `$$` 会被 bash 展开为当前 PID,导致 key 错误。 2. **`ziniaoConnect` 重连前清理 slotContexts**:关闭旧 browser 后所有 Context/Page 失效 3. **`cancelTtl` 变量名**:用 `cancelTtl` 而非 `clearTimeout`(不遮蔽全局函数) 4. **`params.url` 必须非空 + 协议校验**:仅允许 `http://` 或 `https://` 97. **🔴 Renderer 组件中外部 URL 必须做协议白名单校验**:任何从后端数据(remote products、scraped data)渲染到 ``、`cdpNavigate()`、`window.open()` 的 URL,必须在 renderer 侧做协议校验。典型漏洞: - `` + `target="_blank"` — 中键点击不触发 onClick/preventDefault,直接跟随 href。若 source_url 含 `javascript:` → 代码执行 - `cdpNavigate(product.source_url!)` — URL 直传 CDP `Page.navigate`,`file:///etc/passwd` 可读本地文件 **修复模板**: ```typescript const ALLOWED_PROTOCOLS = ['http:', 'https:']; function safeUrl(url: string | null | undefined): string { if (!url) return '#'; try { const u = new URL(url); return ALLOWED_PROTOCOLS.includes(u.protocol) ? url : '#'; } catch { return '#'; } } // 然后:href={safeUrl(product.source_url)} // 或用