From bd0873f34823754760368d8d37703c74bf65106d Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 4 Sep 2026 12:01:42 +0800 Subject: [PATCH] feat: remove Learning module --- AGENTS.md | 2 +- README.md | 9 +- ...earning-project-catalog-server-contract.md | 136 ------ electron/api/route-handlers.ts | 2 - electron/api/routes/learning.ts | 444 ------------------ electron/main/background-lifecycle.ts | 2 +- electron/main/index.ts | 2 +- .../services/learning-project-download.ts | 221 --------- shared/learning.ts | 45 -- shared/module-access.ts | 3 - src/App.tsx | 5 - src/assets/module-learning.webp | Bin 45392 -> 0 bytes src/components/layout/LearningSidebar.tsx | 37 -- src/components/layout/Sidebar.tsx | 4 - src/lib/ai-modules.ts | 17 +- src/lib/host-api.ts | 2 +- src/lib/learning.ts | 156 ------ src/pages/Learning/ProjectDetail.tsx | 165 ------- src/pages/Learning/ProjectImage.tsx | 68 --- src/pages/Learning/index.tsx | 149 ------ src/pages/ModuleSelection/index.tsx | 2 - src/stores/auth.ts | 2 +- tests/e2e/image-workspace-v2.spec.ts | 4 +- tests/e2e/main-navigation.spec.ts | 23 +- tests/e2e/plugin-marketplace.spec.ts | 4 +- tests/unit/app-module-provider-gate.test.tsx | 18 +- tests/unit/auth-routes.test.ts | 3 +- tests/unit/auth-store.test.ts | 15 +- tests/unit/background-lifecycle.test.ts | 4 +- tests/unit/host-api-dispatcher.test.ts | 15 + tests/unit/learning-client.test.ts | 89 ---- tests/unit/learning-page.test.tsx | 120 ----- tests/unit/learning-project-download.test.ts | 150 ------ tests/unit/learning-project-image.test.tsx | 30 -- tests/unit/learning-route.test.ts | 303 ------------ tests/unit/learning-sidebar.test.tsx | 33 -- tests/unit/main-layout-module-gate.test.tsx | 22 +- tests/unit/module-access.test.ts | 33 ++ tests/unit/module-navigation.test.tsx | 43 +- 39 files changed, 104 insertions(+), 2278 deletions(-) delete mode 100644 docs/learning-project-catalog-server-contract.md delete mode 100644 electron/api/routes/learning.ts delete mode 100644 electron/services/learning-project-download.ts delete mode 100644 shared/learning.ts delete mode 100644 src/assets/module-learning.webp delete mode 100644 src/components/layout/LearningSidebar.tsx delete mode 100644 src/lib/learning.ts delete mode 100644 src/pages/Learning/ProjectDetail.tsx delete mode 100644 src/pages/Learning/ProjectImage.tsx delete mode 100644 src/pages/Learning/index.tsx delete mode 100644 tests/unit/learning-client.test.ts delete mode 100644 tests/unit/learning-page.test.tsx delete mode 100644 tests/unit/learning-project-download.test.ts delete mode 100644 tests/unit/learning-project-image.test.tsx delete mode 100644 tests/unit/learning-route.test.ts delete mode 100644 tests/unit/learning-sidebar.test.tsx create mode 100644 tests/unit/module-access.test.ts diff --git a/AGENTS.md b/AGENTS.md index d2d2fae..29cb3c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Product boundary -Makelore is a cross-platform Electron application with four enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, `Makelore Learning|AI 学习`, and `Makelore Robot|AI 机器`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. +Makelore is a cross-platform Electron application with three enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, and `Makelore Robot|AI 机器`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. Do not restore Works gallery, asset gallery, publish/upload, or cloud-deploy workbench pages. `/deliverables` is a project output preview and remains supported. diff --git a/README.md b/README.md index 533f85f..299500d 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,13 @@ **一念成光,万物可创。** -Makelore 是一个面向软件、视觉创作、互动学习与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含四个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。用户通过对话或直接编辑持续完善同一设计方向,再生成图片、单参考图作品或视频;参考素材可从当前项目作品选择或从本地上传。Canvas 侧栏提供“获取灵感”,进入服务端驱动的提示词博物馆。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 -- `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 README,并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 @@ -29,7 +28,6 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 -- AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 - 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。 - 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。 @@ -75,7 +73,7 @@ pnpm run package:win pnpm run package:linux ``` -各平台打包脚本会先准备目标架构所需的 Pi、Python 与 uv 运行时资源,产物写入忽略的 `release/` 目录。AI 学习不再携带独立播放器产物。正式包中的 Pi、Python 和 uv 均从安装目录解析;Agent Server 以已 staged 的 `resources/pi-runtime/package.json` 为解析锚点,并校验目标包入口仍位于该包内部。缺少本地资源或合法入口时启动和产物验证会直接失败,不会回退到应用 `node_modules`、系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 Pi 运行时。macOS、Windows 与 Linux 的各架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 +各平台打包脚本会先准备目标架构所需的 Pi、Python 与 uv 运行时资源,产物写入忽略的 `release/` 目录。正式包中的 Pi、Python 和 uv 均从安装目录解析;Agent Server 以已 staged 的 `resources/pi-runtime/package.json` 为解析锚点,并校验目标包入口仍位于该包内部。缺少本地资源或合法入口时启动和产物验证会直接失败,不会回退到应用 `node_modules`、系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 Pi 运行时。macOS、Windows 与 Linux 的各架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:pi:real` 和 `pnpm run perf:pi:release`。这里的 `real` 表示从最终产品可执行文件启动最终 `resources/pi-runtime`,并使用受控的 Provider-shaped 回环服务验证会话、工具、中止、结算、重开、并发隔离、子 Agent 与退出;它不表示真实外部 Provider 已验证。各目标平台、证据字段、兼容边界与整版本回滚步骤见 [`docs/pi-runtime-release-runbook.md`](docs/pi-runtime-release-runbook.md)。 @@ -100,7 +98,6 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - AI 编程项目配置只以项目内 `.makelore/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。Main 不探测、读取或迁移 `.niancode` 与 `.opencode` 项目数据。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Workspace 所属的持久 Agent Session、稳定命令身份、有界 Run 查询、可恢复事件订阅与契约映射,并通过本机 Host API 的 SSE 投影同步表单、任务和资产状态。切换 Workspace 只重连对应流;注销或退出时关闭本地流,不删除服务端持久 Session。远端 Token、Provider Prompt 与存储地址不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 -- AI 学习只通过 Main-owned Host API 获取项目列表、README 详情和封面/历史媒体路径;Renderer 不持有 Works Token、对象存储地址、任意归档 URL 或本地文件路径。项目 ZIP 只允许同 Works origin 最多五跳重定向,重定向请求不携带 Bearer;下载结果仅向 Renderer 返回 `saved` 或 `cancelled`。README 不执行原始 HTML,仅 Markdown 图片节点可直接加载服务端校验后的无凭据 HTTPS URL。 - Prompt Museum 使用独立的 Main-owned Host API 代理;Renderer 只接收分页卡片、详情和服务端返回的图片地址,Works Square Token 只由 Main 持有。发布记录必须由服务端完成作者、来源、许可证和素材授权审核,模块不提供投稿、点赞、评论或排行榜。 - AI 编程的智能体配置属于当前项目;稳定 id 用于保持对话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 diff --git a/docs/learning-project-catalog-server-contract.md b/docs/learning-project-catalog-server-contract.md deleted file mode 100644 index 68a5a8d..0000000 --- a/docs/learning-project-catalog-server-contract.md +++ /dev/null @@ -1,136 +0,0 @@ -# AI 学习项目目录服务端协作契约 - -> 状态:Makelore 客户端与 Electron Main 已按本契约实现;运营后台、数据库、对象存储与生产数据不在本仓库内,需在 Works Square 侧实现并部署后联调。 - -## 1. 产品与权限边界 - -AI 学习是登录后可用的精选项目目录。V1 只包含项目列表、README 详情和 ZIP 下载,不包含课程生成、课程播放器、本地课程库、学习进度、Agent、ASR、评分或课堂 runtime,也不兼容旧课程接口。 - -沿用现有账号与 `module_access.learning` 策略:客户端入口策略在页面初始化前拦截无权限账号,Works Square 的列表、详情、媒体和下载接口仍必须独立校验登录态及 Learning 权限,不能依赖客户端置灰。 - -Renderer 的固定链路为 `Renderer -> Host API -> Electron Main -> Works Square`。Renderer 不得获得 Works Token、对象存储凭据、内部存储 key、任意下载 URL或本地保存路径。 - -## 2. 运营后台“学习项目管理” - -运营后台新增一级菜单“学习项目管理”,至少支持新建、编辑、预览、发布、下架、排序和查看发布记录。项目字段如下: - -| 字段 | 规则 | -| --- | --- | -| 项目名称 `name` | 必填,1–200 字符 | -| 摘要 `summary` | 必填,1–2,000 字符,用于卡片 | -| 封面 `cover` | 必填,PNG/JPEG/WebP,最大 10 MiB;服务端读取真实 MIME、尺寸并生成受控媒体 | -| 项目压缩包 `archive` | 必填 ZIP;运营上传可保留独立的 512 MiB 服务端限制,服务端流式记录字节数、计算 SHA-256 并验证 ZIP 签名;该上传限制不由客户端在下载时执行 | -| README `readme` | 必填 `.md`,UTF-8,最大 500 KiB;不接受可执行 HTML 作为发布内容 | -| 标签 `tags` | 0–16 个,每项 1–64 字符,去重 | -| 版本 `version` | 可空,最大 64 字符,仅作展示 | -| 排序 `sort_order` | 有界整数;列表默认按运营排序,再按发布时间稳定排序 | -| 状态 `status` | `draft`、`published`、`archived` | - -上传中的对象不能直接进入公开目录。发布必须在一个事务/发布代际中冻结元数据、经过净化和 URL 校验的 README、封面及 ZIP 摘要;任一校验失败则整个发布失败,旧的已发布版本继续可读。下架后列表和详情立即不可见,但已有审计记录不能物理删除。 - -后台必须记录操作人、时间、发布代际、变更摘要、归档 SHA-256/字节数、README 警告和远程图片 URL 数量。客户端不提供任何运营上传或发布入口。 - -## 3. README 远程图片发布规则 - -README 中的远程图片在“发布”时由服务端解析 Markdown,并保留通过校验的原始 HTTPS URL;服务端不下载或处理图片字节。处理要求: - -1. 只接受无用户名/密码的 HTTPS URL;拒绝 `http:`、`data:`、`file:`、本地路径和协议相对地址。 -2. 只允许默认 HTTPS 端口且不允许 fragment;解析当前 DNS,任一结果属于 loopback、私网、链路本地、保留地址、云元数据或其他非公网地址时拒绝发布。 -3. 限制 README 图片总量;不请求远端响应,因此不校验重定向、响应大小、MIME、像素、实际格式或内容。SVG 及其他 Electron 可渲染格式可直接显示。 -4. 发布后的 Markdown 保留通过校验的 URL,不创建 README 图片 blob 或新 release-media 行;现有媒体路由继续用于封面和历史已镜像发布。 -5. Markdown 原始 HTML 在客户端被禁用;服务端也从发布内容中移除 HTML 并返回 warning,避免运营误判展示效果。 -6. 客户端请求会直接到第三方图片 origin;图片可用性、后续 DNS/重定向和格式支持由 origin 与 Electron 决定,origin 也会看到请求方网络信息。单图加载失败不得阻断 README 其余内容。 - -封面同样优先返回固定媒体路径。若返回 HTTPS CDN 地址,该地址必须无凭据、由 Works Square 控制且不包含用户隐私。 - -## 4. 客户端公开接口 - -| 方法 | Works Square 路径 | 用途 | -| --- | --- | --- | -| `GET` | `/api/learning/projects?cursor=&limit=` | 已发布项目分页列表 | -| `GET` | `/api/learning/projects/:id` | 项目详情和 README | -| `GET` | `/api/learning/projects/:id/media/:mediaId` | 受控封面/README raster 图片 | -| `GET` | `/api/learning/projects/:id/archive` | ZIP 字节流或同 Works origin 重定向 | - -`limit` 为 1–48,默认 24;`cursor` 是不透明游标。空列表返回 `200`,不要用 `404`。列表不返回 `readmeMarkdown`、`archiveSha256`、`archiveFileName` 或任何存储字段。 - -### 列表响应 - -```json -{ - "success": true, - "data": { - "items": [ - { - "id": "robot-arm", - "name": "桌面机械臂", - "summary": "从零搭建一个可以抓取积木的桌面机械臂。", - "cover": { - "url": "/api/learning/projects/robot-arm/media/cover", - "alt": "桌面机械臂成品", - "width": 1600, - "height": 900 - }, - "tags": ["机器人", "Python"], - "version": "1.2.0", - "archiveBytes": 12582912, - "publishedAt": "2026-08-01T00:00:00Z", - "updatedAt": "2026-08-18T00:00:00Z" - } - ], - "nextCursor": null, - "total": 1 - } -} -``` - -### 详情响应 - -详情复用全部列表字段,并增加: - -```json -{ - "success": true, - "data": { - "id": "robot-arm", - "name": "桌面机械臂", - "summary": "从零搭建一个可以抓取积木的桌面机械臂。", - "cover": { - "url": "/api/learning/projects/robot-arm/media/cover", - "alt": "桌面机械臂成品", - "width": 1600, - "height": 900 - }, - "tags": ["机器人", "Python"], - "version": "1.2.0", - "archiveBytes": 12582912, - "publishedAt": "2026-08-01T00:00:00Z", - "updatedAt": "2026-08-18T00:00:00Z", - "readmeMarkdown": "# 桌面机械臂\n\n![接线图](https://docs.example.com/wiring.svg)", - "archiveFileName": "makelore-robot-arm-1.2.0.zip", - "archiveSha256": "64位小写十六进制SHA-256" - } -} -``` - -TypeScript 权威字段定义位于 [`shared/learning.ts`](../shared/learning.ts)。未列出的内部字段会被 Main 丢弃。 - -### 媒体与归档响应 - -- 媒体接口只用于封面和历史已镜像内容,返回受控 raster 内容并设置准确 `Content-Type` 与 `Content-Length`;Main 限制 10 MiB,并转换为 data URL 给 Renderer。新发布 README 的 HTTPS 图片不经过该接口。 -- 归档接口返回 `application/zip`、`application/x-zip-compressed` 或 `application/octet-stream`;`Content-Length` 可省略,客户端也不使用它决定是否保存。如需重定向,只能跳转到与 Works API 相同 origin 的 HTTP(S) 地址,最多 5 跳;Main 不向重定向目标转发 Bearer。 -- `archiveBytes` 是列表、详情和运营审计使用的展示元数据,不是客户端下载门禁。客户端不比较它、`Content-Length` 与实际流字节数,也不设置归档大小上限;客户端仍要求实际归档匹配 `archiveSha256` 和 ZIP 签名,验证失败时删除临时文件,不留下部分下载。 - -## 5. 错误、缓存与上线顺序 - -统一使用现有 `{ success, status, code, error }` 错误封装。至少支持: - -- `LEARNING_AUTH_REQUIRED`(401); -- `LEARNING_FORBIDDEN`(403); -- `LEARNING_PROJECT_NOT_FOUND`(404); -- `LEARNING_CONFLICT`(409,发布代际变化); -- `LEARNING_UNAVAILABLE`(429/502/503)。 - -列表/详情可对发布代际生成 ETag;媒体和归档按内容摘要设置不可变缓存,但不得缓存带用户私有授权的响应到公共共享缓存。日志不得记录 Bearer、签名 URL、完整 README 图片 URL 或对象存储 key;可记录规范化主机、URL 摘要和图片数量。 - -上线顺序:先部署数据库/对象存储、运营后台、README HTTPS URL 校验和四个公开接口,再发布包含直连图片与无下载大小校验的新客户端;随后用真实账号完成发布/下架/远程图片(含 SVG、失效 origin 和隐私提示)/ZIP 联调,并覆盖缺失或不准确 `Content-Length` 的归档响应。旧 `/api/learning/courses`、generation/progress/runtime 接口不在新客户端兼容范围内,可按服务端消费者盘点结果独立退役。 diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 4168cbb..6bd875d 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -6,7 +6,6 @@ import { handleAppRoutes } from './routes/app'; import { handleAuthRoutes } from './routes/auth'; import { handleImageWorkspaceRoutes } from './routes/image-workspace'; import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum'; -import { handleLearningRoutes } from './routes/learning'; import { handleDataServiceRoutes } from './routes/data-service'; import { handleWorksRoutes } from './routes/works'; import { handleUserSyncRoutes } from './routes/user-sync'; @@ -44,7 +43,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleAuthRoutes, handleImageWorkspaceRoutes, handleImagePromptMuseumRoutes, - handleLearningRoutes, handleDataServiceRoutes, handleWorksRoutes, handleAgentBrowserRoutes, diff --git a/electron/api/routes/learning.ts b/electron/api/routes/learning.ts deleted file mode 100644 index c2bcece..0000000 --- a/electron/api/routes/learning.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { app, dialog, type SaveDialogOptions } from 'electron'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { join } from 'node:path'; -import { - LEARNING_MEDIA_MAX_BYTES, - type LearningProjectDetail, -} from '../../../shared/learning'; -import { - LearningProjectDownloadError, - safeLearningProjectArchiveFileName, - saveLearningProjectArchive, -} from '../../services/learning-project-download'; -import { - getValidWorksSquareAccessToken, - getWorksSquareAccountBinding, - isCurrentWorksSquareAccountBinding, - type WorksSquareAccountBinding, -} from '../../services/works-square-session'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import type { HostApiContext } from '../context'; -import { sendJson } from '../route-utils'; -import { WORKS_SQUARE_CONFIG } from '../works-config'; - -const LOCAL_ROOT = '/api/works/learning/projects'; -const UPSTREAM_ROOT = '/api/learning/projects'; -const PROJECT_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; -const MEDIA_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; -const PROJECT_ID_PATTERN = new RegExp(`^${PROJECT_ID}$`); -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const MEDIA_URL_PATTERN = new RegExp(`^/api/learning/projects/${PROJECT_ID}/media/${MEDIA_ID}$`); -const LOCAL_MEDIA_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/media/(${MEDIA_ID})$`); -const LOCAL_DOWNLOAD_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/download$`); -const LOCAL_DETAIL_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})$`); -const MAX_LIST_ITEMS = 48; -const MAX_TAGS = 16; -const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; -const TRUSTED_MEDIA_MIME_TYPES = new Set([ - 'image/avif', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/webp', -]); - -type LearningRoute = - | { kind: 'list'; upstreamPath: string } - | { kind: 'detail'; upstreamPath: string; projectId: string } - | { kind: 'media'; upstreamPath: string; projectId: string } - | { kind: 'download'; upstreamPath: string; projectId: string }; - -type Dependencies = { - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - getAccountBinding?: () => WorksSquareAccountBinding | null; - isCurrentAccountBinding?: (value: WorksSquareAccountBinding) => boolean; - apiBaseUrl?: string; - chooseDestination?: (ctx: HostApiContext, fileName: string) => Promise; - saveArchive?: typeof saveLearningProjectArchive; -}; - -class InvalidLearningDataError extends Error {} - -function matchRoute(pathname: string): LearningRoute | null { - if (pathname === LOCAL_ROOT) return { kind: 'list', upstreamPath: UPSTREAM_ROOT }; - const media = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); - if (media) { - return { - kind: 'media', - projectId: media[1], - upstreamPath: `${UPSTREAM_ROOT}/${media[1]}/media/${media[2]}`, - }; - } - const download = LOCAL_DOWNLOAD_PATH_PATTERN.exec(pathname); - if (download) { - return { - kind: 'download', - projectId: download[1], - upstreamPath: `${UPSTREAM_ROOT}/${download[1]}`, - }; - } - const detail = LOCAL_DETAIL_PATH_PATTERN.exec(pathname); - return detail - ? { kind: 'detail', projectId: detail[1], upstreamPath: `${UPSTREAM_ROOT}/${detail[1]}` } - : null; -} - -function listQuery(url: URL): string { - const query = new URLSearchParams(); - const cursor = url.searchParams.get('cursor')?.trim(); - if (cursor && cursor.length <= 1024) query.set('cursor', cursor); - const limit = url.searchParams.get('limit')?.trim(); - if (limit && /^\d{1,2}$/.test(limit) && Number(limit) >= 1 && Number(limit) <= MAX_LIST_ITEMS) { - query.set('limit', limit); - } - const encoded = query.toString(); - return encoded ? `?${encoded}` : ''; -} - -function asRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError(); - return value as Record; -} - -function boundedString(value: unknown, maximum: number): string { - if (typeof value !== 'string') throw new InvalidLearningDataError(); - const normalized = value.trim(); - if (!normalized || normalized.length > maximum) throw new InvalidLearningDataError(); - return normalized; -} - -function nullableString(value: unknown, maximum: number): string | null { - return value === null ? null : boundedString(value, maximum); -} - -function boundedInteger(value: unknown, minimum: number, maximum: number): number { - if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { - throw new InvalidLearningDataError(); - } - return value as number; -} - -function isoTimestamp(value: unknown): string { - const timestamp = boundedString(value, 64); - if (!Number.isFinite(Date.parse(timestamp))) throw new InvalidLearningDataError(); - return timestamp; -} - -function mediaUrl(value: unknown): string { - const url = boundedString(value, 2048); - if (MEDIA_URL_PATTERN.test(url)) return url; - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new InvalidLearningDataError(); - } - if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new InvalidLearningDataError(); - return parsed.toString(); -} - -function projectImage(value: unknown): Record { - const image = asRecord(value); - const width = image.width === undefined ? undefined : boundedInteger(image.width, 1, 32_768); - const height = image.height === undefined ? undefined : boundedInteger(image.height, 1, 32_768); - return { - url: mediaUrl(image.url), - alt: boundedString(image.alt, 500), - ...(width === undefined ? {} : { width }), - ...(height === undefined ? {} : { height }), - }; -} - -function projectSummary(value: unknown): Record { - const project = asRecord(value); - const id = boundedString(project.id, 128); - if (!PROJECT_ID_PATTERN.test(id)) throw new InvalidLearningDataError(); - const tags = Array.isArray(project.tags) && project.tags.length <= MAX_TAGS - ? project.tags.map((tag) => boundedString(tag, 64)) - : (() => { throw new InvalidLearningDataError(); })(); - if (new Set(tags).size !== tags.length) throw new InvalidLearningDataError(); - return { - id, - name: boundedString(project.name, 200), - summary: boundedString(project.summary, 2_000), - cover: projectImage(project.cover), - tags, - version: nullableString(project.version, 64), - archiveBytes: boundedInteger(project.archiveBytes, 1, Number.MAX_SAFE_INTEGER), - publishedAt: isoTimestamp(project.publishedAt), - updatedAt: isoTimestamp(project.updatedAt), - }; -} - -function projectDetail(value: unknown): LearningProjectDetail { - const project = asRecord(value); - const summary = projectSummary(project); - const archiveSha256 = boundedString(project.archiveSha256, 64); - if (!SHA256_PATTERN.test(archiveSha256)) throw new InvalidLearningDataError(); - const archiveFileName = boundedString(project.archiveFileName, 160); - if (!archiveFileName.toLowerCase().endsWith('.zip')) throw new InvalidLearningDataError(); - return { - ...summary, - readmeMarkdown: boundedString(project.readmeMarkdown, 500_000), - archiveFileName, - archiveSha256, - } as LearningProjectDetail; -} - -function unwrapEnvelope(value: unknown): unknown { - const envelope = asRecord(value); - if (envelope.success !== true || envelope.data === undefined) throw new InvalidLearningDataError(); - return envelope.data; -} - -function projectPage(value: unknown): Record { - const page = asRecord(unwrapEnvelope(value)); - if (!Array.isArray(page.items) || page.items.length > MAX_LIST_ITEMS) throw new InvalidLearningDataError(); - const nextCursor = page.nextCursor === null ? null : boundedString(page.nextCursor, 1024); - const total = page.total === undefined ? undefined : boundedInteger(page.total, 0, Number.MAX_SAFE_INTEGER); - return { - items: page.items.map(projectSummary), - nextCursor, - ...(total === undefined ? {} : { total }), - }; -} - -async function readBoundedJson(response: Response): Promise { - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { - await response.body?.cancel().catch(() => undefined); - throw new InvalidLearningDataError(); - } - if (!response.body) throw new InvalidLearningDataError(); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > MAX_RESPONSE_BYTES) { - await reader.cancel().catch(() => undefined); - throw new InvalidLearningDataError(); - } - chunks.push(value); - } - try { - return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')) as unknown; - } catch { - throw new InvalidLearningDataError(); - } -} - -function mediaMimeType(response: Response): string | null { - const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); - return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; -} - -async function readBoundedMedia(response: Response): Promise { - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > LEARNING_MEDIA_MAX_BYTES) { - await response.body?.cancel().catch(() => undefined); - return null; - } - if (!response.body) return null; - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > LEARNING_MEDIA_MAX_BYTES) { - await reader.cancel().catch(() => undefined); - return null; - } - chunks.push(value); - } - return size > 0 ? Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) : null; -} - -function safeError(status: number): { status: number; code: string; error: string } { - if (status === 400 || status === 422) return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习项目请求无效' }; - if (status === 401) return { status, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' }; - if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限访问学习项目' }; - if (status === 404) return { status, code: 'LEARNING_PROJECT_NOT_FOUND', error: '学习项目不存在或已下架' }; - if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '学习项目状态已变化,请刷新后重试' }; - const safeStatus = status >= 400 && status <= 599 ? status : 502; - return { - status: safeStatus, - code: 'LEARNING_UNAVAILABLE', - error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '学习项目服务暂时不可用', - }; -} - -function sendSafeError(res: ServerResponse, status: number): void { - const error = safeError(status); - sendJson(res, error.status, { success: false, ...error }); -} - -function sendInvalidResponse(res: ServerResponse): void { - sendJson(res, 502, { - success: false, - status: 502, - code: 'LEARNING_INVALID_RESPONSE', - error: '学习项目服务返回了无效数据', - }); -} - -async function defaultChooseDestination(ctx: HostApiContext, fileName: string): Promise { - const options: SaveDialogOptions = { - defaultPath: join(app.getPath('downloads'), fileName), - filters: [ - { name: 'ZIP 项目压缩包', extensions: ['zip'] }, - { name: '所有文件', extensions: ['*'] }, - ], - }; - const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed() ? ctx.mainWindow : null; - const selection = mainWindow - ? await dialog.showSaveDialog(mainWindow, options) - : await dialog.showSaveDialog(options); - return selection.canceled || !selection.filePath ? null : selection.filePath; -} - -export function createLearningRouteHandler(dependencies: Dependencies = {}) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding; - const isCurrentAccountBinding = dependencies.isCurrentAccountBinding ?? isCurrentWorksSquareAccountBinding; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - const chooseDestination = dependencies.chooseDestination ?? defaultChooseDestination; - const saveArchive = dependencies.saveArchive ?? saveLearningProjectArchive; - - async function authorizedRequest(path: string, accept: string): Promise { - const token = await getAccessToken({ fetchImpl }); - if (!token) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { - method: 'GET', - headers: { Accept: accept, Authorization: `Bearer ${accessToken}` }, - redirect: 'manual', - }); - let response = await request(token); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); - if (!refreshed) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - response = await request(refreshed); - } - return response; - } - - async function readDetail(route: Extract): Promise { - const response = await authorizedRequest(route.upstreamPath, 'application/json'); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new LearningProjectDownloadError(response.status, 'LEARNING_PROJECT_REQUEST_FAILED', '学习项目暂时无法读取'); - } - const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); - if (detail.id !== route.projectId) throw new InvalidLearningDataError(); - return detail; - } - - return async function handleLearningRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, - ): Promise { - const route = matchRoute(url.pathname); - if (!route) return false; - const expectedMethod = route.kind === 'download' ? 'POST' : 'GET'; - if (req.method !== expectedMethod) { - sendJson(res, 405, { - success: false, - status: 405, - code: 'LEARNING_METHOD_NOT_ALLOWED', - error: '不支持的学习项目请求', - }); - return true; - } - - try { - if (route.kind === 'download') { - const binding = getAccountBinding(); - if (!binding) { - sendSafeError(res, 401); - return true; - } - const detail = await readDetail(route); - if (!isCurrentAccountBinding(binding)) throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); - const fileName = safeLearningProjectArchiveFileName(detail.archiveFileName, detail.id); - const destinationPath = await chooseDestination(ctx, fileName); - if (!destinationPath) { - sendJson(res, 200, { success: true, data: { status: 'cancelled' } }); - return true; - } - await saveArchive({ - project: detail, - destinationPath, - binding, - fetchImpl, - getAccessToken, - isCurrentAccountBinding, - apiBaseUrl, - }); - sendJson(res, 200, { success: true, data: { status: 'saved' } }); - return true; - } - - const response = route.kind === 'list' - ? await authorizedRequest(`${route.upstreamPath}${listQuery(url)}`, 'application/json') - : await authorizedRequest(route.upstreamPath, route.kind === 'media' - ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' - : 'application/json'); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - sendSafeError(res, response.status); - return true; - } - - if (route.kind === 'media') { - const mimeType = mediaMimeType(response); - if (!mimeType) { - await response.body?.cancel().catch(() => undefined); - sendInvalidResponse(res); - return true; - } - const bytes = await readBoundedMedia(response); - if (!bytes) { - sendInvalidResponse(res); - return true; - } - sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); - return true; - } - - if (route.kind === 'list') { - sendJson(res, 200, { success: true, data: projectPage(await readBoundedJson(response)) }); - return true; - } - - const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); - if (detail.id !== route.projectId) throw new InvalidLearningDataError(); - sendJson(res, 200, { success: true, data: detail }); - return true; - } catch (error) { - if (error instanceof InvalidLearningDataError) { - sendInvalidResponse(res); - } else if (error instanceof LearningProjectDownloadError) { - sendJson(res, error.status, { - success: false, - status: error.status, - code: error.code, - error: error.message, - }); - } else { - sendSafeError(res, 502); - } - return true; - } - }; -} - -export const handleLearningRoutes = createLearningRouteHandler(); diff --git a/electron/main/background-lifecycle.ts b/electron/main/background-lifecycle.ts index e144fb4..36ee620 100644 --- a/electron/main/background-lifecycle.ts +++ b/electron/main/background-lifecycle.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; -export type DesktopModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other'; +export type DesktopModule = 'programming' | 'painting' | 'robot' | 'other'; export type DesktopActivity = { visible: boolean; diff --git a/electron/main/index.ts b/electron/main/index.ts index 2100f5c..0d24883 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -663,7 +663,7 @@ async function initialize(): Promise { }); // Coding workers are started on demand. Keeping them cold for - // Canvas/Learning/Robot avoids a resident child process on every launch. + // Canvas/Robot avoids a resident child process on every launch. void initializeBackgroundServices().catch((error) => { logger.warn('Deferred background initialization failed:', error); }); diff --git a/electron/services/learning-project-download.ts b/electron/services/learning-project-download.ts deleted file mode 100644 index 0b30d68..0000000 --- a/electron/services/learning-project-download.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { open, rename, rm } from 'node:fs/promises'; -import { basename, dirname, extname, join } from 'node:path'; -import type { LearningProjectDetail } from '../../shared/learning'; -import type { WorksSquareAccountBinding } from './works-square-session'; - -const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const ARCHIVE_MIME_TYPES = new Set([ - 'application/octet-stream', - 'application/x-zip-compressed', - 'application/zip', -]); -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); -export const LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS = 5; - -export class LearningProjectDownloadError extends Error { - constructor( - readonly status: number, - readonly code: string, - message: string, - ) { - super(message); - this.name = 'LearningProjectDownloadError'; - } -} - -export function safeLearningProjectArchiveFileName(value: string, projectId: string): string { - const fallbackId = projectId.replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 48) || 'project'; - const normalized = [...basename(value.trim().replace(/\\/g, '/'))] - .map((character) => character.charCodeAt(0) < 32 ? '-' : character) - .join('') - .replace(/[<>:"/\\|?*]/g, '-') - .replace(/[. ]+$/g, '') - .slice(0, 120); - if (!normalized || extname(normalized).toLowerCase() !== '.zip') { - return `Makelore-${fallbackId}.zip`; - } - return normalized; -} - -function assertCurrentAccount( - binding: WorksSquareAccountBinding, - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean, -): void { - if (!isCurrentAccountBinding(binding)) { - throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重新下载'); - } -} - -function validateProject(project: LearningProjectDetail): void { - if (!PROJECT_ID_PATTERN.test(project.id) - || !SHA256_PATTERN.test(project.archiveSha256)) { - throw new LearningProjectDownloadError(502, 'LEARNING_PROJECT_INVALID', '项目下载信息无效'); - } -} - -async function followRedirects(input: { - response: Response; - initialUrl: string; - apiOrigin: string; - fetchImpl: typeof fetch; - binding: WorksSquareAccountBinding; - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; -}): Promise { - let response = input.response; - let currentUrl = new URL(input.initialUrl).href; - const visited = new Set([currentUrl]); - - for (let hop = 0; REDIRECT_STATUSES.has(response.status); hop += 1) { - if (hop >= LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS) { - await response.body?.cancel().catch(() => undefined); - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_LIMIT', '项目下载重定向次数过多'); - } - const location = response.headers.get('location'); - await response.body?.cancel().catch(() => undefined); - if (!location) { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效'); - } - let target: URL; - try { - target = new URL(location, currentUrl); - } catch { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效'); - } - if (!['http:', 'https:'].includes(target.protocol) - || target.origin !== input.apiOrigin - || Boolean(target.username || target.password) - || visited.has(target.href)) { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址不安全'); - } - visited.add(target.href); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - response = await input.fetchImpl(target, { - method: 'GET', - headers: { Accept: 'application/zip' }, - redirect: 'manual', - }); - currentUrl = target.href; - } - return response; -} - -async function writeVerifiedArchive(input: { - response: Response; - temporaryPath: string; - project: LearningProjectDetail; - binding: WorksSquareAccountBinding; - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; -}): Promise { - if (!input.response.body) { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_EMPTY', '项目压缩包内容为空'); - } - const contentType = input.response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); - if (!contentType || !ARCHIVE_MIME_TYPES.has(contentType)) { - await input.response.body.cancel().catch(() => undefined); - throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_MIME_INVALID', '项目压缩包类型无效'); - } - const handle = await open(input.temporaryPath, 'wx'); - const hash = createHash('sha256'); - const signature: number[] = []; - const reader = input.response.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte); - hash.update(value); - await handle.write(value); - } - } finally { - reader.releaseLock(); - await handle.close(); - } - - if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b - || !((signature[2] === 0x03 && signature[3] === 0x04) - || (signature[2] === 0x05 && signature[3] === 0x06) - || (signature[2] === 0x07 && signature[3] === 0x08))) { - throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_INVALID', '项目压缩包不是有效的 ZIP 文件'); - } - if (hash.digest('hex') !== input.project.archiveSha256) { - throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_HASH_MISMATCH', '项目压缩包完整性校验失败'); - } -} - -export async function saveLearningProjectArchive(input: { - project: LearningProjectDetail; - destinationPath: string; - binding: WorksSquareAccountBinding; - fetchImpl: typeof fetch; - getAccessToken: (options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean }) => Promise; - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; - apiBaseUrl: string; -}): Promise { - validateProject(input.project); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - const apiBaseUrl = input.apiBaseUrl.replace(/\/+$/, ''); - const archiveUrl = `${apiBaseUrl}/api/learning/projects/${encodeURIComponent(input.project.id)}/archive`; - const request = (accessToken: string) => input.fetchImpl(archiveUrl, { - method: 'GET', - headers: { - Accept: 'application/zip', - Authorization: `Bearer ${accessToken}`, - }, - redirect: 'manual', - }); - - const token = await input.getAccessToken({ fetchImpl: input.fetchImpl }); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - if (!token) { - throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - } - let response = await request(token); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - const refreshed = await input.getAccessToken({ fetchImpl: input.fetchImpl, forceRefresh: true }); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - if (!refreshed) { - throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - } - response = await request(refreshed); - } - response = await followRedirects({ - response, - initialUrl: archiveUrl, - apiOrigin: new URL(apiBaseUrl).origin, - fetchImpl: input.fetchImpl, - binding: input.binding, - isCurrentAccountBinding: input.isCurrentAccountBinding, - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new LearningProjectDownloadError( - response.status >= 400 && response.status <= 599 ? response.status : 502, - 'LEARNING_DOWNLOAD_FAILED', - '项目下载失败,请稍后重试', - ); - } - - const temporaryPath = join( - dirname(input.destinationPath), - `.${basename(input.destinationPath)}.${randomUUID()}.download`, - ); - try { - await writeVerifiedArchive({ - response, - temporaryPath, - project: input.project, - binding: input.binding, - isCurrentAccountBinding: input.isCurrentAccountBinding, - }); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - await rename(temporaryPath, input.destinationPath); - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined); - if (error instanceof LearningProjectDownloadError) throw error; - throw new LearningProjectDownloadError(500, 'LEARNING_SAVE_FAILED', '项目保存失败,请重新选择位置后重试'); - } -} diff --git a/shared/learning.ts b/shared/learning.ts deleted file mode 100644 index 24e1f0b..0000000 --- a/shared/learning.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Stable project-catalog boundary between Makelore and Works Square. */ - -export const LEARNING_API_PATH = '/api/works/learning'; - -export const LEARNING_MEDIA_MAX_BYTES = 10 * 1024 * 1024; - -export type LearningProjectImage = { - url: string; - alt: string; - width?: number; - height?: number; -}; - -export type LearningProjectSummary = { - id: string; - name: string; - summary: string; - cover: LearningProjectImage; - tags: string[]; - version: string | null; - archiveBytes: number; - publishedAt: string; - updatedAt: string; -}; - -export type LearningProjectDetail = LearningProjectSummary & { - readmeMarkdown: string; - archiveFileName: string; - archiveSha256: string; -}; - -export type LearningProjectPage = { - items: LearningProjectSummary[]; - nextCursor: string | null; - total?: number; -}; - -export type LearningProjectListQuery = { - cursor?: string; - limit?: number; -}; - -export type LearningProjectDownloadResult = { - status: 'saved' | 'cancelled'; -}; diff --git a/shared/module-access.ts b/shared/module-access.ts index dcc77ad..412a954 100644 --- a/shared/module-access.ts +++ b/shared/module-access.ts @@ -1,7 +1,6 @@ export const MODULE_ACCESS_KEYS = [ 'programming', 'design', - 'learning', 'robot', ] as const; @@ -12,7 +11,6 @@ export type ModuleAccess = Record; export const DEFAULT_MODULE_ACCESS: ModuleAccess = { programming: true, design: true, - learning: true, robot: true, }; @@ -24,7 +22,6 @@ export function normalizeModuleAccess(value: unknown): ModuleAccess { return { programming: typeof record.programming === 'boolean' ? record.programming : true, design: typeof record.design === 'boolean' ? record.design : true, - learning: typeof record.learning === 'boolean' ? record.learning : true, robot: typeof record.robot === 'boolean' ? record.robot : true, }; } diff --git a/src/App.tsx b/src/App.tsx index c97a8d8..f1cd4a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,8 +42,6 @@ const Settings = lazy(() => import('./pages/Settings').then(({ Settings: compone const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component }))); const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component }))); const ModuleSelection = lazy(() => import('./pages/ModuleSelection').then(({ ModuleSelection: component }) => ({ default: component }))); -const Learning = lazy(() => import('./pages/Learning').then(({ Learning: component }) => ({ default: component }))); -const LearningProjectDetail = lazy(() => import('./pages/Learning/ProjectDetail').then(({ LearningProjectDetail: component }) => ({ default: component }))); /** * Error Boundary to catch and display React rendering errors @@ -129,7 +127,6 @@ function moduleForPath(pathname: string): DesktopActivityModule | null { const module = getGuardedAiModuleForPath(pathname); if (module === 'programming') return 'programming'; if (module === 'painting') return 'painting'; - if (module === 'learning') return 'learning'; if (module === 'robot') return 'robot'; return null; } @@ -457,8 +454,6 @@ function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/assets/module-learning.webp b/src/assets/module-learning.webp deleted file mode 100644 index 0ecd91b7de085dadd8556ec5b390c770537af30a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45392 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 Qmu>ZW{FA|^slosN0E~N%iU0rr diff --git a/src/components/layout/LearningSidebar.tsx b/src/components/layout/LearningSidebar.tsx deleted file mode 100644 index 506ff71..0000000 --- a/src/components/layout/LearningSidebar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FolderKanban } from 'lucide-react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { cn } from '@/lib/utils'; - -type LearningSidebarProps = { - sidebarCollapsed: boolean; -}; - -export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) { - const navigate = useNavigate(); - const location = useLocation(); - const active = location.pathname === '/learning' || location.pathname.startsWith('/learning/project/'); - - return ( - - ); -} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b0e8f44..009aebb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -40,7 +40,6 @@ import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; import { ModuleSwitcher } from './ModuleSwitcher'; import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar'; -import { LearningSidebar } from './LearningSidebar'; import { SidebarUpdateButton } from './SidebarUpdateButton'; import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; @@ -185,7 +184,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const activeModule = getAiModuleForPath(location.pathname); const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; - const isLearningModule = activeModule === 'learning'; const isRobotModule = activeModule === 'robot'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const projectConfigPath = '/project-config'; @@ -672,8 +670,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi ) : null} - ) : isLearningModule ? ( - ) : (
- -
- - - ); - } - - return ( -
-
- - -
- -
- -
-
- {project.tags.map((tag) => ( - {tag} - ))} -
-

{project.name}

-

{project.summary}

-
- ZIP · {formatBytes(project.archiveBytes)} - {project.version ? `版本 ${project.version}` : '最新版'} - 更新于 {new Date(project.updatedAt).toLocaleDateString('zh-CN')} -
-
-
- -
-

项目说明

-
-

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - p: ({ children }) =>

{children}

, - a: ({ href, children }) => isHttpsLink(href) ? ( -
{children} - ) : {children}, - img: ({ src, alt }) => typeof src === 'string' ? ( - - ) : null, - pre: ({ children }) =>
{children}
, - code: ({ children, className }) => {children}, - blockquote: ({ children }) =>
{children}
, - table: ({ children }) =>
{children}
, - }} - > - {project.readmeMarkdown} - -
-
-
- ); -} diff --git a/src/pages/Learning/ProjectImage.tsx b/src/pages/Learning/ProjectImage.tsx deleted file mode 100644 index 8f40b4e..0000000 --- a/src/pages/Learning/ProjectImage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useState } from 'react'; -import { ImageOff, Loader2 } from 'lucide-react'; -import { - fetchLearningProjectMedia, - isLearningProjectMediaUrl, - isSafeLearningImageUrl, -} from '@/lib/learning'; -import { cn } from '@/lib/utils'; - -type ProjectImageProps = { - src: string; - alt: string; - className?: string; - allowHttps?: boolean; -}; - -export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) { - const controlledMedia = isLearningProjectMediaUrl(src); - const directSource = allowHttps && isSafeLearningImageUrl(src) && !controlledMedia ? src : null; - const [failedSource, setFailedSource] = useState(null); - const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({ - input: '', - source: '', - status: 'error', - }); - - useEffect(() => { - if (!controlledMedia) return undefined; - let cancelled = false; - void fetchLearningProjectMedia(src).then((source) => { - if (!cancelled) setState({ input: src, source, status: 'ready' }); - }).catch(() => { - if (!cancelled) setState({ input: src, source: '', status: 'error' }); - }); - return () => { cancelled = true; }; - }, [controlledMedia, src]); - - if (failedSource === src || (!controlledMedia && !directSource)) { - return ( - - - - ); - } - if (controlledMedia && state.input !== src) { - return ( - - - - ); - } - if (controlledMedia && state.status === 'error') { - return ( - - - - ); - } - return ( - {alt} setFailedSource(src)} - /> - ); -} diff --git a/src/pages/Learning/index.tsx b/src/pages/Learning/index.tsx deleted file mode 100644 index d08f514..0000000 --- a/src/pages/Learning/index.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { ArrowRight, FolderArchive, Loader2, RefreshCw } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { fetchLearningProjects } from '@/lib/learning'; -import type { LearningProjectSummary } from '../../../shared/learning'; -import { ProjectImage } from './ProjectImage'; - -function formatBytes(bytes: number): string { - if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`; - return `${Math.max(1, Math.round(bytes / 1024))} KB`; -} - -function mergeProjects( - current: LearningProjectSummary[], - incoming: LearningProjectSummary[], -): LearningProjectSummary[] { - const projects = new Map(current.map((project) => [project.id, project])); - for (const project of incoming) projects.set(project.id, project); - return [...projects.values()]; -} - -export function Learning() { - const navigate = useNavigate(); - const [projects, setProjects] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - - const loadProjects = useCallback(async (cursor?: string) => { - const append = Boolean(cursor); - if (append) setLoadingMore(true); - else setLoading(true); - setError(null); - try { - const page = await fetchLearningProjects({ cursor, limit: 24 }); - setProjects((current) => append ? mergeProjects(current, page.items) : page.items); - setNextCursor(page.nextCursor); - } catch (cause) { - setError(cause instanceof Error ? cause.message : '学习项目暂时无法加载'); - } finally { - if (append) setLoadingMore(false); - else setLoading(false); - } - }, []); - - useEffect(() => { - void loadProjects(); - }, [loadProjects]); - - return ( -
-
-
-

动手学习

-

学习项目

-

- 选择一个感兴趣的项目,先阅读完整介绍,再把源码压缩包保存到电脑继续实践。 -

-
- -
- - {loading ? ( -
- 正在读取项目 -
- ) : error && projects.length === 0 ? ( -
-

项目列表加载失败

-

{error}

- -
- ) : projects.length === 0 ? ( -
- -

暂时还没有已发布项目

-

运营发布后,项目会直接出现在这里。

-
- ) : ( - <> - {error ?

{error}

: null} -
- {projects.map((project) => ( -
- -
- ))} -
- {nextCursor ? ( -
- -
- ) : null} - - )} -
- ); -} diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 8cd05bb..efe15d8 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import moduleCanvasImage from '@/assets/module-canvas-neon.webp'; import moduleGameImage from '@/assets/module-game.webp'; -import moduleLearningImage from '@/assets/module-learning.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; @@ -16,7 +15,6 @@ import { getUserProfileDisplayName } from '@/stores/user-profile'; const moduleSelectionContent: Record = { programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' }, painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' }, - learning: { label: 'Learning 学习', image: moduleLearningImage, imageAlt: '数学科技宇宙' }, robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' }, }; diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 5855cf5..48be8dc 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -785,7 +785,7 @@ export const useAuthStore = create()( }), { name: 'niancode-auth', - version: 3, + version: 4, migrate: (persistedState: unknown) => { const state = persistedState && typeof persistedState === 'object' ? persistedState as Record diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index 4bd5277..0ba93e0 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -231,7 +231,7 @@ test.describe('AI Design V2 workspace', () => { e2eGlobal.__designE2eAuthReady = true; return respond({ success: true, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); } if (path === '/api/works/user/agent-profile') { @@ -389,7 +389,7 @@ test.describe('AI Design V2 workspace', () => { deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }, version: 2, })); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index a83fc91..0a9b53e 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,7 +1,7 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('Makelore module navigation without setup flow', () => { - test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { + test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -23,7 +23,7 @@ test.describe('Makelore module navigation without setup flow', () => { })); const initialMetrics = await readCardMetrics(); - expect(initialMetrics).toHaveLength(4); + expect(initialMetrics).toHaveLength(3); await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0); await expect.poll(async () => await moduleCards.first().evaluate((element) => { @@ -75,7 +75,7 @@ test.describe('Makelore module navigation without setup flow', () => { await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1); const resizedMetrics = await readCardMetrics(); - expect(resizedMetrics).toHaveLength(4); + expect(resizedMetrics).toHaveLength(3); expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1); expect(resizedMetrics.map((card) => card.top)).toEqual( [...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), @@ -108,23 +108,18 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('opens Learning from the module chooser and returns through the module switcher', async ({ launchElectronApp }) => { + test('omits Learning from the module chooser and redirects its retired route', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - const learningOption = page.getByTestId('ai-module-option-learning'); - await expect(learningOption).toBeVisible(); - await expect(learningOption).toBeEnabled(); - await learningOption.click(); - - await expect(page).toHaveURL(/\/learning$/); - await expect(page.getByTestId('learning-home')).toBeVisible(); - await expect(page.getByRole('heading', { name: '学习项目' })).toBeVisible(); - - await page.getByTestId('sidebar-module-switcher-trigger').click(); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0); + await page.evaluate(() => { + window.location.hash = '#/learning'; + }); await expect(page).toHaveURL(/\/module-select$/); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); } finally { diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index dc35059..4d665fe 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -47,7 +47,7 @@ test.describe('Unified plugin workspace', () => { user: { username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); if (requestPath === '/api/works/user/agent-profile') return result({ success: true, @@ -239,7 +239,7 @@ test.describe('Unified plugin workspace', () => { user: { username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); if (requestPath === '/api/works/user/agent-profile') return result({ success: true, diff --git a/tests/unit/app-module-provider-gate.test.tsx b/tests/unit/app-module-provider-gate.test.tsx index 24684c1..edc7385 100644 --- a/tests/unit/app-module-provider-gate.test.tsx +++ b/tests/unit/app-module-provider-gate.test.tsx @@ -63,7 +63,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, init: vi.fn(), @@ -116,7 +115,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -131,7 +129,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: false, design: true, - learning: true, robot: true, }, }); @@ -147,8 +144,6 @@ describe('App programming provider initialization gate', () => { ['/chat', 'programming'], ['/image-canvas', 'design'], ['/image-prompts/example', 'design'], - ['/learning', 'learning'], - ['/learning/project/project-1', 'learning'], ['/ai-hardware', 'robot'], ['/ai-hardware/device-1', 'robot'], ] as const)('redirects disabled %s routes before mounting their module', async (pathname, accessKey) => { @@ -156,7 +151,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, [accessKey]: false, }, @@ -171,12 +165,22 @@ describe('App programming provider initialization gate', () => { } }); + it.each(['/learning', '/learning/project/project-1'])( + 'redirects retired Learning route %s to the module chooser', + async (pathname) => { + await renderAt(pathname); + + expect(await screen.findByText('Module chooser')).toBeInTheDocument(); + expect(screen.queryByTestId('main-layout')).not.toBeInTheDocument(); + expect(initProviders).not.toHaveBeenCalled(); + }, + ); + it('keeps global settings available when Programming is disabled', async () => { useAuthStore.setState({ moduleAccess: { programming: false, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 6636626..650793a 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -115,10 +115,10 @@ describe('auth host api routes', () => { moduleAccess: { programming: false, design: true, - learning: false, robot: true, }, }); + expect(response.json()).not.toHaveProperty('moduleAccess.learning'); expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer'); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/auth/me', @@ -161,7 +161,6 @@ describe('auth host api routes', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/auth-store.test.ts b/tests/unit/auth-store.test.ts index 2e5e24a..edfdc10 100644 --- a/tests/unit/auth-store.test.ts +++ b/tests/unit/auth-store.test.ts @@ -24,7 +24,6 @@ function resetAuthStore() { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -88,7 +87,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: false, }, }); @@ -129,7 +127,6 @@ describe('auth store', () => { expect(state.moduleAccess).toEqual({ programming: true, design: false, - learning: true, robot: false, }); expect(window.localStorage.getItem('niancode-auth')).not.toContain( @@ -158,7 +155,7 @@ describe('auth store', () => { }) .mockResolvedValueOnce({ success: true, - moduleAccess: { learning: false }, + moduleAccess: { robot: false }, }); await useAuthStore.getState().loginWithMobile({ @@ -179,8 +176,7 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: false, - robot: true, + robot: false, }, }); }); @@ -217,7 +213,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -261,7 +256,6 @@ describe('auth store', () => { expect(useAuthStore.getState().moduleAccess).toEqual({ programming: true, design: false, - learning: true, robot: true, }); }); @@ -315,7 +309,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: true, }, }); @@ -422,7 +415,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: true, }, user: { @@ -445,7 +437,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -488,7 +479,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -952,7 +942,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/background-lifecycle.test.ts b/tests/unit/background-lifecycle.test.ts index aebc5ef..cffcd90 100644 --- a/tests/unit/background-lifecycle.test.ts +++ b/tests/unit/background-lifecycle.test.ts @@ -64,7 +64,7 @@ describe('BackgroundLifecycleController', () => { onStopRuntime, }); - controller.setActivity({ visible: false, module: 'learning' }); + controller.setActivity({ visible: false, module: 'painting' }); await vi.advanceTimersByTimeAsync(60_000); expect(onSleep).toHaveBeenCalledTimes(1); @@ -90,7 +90,7 @@ describe('BackgroundLifecycleController', () => { onStopRuntime, }); - controller.setActivity({ visible: false, module: 'learning' }); + controller.setActivity({ visible: false, module: 'robot' }); await vi.advanceTimersByTimeAsync(30_000); controller.setActivity({ visible: true, module: 'programming' }); await vi.advanceTimersByTimeAsync(90_000); diff --git a/tests/unit/host-api-dispatcher.test.ts b/tests/unit/host-api-dispatcher.test.ts index ac30aa4..8e63fc8 100644 --- a/tests/unit/host-api-dispatcher.test.ts +++ b/tests/unit/host-api-dispatcher.test.ts @@ -29,4 +29,19 @@ describe('Host API transport selection', () => { expect(data).toMatchObject({ status: 200, ok: true }); expect(data.json).toMatchObject({ runtime: 'pi', diagnostics: { revision: 3 } }); }); + + it('leaves the retired Learning Host API unregistered', async () => { + const data = await dispatchHostApiRequest({} as never, { + path: '/api/works/learning/projects', + }); + + expect(data).toMatchObject({ + status: 404, + ok: false, + json: { + success: false, + error: 'No route for GET /api/works/learning/projects', + }, + }); + }); }); diff --git a/tests/unit/learning-client.test.ts b/tests/unit/learning-client.test.ts deleted file mode 100644 index 767bef2..0000000 --- a/tests/unit/learning-client.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - downloadLearningProject, - fetchLearningProject, - fetchLearningProjectMedia, - fetchLearningProjects, - openLearningExternalLink, -} from '@/lib/learning'; - -const hostApiFetchMock = vi.hoisted(() => vi.fn()); -const invokeIpcMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), -})); - -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), -})); - -describe('Learning project renderer client', () => { - beforeEach(() => { - hostApiFetchMock.mockReset(); - invokeIpcMock.mockReset(); - }); - - it('reads paged projects and project detail through the Host API', async () => { - const page = { items: [], nextCursor: 'next' }; - const detail = { id: 'project-1', name: '机械臂' }; - hostApiFetchMock - .mockResolvedValueOnce({ success: true, data: page }) - .mockResolvedValueOnce({ success: true, data: detail }); - - await expect(fetchLearningProjects({ cursor: 'cursor one', limit: 24 })).resolves.toBe(page); - await expect(fetchLearningProject('project/one')).resolves.toBe(detail); - - expect(hostApiFetchMock).toHaveBeenNthCalledWith( - 1, - '/api/works/learning/projects?cursor=cursor+one&limit=24', - undefined, - ); - expect(hostApiFetchMock).toHaveBeenNthCalledWith( - 2, - '/api/works/learning/projects/project%2Fone', - undefined, - ); - }); - - it('starts the native Main-owned download through a fixed Host route', async () => { - hostApiFetchMock.mockResolvedValue({ success: true, data: { status: 'saved' } }); - - await expect(downloadLearningProject('project-1')).resolves.toEqual({ status: 'saved' }); - - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/learning/projects/project-1/download', - { method: 'POST' }, - ); - expect(invokeIpcMock).not.toHaveBeenCalled(); - }); - - it('accepts only fixed project media paths and validated raster data', async () => { - hostApiFetchMock.mockResolvedValue({ mimeType: 'image/png', dataBase64: 'AQID' }); - - await expect(fetchLearningProjectMedia( - '/api/learning/projects/project-1/media/readme-1', - )).resolves.toBe('data:image/png;base64,AQID'); - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/learning/projects/project-1/media/readme-1', - ); - - await expect(fetchLearningProjectMedia('https://tracker.example/image.png')).rejects.toMatchObject({ - code: 'LEARNING_INVALID_MEDIA_URL', - }); - hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/svg+xml', dataBase64: 'AQID' }); - await expect(fetchLearningProjectMedia( - '/api/learning/projects/project-1/media/readme-2', - )).rejects.toMatchObject({ code: 'LEARNING_INVALID_MEDIA_RESPONSE' }); - }); - - it('opens only credential-free HTTPS README links through the IPC facade', async () => { - invokeIpcMock.mockResolvedValue(undefined); - - await openLearningExternalLink('https://example.com/guide'); - expect(invokeIpcMock).toHaveBeenCalledWith('shell:openExternal', 'https://example.com/guide'); - await expect(openLearningExternalLink('http://example.com')).rejects.toMatchObject({ - code: 'LEARNING_INVALID_LINK', - }); - }); -}); diff --git a/tests/unit/learning-page.test.tsx b/tests/unit/learning-page.test.tsx deleted file mode 100644 index 57f7ce3..0000000 --- a/tests/unit/learning-page.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { Learning } from '@/pages/Learning'; -import { LearningProjectDetail } from '@/pages/Learning/ProjectDetail'; - -const fetchLearningProjectsMock = vi.hoisted(() => vi.fn()); -const fetchLearningProjectMock = vi.hoisted(() => vi.fn()); -const downloadLearningProjectMock = vi.hoisted(() => vi.fn()); -const openLearningExternalLinkMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/learning', () => ({ - fetchLearningProjects: fetchLearningProjectsMock, - fetchLearningProject: fetchLearningProjectMock, - downloadLearningProject: downloadLearningProjectMock, - openLearningExternalLink: openLearningExternalLinkMock, -})); - -vi.mock('@/pages/Learning/ProjectImage', () => ({ - ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => ( - {alt} - ), -})); - -const project = { - id: 'robot-arm', - name: '桌面机械臂', - summary: '从零搭建一个可以抓取积木的桌面机械臂。', - cover: { - url: '/api/learning/projects/robot-arm/media/cover', - alt: '桌面机械臂封面', - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 12 * 1024 * 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', -}; - -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - -describe('Learning project pages', () => { - beforeEach(() => { - fetchLearningProjectsMock.mockReset(); - fetchLearningProjectMock.mockReset(); - downloadLearningProjectMock.mockReset(); - openLearningExternalLinkMock.mockReset(); - openLearningExternalLinkMock.mockResolvedValue(undefined); - }); - - it('renders project cards and opens a project detail route', async () => { - fetchLearningProjectsMock.mockResolvedValue({ items: [project], nextCursor: null }); - render( - - } /> - , - ); - - expect(await screen.findByTestId('learning-project-robot-arm')).toHaveTextContent('桌面机械臂'); - expect(screen.getByAltText('桌面机械臂封面')).toHaveAttribute('src', project.cover.url); - fireEvent.click(screen.getByRole('button', { name: '查看项目:桌面机械臂' })); - expect(screen.getByTestId('location-path')).toHaveTextContent('/learning/project/robot-arm'); - }); - - it('loads more projects with the opaque cursor', async () => { - fetchLearningProjectsMock - .mockResolvedValueOnce({ items: [project], nextCursor: 'cursor-2' }) - .mockResolvedValueOnce({ items: [{ ...project, id: 'robot-car', name: '智能小车' }], nextCursor: null }); - render(); - - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); - expect(await screen.findByText('智能小车')).toBeInTheDocument(); - expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 }); - }); - - it('renders Markdown without raw HTML and allows direct HTTPS README images', async () => { - fetchLearningProjectMock.mockResolvedValue({ - ...project, - archiveFileName: 'robot-arm.zip', - archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 开始搭建\n\n![接线图](https://images.example.com/wiring.svg)\n\n\n\n[参考资料](https://example.com/guide)', - }); - render( - - } /> - , - ); - - expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument(); - expect(screen.getByAltText('接线图')).toHaveAttribute( - 'src', - 'https://images.example.com/wiring.svg', - ); - expect(screen.getByAltText('接线图')).toHaveAttribute('data-allow-https', 'true'); - expect(document.querySelector('script')).toBeNull(); - fireEvent.click(screen.getByRole('link', { name: '参考资料' })); - expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide'); - }); - - it('downloads only after the detail page action', async () => { - fetchLearningProjectMock.mockResolvedValue({ - ...project, - archiveFileName: 'robot-arm.zip', - archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 项目介绍', - }); - downloadLearningProjectMock.mockResolvedValue({ status: 'saved' }); - render( - - } /> - , - ); - - fireEvent.click(await screen.findByRole('button', { name: '下载项目' })); - await waitFor(() => expect(downloadLearningProjectMock).toHaveBeenCalledWith('robot-arm')); - }); -}); diff --git a/tests/unit/learning-project-download.test.ts b/tests/unit/learning-project-download.test.ts deleted file mode 100644 index 7745e41..0000000 --- a/tests/unit/learning-project-download.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - saveLearningProjectArchive, - safeLearningProjectArchiveFileName, -} from '@electron/services/learning-project-download'; - -const archive = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]); -const archiveSha256 = createHash('sha256').update(archive).digest('hex'); -const binding = { accountKey: 'a'.repeat(64), epoch: 1 }; -let root = ''; - -function project(overrides: Record = {}) { - return { - id: 'project-1', - name: '机械臂', - summary: '项目介绍', - cover: { url: '/api/learning/projects/project-1/media/cover', alt: '封面' }, - tags: [], - version: null, - archiveBytes: archive.length, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - readmeMarkdown: '# 机械臂', - archiveFileName: 'project.zip', - archiveSha256, - ...overrides, - } as never; -} - -describe('Learning project verified download', () => { - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'makelore-learning-project-')); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('streams, verifies and atomically saves a ZIP without returning its path', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, - })); - const destinationPath = join(root, '机械臂.zip'); - - await saveLearningProjectArchive({ - project: project(), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects/project-1/archive', - expect.objectContaining({ - headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' }, - redirect: 'manual', - }), - ); - }); - - it('downloads without comparing metadata or transport-reported archive sizes', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length + 100) }, - })); - const destinationPath = join(root, 'large-project.zip'); - - await saveLearningProjectArchive({ - project: project({ archiveBytes: 512 * 1024 * 1024 + 1 }), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - }); - - it('downloads when the archive response omits Content-Length', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip' }, - })); - const destinationPath = join(root, 'unknown-size-project.zip'); - - await saveLearningProjectArchive({ - project: project({ archiveBytes: 1 }), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - }); - - it('rejects an unsafe redirect and never forwards Bearer credentials to redirects', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { - status: 302, - headers: { Location: 'https://storage.example/private.zip' }, - })); - - await expect(saveLearningProjectArchive({ - project: project(), - destinationPath: join(root, 'project.zip'), - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - })).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_INVALID' }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('removes partial files after an integrity failure', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, - })); - - await expect(saveLearningProjectArchive({ - project: project({ archiveSha256: 'f'.repeat(64) }), - destinationPath: join(root, 'project.zip'), - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - })).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' }); - await expect(readdir(root)).resolves.toEqual([]); - }); - - it('normalizes untrusted archive names to a ZIP file name', () => { - expect(safeLearningProjectArchiveFileName('../bad.exe', 'project:1')).toBe('Makelore-project-1.zip'); - expect(safeLearningProjectArchiveFileName('机械臂.zip', 'project-1')).toBe('机械臂.zip'); - }); -}); diff --git a/tests/unit/learning-project-image.test.tsx b/tests/unit/learning-project-image.test.tsx deleted file mode 100644 index 6a9864c..0000000 --- a/tests/unit/learning-project-image.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { ProjectImage } from '@/pages/Learning/ProjectImage'; - -describe('Learning ProjectImage', () => { - it('renders a credential-free HTTPS image only when direct loading is allowed', () => { - const { rerender } = render( - , - ); - - expect(screen.getByAltText('项目结构图')).toHaveAttribute( - 'src', - 'https://images.example.com/diagram.svg', - ); - - rerender( - , - ); - - expect(screen.queryByAltText('项目结构图')).not.toBeInTheDocument(); - expect(screen.getByRole('img', { name: '项目结构图加载失败' })).toBeInTheDocument(); - }); -}); diff --git a/tests/unit/learning-route.test.ts b/tests/unit/learning-route.test.ts deleted file mode 100644 index 319b79c..0000000 --- a/tests/unit/learning-route.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLearningRouteHandler } from '@electron/api/routes/learning'; - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => 'C:\\Downloads') }, - dialog: { showSaveDialog: vi.fn() }, -})); - -const ARCHIVE_HASH = 'a'.repeat(64); - -function project(overrides: Record = {}) { - return { - id: 'project-1', - name: '机械臂入门', - summary: '从零搭建桌面机械臂。', - cover: { - url: '/api/learning/projects/project-1/media/cover', - alt: '机械臂封面', - width: 1600, - height: 900, - internalKey: 'private/cover.webp', - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', - readmeMarkdown: '# 机械臂入门', - archiveFileName: 'robot-arm.zip', - archiveSha256: ARCHIVE_HASH, - objectStorageKey: 'private/project.zip', - ...overrides, - }; -} - -function envelope(data: unknown): Response { - return new Response(JSON.stringify({ success: true, data }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function createResponse() { - const chunks: string[] = []; - const res = { - statusCode: 0, - setHeader: vi.fn(), - end: vi.fn((chunk?: string) => { - if (chunk) chunks.push(chunk); - }), - } as unknown as ServerResponse; - return { - res, - get json() { - return JSON.parse(chunks.join('')) as Record; - }, - }; -} - -describe('Learning project Main route boundary', () => { - const fetchImpl = vi.fn(); - const getAccessToken = vi.fn(); - - beforeEach(() => { - fetchImpl.mockReset(); - getAccessToken.mockReset(); - }); - - it.each([ - '/api/works/projects', - '/api/works/learning/courses', - '/api/works/learning/projects/project-1/unknown', - ])('does not claim old or unrelated route %s', async (pathname) => { - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await expect(handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL(`http://127.0.0.1${pathname}`), - {} as never, - )).resolves.toBe(false); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('forwards bounded pagination and strictly projects project cards', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope({ - items: [project()], - nextCursor: 'next-page', - total: 1, - internalFacet: 'drop-me', - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example/' }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects?cursor=opaque&limit=24&unknown=no'), - {} as never, - ); - - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects?cursor=opaque&limit=24', - expect.objectContaining({ - method: 'GET', - headers: { Accept: 'application/json', Authorization: 'Bearer works-token' }, - redirect: 'manual', - }), - ); - expect(response.json).toEqual({ - success: true, - data: { - items: [{ - id: 'project-1', - name: '机械臂入门', - summary: '从零搭建桌面机械臂。', - cover: { - url: '/api/learning/projects/project-1/media/cover', - alt: '机械臂封面', - width: 1600, - height: 900, - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', - }], - nextCursor: 'next-page', - total: 1, - }, - }); - expect(JSON.stringify(response.json)).not.toContain('objectStorageKey'); - }); - - it('projects detail Markdown and archive integrity metadata without storage keys', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1'), - {} as never, - ); - - expect(response.json).toMatchObject({ - success: true, - data: { - id: 'project-1', - readmeMarkdown: '# 机械臂入门', - archiveFileName: 'robot-arm.zip', - archiveSha256: ARCHIVE_HASH, - }, - }); - expect(JSON.stringify(response.json)).not.toContain('private/project.zip'); - }); - - it('accepts project metadata above the former client archive-size limit', async () => { - getAccessToken.mockResolvedValue('works-token'); - const archiveBytes = 512 * 1024 * 1024 + 1; - fetchImpl.mockResolvedValue(envelope(project({ archiveBytes }))); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1'), - {} as never, - ); - - expect(response.json).toMatchObject({ - success: true, - data: { id: 'project-1', archiveBytes }, - }); - }); - - it('rejects malformed identities and unsafe media URLs in successful DTOs', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope({ - items: [project({ cover: { url: 'http://internal.example/cover.png', alt: '不安全' } })], - nextCursor: null, - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects'), - {} as never, - ); - - expect(response.res.statusCode).toBe(502); - expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_RESPONSE' }); - }); - - it('proxies only fixed raster media paths with Works authentication', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), { - status: 200, - headers: { 'Content-Type': 'image/webp', 'Content-Length': '3' }, - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/media/readme-1'), - {} as never, - ); - - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects/project-1/media/readme-1', - expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }), - ); - expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/webp' }); - }); - - it('uses project metadata and a native destination before delegating verified download', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const chooseDestination = vi.fn().mockResolvedValue('D:\\Downloads\\robot-arm.zip'); - const saveArchive = vi.fn().mockResolvedValue(undefined); - const binding = { accountKey: 'b'.repeat(64), epoch: 1 }; - const handler = createLearningRouteHandler({ - fetchImpl, - getAccessToken, - getAccountBinding: () => binding, - isCurrentAccountBinding: () => true, - chooseDestination, - saveArchive, - apiBaseUrl: 'https://square.example', - }); - const response = createResponse(); - - await handler( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), - { mainWindow: null } as never, - ); - - expect(chooseDestination).toHaveBeenCalledWith(expect.anything(), 'robot-arm.zip'); - expect(saveArchive).toHaveBeenCalledWith(expect.objectContaining({ - destinationPath: 'D:\\Downloads\\robot-arm.zip', - binding, - project: expect.objectContaining({ id: 'project-1', archiveSha256: ARCHIVE_HASH }), - })); - expect(response.json).toEqual({ success: true, data: { status: 'saved' } }); - }); - - it('does not download when the user cancels the native save dialog', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const saveArchive = vi.fn(); - const handler = createLearningRouteHandler({ - fetchImpl, - getAccessToken, - getAccountBinding: () => ({ accountKey: 'b'.repeat(64), epoch: 1 }), - isCurrentAccountBinding: () => true, - chooseDestination: vi.fn().mockResolvedValue(null), - saveArchive, - }); - const response = createResponse(); - - await handler( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), - {} as never, - ); - - expect(saveArchive).not.toHaveBeenCalled(); - expect(response.json).toEqual({ success: true, data: { status: 'cancelled' } }); - }); - - it('refreshes one upstream 401 and redacts unavailable-service details', async () => { - getAccessToken.mockResolvedValueOnce('expired').mockResolvedValueOnce('fresh'); - fetchImpl - .mockResolvedValueOnce(new Response('expired secret', { status: 401 })) - .mockResolvedValueOnce(envelope({ items: [], nextCursor: null })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects'), - {} as never, - ); - - expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); - expect(response.json).toEqual({ success: true, data: { items: [], nextCursor: null } }); - }); -}); diff --git a/tests/unit/learning-sidebar.test.tsx b/tests/unit/learning-sidebar.test.tsx deleted file mode 100644 index e28074b..0000000 --- a/tests/unit/learning-sidebar.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { LearningSidebar } from '@/components/layout/LearningSidebar'; - -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - -describe('LearningSidebar', () => { - it('shows only the new project catalog navigation and returns detail pages to the list', () => { - render( - - - } /> - - , - ); - - expect(screen.getByRole('navigation', { name: 'AI 学习导航' })).toHaveTextContent('学习项目'); - expect(screen.queryByText('生成课程')).not.toBeInTheDocument(); - expect(screen.queryByText('我的课程')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '学习项目' })); - expect(screen.getByTestId('location-path')).toHaveTextContent('/learning'); - }); - - it('keeps the collapsed navigation compact', () => { - render(); - expect(screen.getByRole('button', { name: '学习项目' })).toBeInTheDocument(); - expect(screen.queryByText('阅读项目说明并下载到电脑实践。')).not.toBeInTheDocument(); - }); -}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 827d9c7..37bb229 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -7,7 +7,7 @@ import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; -const learningIpcMock = vi.hoisted(() => vi.fn()); +const routeRenderMock = vi.hoisted(() => vi.fn()); vi.mock('@/components/layout/Sidebar', () => ({ Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => ( @@ -26,8 +26,8 @@ vi.mock('@/components/layout/TitleBar', () => ({ ), })); -function LearningRouteContent() { - learningIpcMock(); +function RouteContent() { + routeRenderMock(); return
; } @@ -36,7 +36,7 @@ function renderLayout(path: string) { }> - } /> + } /> , @@ -45,7 +45,7 @@ function renderLayout(path: string) { describe('MainLayout module isolation', () => { beforeEach(() => { - learningIpcMock.mockReset(); + routeRenderMock.mockReset(); const project = { id: 'local-project', path: '/tmp/local-project', @@ -118,16 +118,4 @@ describe('MainLayout module isolation', () => { expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'overflow-hidden', 'p-0', 'sm:p-6'); }); - it.each(['/learning', '/learning/project/project-1'])( - 'keeps the project catalog route %s in the standard integrated shell', - (path) => { - renderLayout(path); - - expect(screen.getByTestId('sidebar-stub')).toBeVisible(); - expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-integrated', 'true'); - expect(screen.getByTestId('main-content')).toHaveClass('p-5', 'sm:p-6'); - expect(screen.getByTestId('route-content')).toBeVisible(); - expect(screen.queryByTestId('learning-profile-sync-error-gate')).not.toBeInTheDocument(); - }, - ); }); diff --git a/tests/unit/module-access.test.ts b/tests/unit/module-access.test.ts new file mode 100644 index 0000000..d4bed59 --- /dev/null +++ b/tests/unit/module-access.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_MODULE_ACCESS, + MODULE_ACCESS_KEYS, + normalizeModuleAccess, +} from '../../shared/module-access'; + +describe('module access projection', () => { + it('contains only the three supported product modules', () => { + expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot']); + expect(DEFAULT_MODULE_ACCESS).toEqual({ + programming: true, + design: true, + robot: true, + }); + }); + + it('drops the retired Learning key from legacy or upstream policy data', () => { + const access = normalizeModuleAccess({ + programming: false, + design: true, + learning: false, + robot: false, + }); + + expect(access).toEqual({ + programming: false, + design: true, + robot: false, + }); + expect(access).not.toHaveProperty('learning'); + }); +}); diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index f2d4e44..6db5c21 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -52,7 +52,7 @@ describe('AI module navigation', () => { }); }); - it('shows the four module choices and routes Canvas from the chooser', async () => { + it('shows only the three supported module choices and routes Canvas from the chooser', async () => { render( @@ -68,9 +68,11 @@ describe('AI module navigation', () => { expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options'); expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal'); - for (const moduleId of ['programming', 'painting', 'learning', 'robot'] as const) { + for (const moduleId of ['programming', 'painting', 'robot'] as const) { expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull(); } + expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(3); + expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument(); expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument(); expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument(); expect(screen.getByAltText('Makelore')).toHaveClass('h-8', 'w-40', 'object-cover'); @@ -82,12 +84,6 @@ describe('AI module navigation', () => { expect(screen.getByText('Canvas 设计')).toBeInTheDocument(); expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界'); - const learningOption = screen.getByTestId('ai-module-option-learning'); - expect(learningOption).toBeEnabled(); - expect(learningOption).not.toHaveClass('module-option-card-disabled'); - expect(learningOption).toHaveTextContent('Learning 学习'); - expect(learningOption).toHaveTextContent('用顶尖的方法解锁万物规律'); - expect(screen.getByAltText('数学科技宇宙')).toBeInTheDocument(); const robotOption = screen.getByTestId('ai-module-option-robot'); expect(robotOption).toBeEnabled(); expect(robotOption).not.toHaveClass('module-option-card-disabled'); @@ -95,8 +91,8 @@ describe('AI module navigation', () => { expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴'); expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument(); - fireEvent.click(learningOption); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/learning'); + fireEvent.click(screen.getByTestId('ai-module-option-painting')); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas'); }); it('routes Robot from the chooser to the AI hardware workspace', async () => { @@ -117,8 +113,7 @@ describe('AI module navigation', () => { useAuthStore.setState({ moduleAccess: { programming: true, - design: true, - learning: false, + design: false, robot: true, }, user: { @@ -139,12 +134,12 @@ describe('AI module navigation', () => { , ); - const learningOption = screen.getByTestId('ai-module-option-learning'); - expect(learningOption).toBeDisabled(); - expect(learningOption).toHaveClass('module-option-card-disabled'); - expect(learningOption).toHaveAttribute('aria-disabled', 'true'); + const canvasOption = screen.getByTestId('ai-module-option-painting'); + expect(canvasOption).toBeDisabled(); + expect(canvasOption).toHaveClass('module-option-card-disabled'); + expect(canvasOption).toHaveAttribute('aria-disabled', 'true'); - fireEvent.click(learningOption); + fireEvent.click(canvasOption); expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.queryByTestId('location-path')).not.toBeInTheDocument(); }); @@ -274,20 +269,6 @@ describe('AI module navigation', () => { expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select'); }); - it('shows Learning as the active module and returns to the chooser from the learning route', async () => { - render( - - - } /> - - , - ); - - expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveTextContent('学习 Learning'); - fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger')); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select'); - }); - it('keeps breathing room around the compact module switcher trigger', () => { render(