diff --git a/CHANGELOG.md b/CHANGELOG.md index c66632b..d407fb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,27 @@ # 更新记录 -## 未发布 - +## 0.2.0 — 2026-09-29 + +开发预览版。六条内容路径的草稿保存与发布前准备、五站真实数据和评论读取尚未全部通过真实平台验收;最终发布仍由用户在原站手动完成。 + +- 重做工作台视觉层级、深蓝导航、内容卡片与响应式布局,清理重复的说明小字。 +- 新增“设置”分类页;界面默认字号提高到 16px,正文默认 17px,支持即时调节并持久保存,提供默认视图与启动刷新开关。 +- 新增每 6 小时自动检查 GitHub Release、手动检查、预览版筛选和新版本提醒;用户自行选择下载,再按步骤更新解压扩展,不自动安装。 + +- 内容库新增多词搜索、平台与发布记录筛选、三种排序、卡片/列表视图、分页,以及复制、批量归档、撤销和导出。返回内容库保留筛选与页码。 +- 编辑器新增格式工具栏、Ctrl+B / Ctrl+I / Ctrl+S、专注写作/双栏对照/阅读预览和当前版本导出。 +- 修复多窗口继续编辑时覆盖较新母稿、平台版本无并发保护,以及批量导入失败残留图片的问题。 +- 连续输入合并保存;保存失败后阻止后续写入和离开页面,支持重试或将全部当前版本另存为新稿。 +- 修复正文恢复母稿继承时被编辑器回调再次写成独立内容;切换平台清空旧文字选区。 +- 导出 Markdown 和配套图片,重写本地图片引用;缺失图片时明确报错。内容导出与完整备份分别提供。 +- 发布队列增加状态、平台和标题筛选,并只执行筛选范围内可启动的任务。 +- 有原站操作痕迹的失败/取消任务继续阻止重复排队;取消和后台领取在事务内互斥,防止旧页面取消已执行任务。 +- 页面按需加载,预览复用已读取的图片,ZIP 压缩复用独立 Worker;目录备份完成后检查期间的新编辑,错误支持手动重试。 +- 正式构建预览支持 `PORT` 环境变量,方便在独立来源中验证数据与 CSP。 - 采用 A「经典四色」图标,统一扩展工具栏、标签页与工作台品牌。 - 更新仓库首页:品牌横幅、真实本地示例截图、中英文说明和平台进度表。 - 增加路线图、Issue 表单和 Pull Request 模板。 -以上变更位于源码分支,既有 v0.1.2 Release 安装包不变。 - ## 0.1.2 — 2026-09-22 首个以 **zMatrix · 自媒体矩阵工作台** 名称公开的开发预览版。 diff --git a/README.en.md b/README.en.md index 82b19cc..a388701 100644 --- a/README.en.md +++ b/README.en.md @@ -20,7 +20,7 @@

> [!IMPORTANT] -> **v0.1.2 is a developer preview.** Local editing, variants and backups are implemented. None of the 12 real-platform draft/pre-publication acceptance flows have passed yet; live metrics and comments also remain unverified. You always perform the final publication on the platform yourself. See the [acceptance record](docs/acceptance.md). +> **v0.2.0 is a developer preview.** Local editing, variants and backups are implemented. None of the 12 real-platform draft/pre-publication acceptance flows have passed yet; live metrics and comments also remain unverified. You always perform the final publication on the platform yourself. See the [acceptance record](docs/acceptance.md). ## Your content, in one workspace @@ -32,15 +32,21 @@ Keep a master draft, tailor titles and content for each platform, prepare image ![Master draft, platform variants and Markdown preview](docs/assets/editor.png) -| Feature | Current implementation | -| --- | --- | -| Writing and import | Markdown editor, files with companion images, pasted text and images | -| Platform variants | Inherited or overridden titles, content and images; change reminders, diffs and snapshots | -| Technical content | Code, tables, LaTeX and Mermaid previews; selected content converted to images as needed | -| Image posts | Three templates, covers, colors, typography and pagination; 1080 × 1440 PNG export | -| Publication preparation | Frozen content, persistent sequential tasks and a manual final publication step | -| Tracking | Registered article links, cached metrics, paginated comments and local read status; live integrations await validation | -| Storage and backup | IndexedDB autosave, ZIP export/restore and backups to an authorized local folder | +| Feature | Current implementation | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Writing and import | Markdown formatting toolbar, focus/split/reading views, atomic import with companion images, pasted text and images | +| Content library | Multi-term title/body/platform-tag search, platform and publication-record filters, sorting, list view, pagination, duplication and bulk archiving with undo | +| Platform variants | Inherited or overridden titles, content and images; change reminders, diffs and snapshots | +| Technical content | Code, tables, LaTeX and Mermaid previews; selected content converted to images as needed | +| Image posts | Three templates, covers, colors, typography and pagination; 1080 × 1440 PNG export | +| Publication preparation | Frozen content, persistent sequential tasks and a manual final publication step | +| Tracking | Registered article links, cached metrics, paginated comments and local read status; live integrations await validation | +| Storage and backup | Coalesced autosave, multi-window conflict protection with save-as-copy recovery, ZIP backup/restore and authorized-folder backups | +| Portable content | Export a version or multiple articles as Markdown ZIPs, with platform metadata and images referenced by relative paths | +| Preferences | Larger default typography, independent interface/editor font sizes, default views and startup refresh preferences saved locally | +| Update checks | Check releases every six hours or manually, choose whether to include previews, and download updates on request; unpacked extensions are replaced and reloaded manually | + +These features are included in the v0.2.0 development preview. [Workflow and export guide (Chinese)](docs/workbench.md).
See the image-post studio @@ -49,27 +55,27 @@ Keep a master draft, tailor titles and content for each platform, prepare image
-Screenshots show the actual local web preview with the built-in sample article. They do not show live platform integration or private account data. Screenshots may be ahead of the published v0.1.2 package. The application UI is currently in Chinese. +Screenshots show the local web preview with the built-in sample article. They do not show live platform integration or private account data. The application UI is currently in Chinese. ## Platform status Adapter code is not proof of a working platform flow. -| Platform / content type | Save and reopen draft | Complete preparation before final publication | Live metrics / comments | -| --- | --- | --- | --- | -| Zhihu · article | Pending | Pending | Pending | -| Juejin · article | Pending | Pending | Pending | -| CNBlogs · article | Pending | Pending | Pending | -| CSDN · article | Pending | Pending | Pending | -| Xiaohongshu · long article | Pending | Pending | Pending | -| Xiaohongshu · image post | Pending | Pending | Pending | -| LINUX DO | Manual assistance | Manual assistance | Best effort; unverified | +| Platform / content type | Save and reopen draft | Complete preparation before final publication | Live metrics / comments | +| -------------------------- | --------------------- | --------------------------------------------- | ----------------------- | +| Zhihu · article | Pending | Pending | Pending | +| Juejin · article | Pending | Pending | Pending | +| CNBlogs · article | Pending | Pending | Pending | +| CSDN · article | Pending | Pending | Pending | +| Xiaohongshu · long article | Pending | Pending | Pending | +| Xiaohongshu · image post | Pending | Pending | Pending | +| LINUX DO | Manual assistance | Manual assistance | Best effort; unverified | Missing settings completed manually do not count as successful automation. Uncertain results require verification before retrying. ## Get started -1. Download the extension ZIP from [Releases](https://github.com/zJay26/zMatrix/releases), such as `zMatrix-edge-0.1.2.zip`, and extract it to a permanent folder. +1. Download the extension ZIP from [Releases](https://github.com/zJay26/zMatrix/releases), such as `zMatrix-edge-0.2.0.zip`, and extract it to a permanent folder. 2. Open `edge://extensions`, enable **Developer mode**, select **Load unpacked**, and choose the folder containing `manifest.json`. 3. Open **zMatrix** from the browser toolbar. Try the sample article, then connect platforms and configure backups as needed. diff --git a/README.md b/README.md index 19e2285..53a5541 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@

> [!IMPORTANT] -> **当前为 v0.1.2 开发预览。** 本地编辑、版本管理与备份已有实现;六条内容路径的 **12 项真实草稿/发布前流程验收尚未通过**,五站指标与评论仍待实测。最终发布始终由你在原站手动完成。详见 [验收记录](docs/acceptance.md)。 +> **当前为 v0.2.0 开发预览。** 本地编辑、版本管理与备份已有实现;六条内容路径的 **12 项真实草稿/发布前流程验收尚未通过**,五站指标与评论仍待实测。最终发布始终由你在原站手动完成。详见 [验收记录](docs/acceptance.md)。 ## 为什么做 zMatrix @@ -53,39 +53,45 @@ Markdown 源码、正文预览和各平台版本并排组织。平台版本可 ![zMatrix 图文制作:模板设置与分页图片预览](docs/assets/card-studio.png) -截图来自当前源码的本地网页预览,使用内置示例稿;不连接平台,不包含私人稿件或真实账号数据。截图可能领先于已发布的 v0.1.2 安装包。 +截图来自本地网页预览,使用内置示例稿;不连接平台,不包含私人稿件或真实账号数据。 ## 工作台能做什么 -| 工作 | 当前实现 | -| --- | --- | -| **写作与导入** | 内置 Markdown 编辑器、文件及配套图片目录导入、正文和图片粘贴 | -| **母稿与平台版本** | 标题、正文、配图继承与独立覆盖;母稿变化提醒、版本差异和发布快照 | -| **技术内容排版** | 代码、表格、LaTeX、Mermaid 预览;按平台能力转换部分内容为图片 | -| **制作图文** | 三种模板、封面、配色与分页,输出 1080 × 1440 PNG | -| **发布准备** | 内容预览、冻结版本、持久化串行队列;最终按钮由用户手动点击 | -| **登记与汇总** | 文章链接登记、指标缓存、评论分页与工具内已读状态;真实平台读取待验收 | -| **本地保存与备份** | IndexedDB 自动保存、ZIP 导出与恢复、授权目录自动备份 | +| 工作 | 当前实现 | +| ------------------ | -------------------------------------------------------------------------------------------- | +| **写作与导入** | Markdown 格式工具栏、专注/双栏/阅读视图,文件及配套图片目录原子导入、正文和图片粘贴 | +| **内容管理** | 标题/正文/平台标签组合搜索、平台与发布记录筛选、排序、列表视图、分页、复制、批量归档与撤销 | +| **母稿与平台版本** | 标题、正文、配图继承与独立覆盖;母稿变化提醒、版本差异和发布快照 | +| **技术内容排版** | 代码、表格、LaTeX、Mermaid 预览;按平台能力转换部分内容为图片 | +| **制作图文** | 三种模板、封面、配色与分页,输出 1080 × 1440 PNG | +| **发布准备** | 内容预览、冻结版本、持久化串行队列;最终按钮由用户手动点击 | +| **登记与汇总** | 文章链接登记、指标缓存、评论分页与工具内已读状态;真实平台读取待验收 | +| **本地保存与备份** | 合并连续输入的自动保存、多窗口冲突保护与另存副本、ZIP 备份恢复、授权目录自动备份 | +| **携带稿件** | 单个版本或多篇稿件导出 ZIP,包含 Markdown、平台信息与使用相对路径的配套图片 | +| **阅读与设置** | 更大默认字号,界面/正文字号独立调节,默认视图与启动刷新偏好自动保存 | +| **版本检测** | 每 6 小时检查新版本、手动检查与预览版筛选;用户选择下载后手动替换并重载解压扩展 | + +以上功能随 v0.2.0 开发预览版提供。[内容管理、快捷键与导出说明 →](docs/workbench.md) ## 平台进度 “已有适配代码”不等于“真实流程已通过”。本地版本编辑可用,远端能力以下表和验收记录为准。 -| 平台 / 内容路径 | 草稿保存与重开 | 完整准备至最终发布前 | 真实指标与评论 | -| --- | --- | --- | --- | -| 知乎 · 长文 | 待验收 | 待验收 | 待验收 | -| 掘金 · 长文 | 待验收 | 待验收 | 待验收 | -| 博客园 · 长文 | 待验收 | 待验收 | 待验收 | -| CSDN · 长文 | 待验收 | 待验收 | 待验收 | -| 小红书 · 长文章 | 待验收 | 待验收 | 待验收 | -| 小红书 · 图文笔记 | 待验收 | 待验收 | 待验收 | -| LINUX DO | 人工发布辅助 | 人工发布辅助 | 尽量接入,尚未实测 | +| 平台 / 内容路径 | 草稿保存与重开 | 完整准备至最终发布前 | 真实指标与评论 | +| ----------------- | -------------- | -------------------- | ------------------ | +| 知乎 · 长文 | 待验收 | 待验收 | 待验收 | +| 掘金 · 长文 | 待验收 | 待验收 | 待验收 | +| 博客园 · 长文 | 待验收 | 待验收 | 待验收 | +| CSDN · 长文 | 待验收 | 待验收 | 待验收 | +| 小红书 · 长文章 | 待验收 | 待验收 | 待验收 | +| 小红书 · 图文笔记 | 待验收 | 待验收 | 待验收 | +| LINUX DO | 人工发布辅助 | 人工发布辅助 | 尽量接入,尚未实测 | 人工补齐缺失设置不计作自动流程通过;结果不确定时先核实,不自动重发。[查看逐项证据 →](docs/acceptance.md) ## 快速开始 -1. 从 [Releases](https://github.com/zJay26/zMatrix/releases) 下载预览版扩展 ZIP,例如 `zMatrix-edge-0.1.2.zip`,解压到固定目录。 +1. 从 [Releases](https://github.com/zJay26/zMatrix/releases) 下载预览版扩展 ZIP,例如 `zMatrix-edge-0.2.0.zip`,解压到固定目录。 2. 在 Edge 打开 `edge://extensions`,启用**开发人员模式**,选择**加载解压缩的扩展**。所选目录应直接包含 `manifest.json`。 3. 点击工具栏中的 **zMatrix**,打开工作台;先用示例体验写作与图文制作,再按需连接平台和设置备份。 @@ -125,15 +131,15 @@ npm run package # 检查、构建、ZIP 与 SHA256 **WXT · React · TypeScript · Dexie · CodeMirror 6 · unified · KaTeX · Mermaid · html-to-image** -| 目录 | 内容 | -| --- | --- | -| `src/core` | 数据模型、版本关系、内容转换、备份、任务与聚合 | -| `src/platforms` | 能力声明、URL 校验、平台适配与结果核实 | -| `src/entrypoints` | Manifest V3 后台与工作台入口 | -| `src/ui` | 编辑、图文、发布队列、数据、评论和设置 | -| `tests` | 转换、版本、任务、备份、评论与平台保护测试 | -| `public/icon` | 正式图标的 SVG 和各尺寸 PNG | -| `docs` | 安装、验收、路线图与品牌规范 | +| 目录 | 内容 | +| ----------------- | ---------------------------------------------- | +| `src/core` | 数据模型、版本关系、内容转换、备份、任务与聚合 | +| `src/platforms` | 能力声明、URL 校验、平台适配与结果核实 | +| `src/entrypoints` | Manifest V3 后台与工作台入口 | +| `src/ui` | 编辑、图文、发布队列、数据、评论和设置 | +| `tests` | 转换、版本、任务、备份、评论与平台保护测试 | +| `public/icon` | 正式图标的 SVG 和各尺寸 PNG | +| `docs` | 安装、验收、路线图与品牌规范 | `npm run preview:built` 在 `http://127.0.0.1:4173` 预览正式构建,并施加扩展脚本 CSP。网页预览与单元测试均不能替代实际扩展验收。修改图标源文件后运行 `npm run icons`。 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 18b02db..cd5ab3b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -14,6 +14,10 @@ - [x] 文章链接登记、指标和评论缓存结构。 - [x] 本地自动保存、文件夹备份、ZIP 导出与校验恢复。 - [x] 稳定扩展身份、开发者模式安装、CI 和预览版打包。 +- [x] 内容库组合筛选、排序、分页、列表视图与批量归档撤销。 +- [x] 多窗口保存冲突保护、合并连续输入、失败后另存完整稿件副本。 +- [x] 带相对图片路径的 Markdown 内容导出、当前版本导出和独立版本复制。 +- [x] 专注/双栏/阅读视图、格式工具栏和发布队列筛选。 ## v1 验收重点 diff --git a/docs/acceptance.md b/docs/acceptance.md index 4fdeff2..b2a16e4 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -1,5 +1,7 @@ # 验收记录 +本地功能的新增验证见 [2026-09-26 改进验证记录](verification-2026-09-26.md)(63 项自动测试、并发编辑、导入导出、分页与本地队列)。下方真实平台验收状态不因此改变。 + 记录日期:2026-09-22。本记录公开技术观察和验收结论,不包含测试账号名称、登录数据或实际备份。原始本机验收资料仅保存在被 Git 忽略的 validation 目录中。原型、模拟测试和按钮识别不等同于真实平台成功。 ## 最新验收边界 diff --git a/docs/installation.md b/docs/installation.md index 156ba03..3fca84d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,21 +2,29 @@ 当前为开发预览包。完整 v1 尚待平台真实验收,请先在工作台中确认预览内容、登录账号和任务结果。 -0.1.2 使用名称“zMatrix · 自媒体矩阵工作台”,停在最终发布前,最终按钮由你在原站手动点击。更新后,“平台与备份”右上角应显示 `v0.1.2 · 开发预览`;旧的发布任务也不会自动提交。 +v0.2.0 使用名称“zMatrix · 自媒体矩阵工作台”,停在最终发布前,最终按钮由你在原站手动点击;旧的发布任务也不会自动提交。平台连接、数据备份与软件更新位于“设置”中。 ## 首次加载到 Edge -1. 保留整个源码目录,或将 `zMatrix-edge-0.1.2.zip` 解压到固定目录。 +1. 保留整个源码目录,或将 `zMatrix-edge-0.2.0.zip` 解压到固定目录。 2. 打开 `edge://extensions`,启用“开发人员模式”。 3. 点击“加载解压缩的扩展”,选择包含 `manifest.json` 的目录。源码构建时是 `.output/edge-mv3`。 4. 点击扩展工具栏中的“zMatrix”,打开独立标签页工作台。 -5. “平台与备份”中逐站连接,检查当前账号。平台登录、绑定或开通操作在原站完成。 +5. “设置 → 平台连接”中逐站连接,检查当前账号。平台登录、绑定或开通操作在原站完成。 6. 设置备份目录,或先手动导出一次 ZIP 备份。 连接平台会请求该站点的访问权限,用于原生编辑器操作及已登记文章的数据读取。仅连接你需要使用的平台。浏览器权限弹窗由用户确认。 ## 更新 +“设置 → 软件更新”提供可选更新检测。扩展默认每 6 小时检查 GitHub 发布版本,浏览器重新启动或扩展后台唤醒时也会补查到期的检查;关闭工作台标签页后仍由浏览器的扩展后台负责检查,浏览器完全关闭时不会运行。普通网页预览仅在页面打开时检查。 + +- 默认接收正式版与开发预览;可以切换为仅正式版。 +- 可关闭自动检查,随时手动“检查更新”。检查失败或被限流会显示原因,不会误报为最新版本。 +- 发现新版本时显示通知和下载入口;可以选择稍后处理。检测只读取版本信息,不下载、安装或重载扩展。 +- 当前分发形式为解压扩展。点击下载后仍需按下列步骤替换和重新加载;并非一键静默安装。 +- 新增 `alarms` 权限用于后台检测,以及 `https://api.github.com/*` 的访问权限用于读取本仓库的公开 Release;请求不携带稿件、登录状态或本地内容。 + 1. 在当前版本中导出备份。 2. 保留 `extension-identity.json` 和 manifest 中的 `key`。它们固定扩展身份,不包含登录凭据或私钥。产品更名不会更换扩展 ID、原数据库或备份格式。 3. 使用新版本替换固定目录中的扩展文件,或在同一源码目录执行 `npm ci`、`npm run build`。 diff --git a/docs/verification-2026-09-26.md b/docs/verification-2026-09-26.md new file mode 100644 index 0000000..c34a3ae --- /dev/null +++ b/docs/verification-2026-09-26.md @@ -0,0 +1,39 @@ +# 本地工作流改进验证记录 + +日期:2026-09-26。基于本轮源码改动,使用独立本地网页来源与合成稿件验证。没有创建真实平台草稿或公开文章,也没有修改平台账号设置。 + +## 自动检查 + +- `npm test`:7 个测试文件,63 项通过;原有 45 项继续通过,新增 18 项。 +- `npm run typecheck`:通过。 +- `npm run build`:Edge Manifest V3 构建通过,扩展身份和数据库版本保持兼容。 +- `git diff --check`:无空白错误。修改的 TypeScript、TSX 和 CSS 通过 Prettier 检查。 + +新增测试先复现并修复了以下问题:旧窗口以更高版本号覆盖母稿、平台版本并发覆盖、失败导入残留素材、原站操作后的失败任务绕过重复排队保护。回归测试另覆盖首次创建平台版本的冲突、图片并发去重、自动保存合并及失败重试、批量归档回滚、复制隔离发布记录、内容导出再导入,以及任务领取与取消互斥。 + +## 浏览器验证 + +| 场景 | 结果 | +| --- | --- | +| 两个窗口编辑同一母稿 | 后写入的旧窗口被拦截;不能通过导航丢弃未保存编辑;另存副本后原稿和副本同时保留 | +| 平台独立编辑后恢复母稿继承 | 标题与正文回到母稿,平台页签保持“跟随母稿”,没有再次被回调改成独立正文 | +| 平台标签多词搜索及平台筛选 | 正确匹配原稿和副本 | +| 批量归档、撤销、导出 | 两篇测试稿归档后可恢复,Worker 导出完成 | +| 批量导入与分页 | 25 篇导入稿加 2 篇已有测试稿;第一页 24 篇,第二页 3 篇 | +| 返回内容库 | 保留第二页和列表视图 | +| 正式构建脚本 CSP | 本地页面使用 `script-src 'self'; object-src 'self'`;动态加载、Markdown 导出、KaTeX 和 Mermaid 显示通过 | +| 图文生成 | 示例稿生成 4 张图片,实际尺寸均为 1080 × 1440;加入小红书图文版本并保存成功 | +| 发布准备和队列 | 示例稿生成知乎/博客园本地预览,仅加入本地队列;平台筛选后显示 1 条,取消后状态与可执行数量更新正确 | +| 窄屏 | 正式构建实际视口宽 390px,预览与任务页无横向页面溢出;图标导航补充明确的无障碍名称 | + +验证时查看的页面错误日志为空。测试截图和原始日志保留在本机被 Git 忽略的 `validation/`,未纳入发行包。 + +## 性能观察与边界 + +本机原有构建的工作台入口脚本为 1,624,689 字节,本轮约 353 KB。编辑器、图文相关代码、备份和数据页面改为按需加载;这比较的是入口脚本原始大小,不是整体安装包大小,也不是实测启动耗时。总安装包仍含 Mermaid 图表库及字体资源。 + +连续输入在短暂停顿后合并写入;内容库每页最多渲染 24 篇,任务列表分批展示。当前搜索仍会在内存中检查完整稿件列表,尚未验证数万篇稿件或极大素材库的容量与延迟。 + +完整备份恢复的既有单元测试继续通过;本轮没有重新验证实际 Edge 扩展的目录授权、MV3 后台终止恢复及真实平台适配。原有六条草稿和六条发布前流程、五站指标/评论验收状态保持不变,见 [平台验收记录](acceptance.md)。 + +使用方法见 [内容管理与创作工作流](workbench.md)。 diff --git a/docs/verification-ui-settings-2026-09-26.md b/docs/verification-ui-settings-2026-09-26.md new file mode 100644 index 0000000..c5d18f7 --- /dev/null +++ b/docs/verification-ui-settings-2026-09-26.md @@ -0,0 +1,30 @@ +# 界面、设置与更新检测验证 + +范围:在已有未提交工作台改动上改进 UI,新增通用偏好与自选更新检测;保留扩展身份、数据库名、备份格式及手动发布边界。未创建 Git 提交、推送或公开 Release。 + +## 已完成检查 + +- `npm run typecheck`:通过。 +- `npm test -- --reporter=dot`:8 个测试文件、84 项通过。新增覆盖字体范围及偏好持久化、并发设置合并、语义版本排序、预览版筛选、受信任的扩展包链接、只读取版本元数据、六小时去重、关闭后手动检查、离线/限流/404、频道切换失败后的缓存处理、并发租约、关闭提醒和后台闹钟重建。 +- `npm run build`:Edge MV3 构建通过。 +- `git diff --check`:通过。 +- 构建 manifest:公开身份 key 与原文件一致;原 CSP 保持 `script-src 'self'; object-src 'self'`;新增 `alarms` 和 GitHub API 主机权限。 +- 示例测试使用独立的 `http://127.0.0.1:5219` 来源;未操作已有扩展的真实稿件、平台登录或发布任务。 + +## 浏览器检查 + +- 通用偏好、软件更新和内容库在 Edge 中打开正常;构建预览启用与扩展相同的 CSP,所查控制台无错误或警告。 +- 将界面字号设为 22px、编辑字号设为 26px,立即读取到相应的计算字号;重新加载后设置保留。 +- 列表与“仅编辑”默认视图重新打开后生效。恢复默认后返回卡片、双栏和 16px/17px,并保留此前关闭的自动更新开关。 +- 390px 宽度、22px 界面字号的设置页无水平溢出:文档宽度 375px(其余为滚动条),可用控件仍出现在可访问性树中。 +- 最终构建的编辑器和预览均为 17px,编辑器使用 Cascadia Code/Consolas/Microsoft YaHei 字体链;代码、表格、公式和 Mermaid 示例预览正常。 +- 真实 GitHub 检查:包含预览版时识别 `v0.1.2`;仅正式版时显示“暂无可用版本”。关闭自动检测后手动检查成功,恢复默认偏好不会覆盖更新选项。 +- 本地截图:`validation/ui-settings/settings-desktop.png`。 + +## 验证边界 + +后台六小时调度、重建闹钟与并发恢复通过模拟 API 的自动化测试,本轮未让真实 Edge 后台连续运行六小时,也未在用户的已安装扩展上执行更新安装。新版本分支使用受控 Release 数据验证;远端当前没有高于本地 0.1.2 的版本可进行真实升级验收。 + +更新功能自动检测并提供手动下载入口;当前解压扩展仍需用户替换文件并在扩展管理页重新加载,不会静默下载、安装或刷新工作台。现有公开 v0.1.2 Release 不包含这些源码改动。 + +实现依据:[Chrome alarms 文档](https://developer.chrome.com/docs/extensions/reference/api/alarms)、[扩展重新加载](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world)、[GitHub Releases API](https://docs.github.com/en/rest/releases/releases)。 diff --git a/docs/workbench.md b/docs/workbench.md new file mode 100644 index 0000000..9c7df29 --- /dev/null +++ b/docs/workbench.md @@ -0,0 +1,62 @@ +# 内容管理与创作工作流 + +本文对应 v0.2.0 开发预览版中的本地功能;真实平台流程的验收状态见 [验收记录](acceptance.md)。 + +## 找到和整理稿件 + +- 搜索同时检查母稿标题、正文、平台独立标题与平台标签。空格分隔的词需要同时匹配,英文不区分大小写。 +- 平台筛选匹配已经保存的平台版本或已经登记的发布记录。未编辑过的“跟随母稿”页签不会自动算成一份平台版本。 +- “已登记发布”来自本地发布记录;它不表示本轮已重新读取原站。仅登记草稿的稿件仍属于“未登记发布”。 +- 可按最近编辑、最近创建或标题排序,并切换卡片和列表。每页显示 24 篇,返回内容库时保留筛选、视图和页码。 +- “选择本页”可以配合翻页累积选择;改变筛选条件会清空选择。批量导出和归档针对已选稿件。 +- 归档保留正文、素材、平台版本与发布记录,也不会增加内容版本号。可立即撤销,或在“已归档”中移回内容库。 +- 复制创建一篇新稿,保留母稿和已保存的平台独立版本,复用本地图片,不复制原稿的发布记录和任务。 + +## 设置与阅读 + +“设置”分为通用偏好、平台连接、数据备份、软件更新。界面默认使用 16px 基准字号,正文编辑与预览默认 17px;两者可分别调整为 14–22px 和 14–26px。界面字号同时影响导航、按钮、标签和标题,字号预览与页面即时响应,偏好保存在本地,重新打开后恢复。 + +通用偏好可选择内容库默认卡片/列表视图、编辑器默认双栏/仅编辑/仅预览视图,并控制打开工作台时是否刷新文章数据。“恢复默认偏好”只恢复本页偏好,保留更新设置、备份目录和所有稿件。字体设置不改变导出图文卡片的固定排版。 + +软件更新默认每 6 小时检测公开 Release,提供手动检测与正式版/开发预览筛选。发现新版本后由用户选择下载;解压扩展的安装步骤见 [安装与更新](installation.md#更新)。 + +## 写作与保存 + +专注写作只显示源码,双栏对照显示源码与预览,阅读预览显示排版正文。切换视图保留编辑器的当前内容和撤销历史。格式工具栏可给选中文字添加加粗、斜体、标题、列表、引用、行内代码或链接。 + +| 快捷键 | 作用 | +| -------------- | ---------------------- | +| Ctrl+B / Cmd+B | 加粗选中文字 | +| Ctrl+I / Cmd+I | 斜体选中文字 | +| Ctrl+S / Cmd+S | 立即保存当前待写入内容 | +| Ctrl+Z / Cmd+Z | 撤销源码编辑 | + +连续输入在停顿约 350 毫秒后合并写入本地存储,离开编辑页或打开发布预览会先保存。关闭浏览器前请确认显示“本地已保存”;浏览器或系统被强制终止时,内存中尚未落盘的输入仍可能丢失。 + +多窗口编辑发生冲突时,旧窗口不会覆盖其他窗口已保存的内容。保存失败会保留页面与未保存编辑,后续写入不会掩盖错误: + +1. 存储暂时不可用时,可点击“重试保存”。 +2. 并发冲突时,可用“另存为新稿”保存当前母稿和所有平台版本,再打开原稿比较。 +3. “导出当前版本”也能携带尚未落盘的当前内容,作为单个版本的外部副本。 + +Markdown 文件与图片目录的批量导入会先完整校验再写入。一篇导入失败时,该批稿件和新图片均不写入;已有内容不受影响。 + +## 内容导出与工作空间备份 + +在编辑器点击“导出当前版本”,或在内容库选择稿件后点击“导出所选”。ZIP 中每个版本包含: + +- `正文.md`:图片引用改为 `images/...` 相对路径。 +- `images/`:正文、配图列表与封面使用的本地图片。 +- `稿件信息.json`:标题、配图顺序,以及已保存的平台设置。 + +解压后可直接使用 Markdown 编辑器打开,也可把一个版本的目录重新导入 zMatrix。重新导入 Markdown 时,标题按正文一级标题或文件名解析,不自动恢复 JSON 中的平台设置和配图顺序;外链图片保留原地址,不自动下载。 + +内容导出用于携带稿件,不包含任务、发布记录、数据或评论。需要完整迁移、恢复时,请使用“平台与备份”里的 ZIP 备份。备份格式、数据库名称与扩展身份保持兼容。 + +## 发布队列 + +按待执行、执行中、需要处理、已完成查看任务,再按平台和标题缩小范围。“执行筛选内任务”只启动当前筛选中允许执行的任务,顺序仍按创建时间排列。结果待核实或等待手动发布的任务不会因此重新提交。 + +失败或历史已取消任务如果已经打开原站编辑页面,仍需核实原站结果,不能通过取消、重试或重新排队来重复提交。后台已领取的任务不能被旧页面取消;可以暂停队列,然后按提示核实。 + +真实平台的最终发布仍由用户在原站手动完成。 diff --git a/package-lock.json b/package-lock.json index cc6f4f5..3c8ec2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zmatrix", - "version": "0.1.2", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zmatrix", - "version": "0.1.2", + "version": "0.2.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1e83e1d..c4d2e16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zmatrix", - "version": "0.1.2", + "version": "0.2.0", "private": true, "type": "module", "description": "zMatrix · 本地优先的自媒体矩阵工作台", diff --git a/scripts/preview-built.mjs b/scripts/preview-built.mjs index 8ea03e2..0cdcfa4 100644 --- a/scripts/preview-built.mjs +++ b/scripts/preview-built.mjs @@ -2,6 +2,9 @@ import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; import { resolve, sep, extname } from "node:path"; const root = resolve(import.meta.dirname, "../.output/edge-mv3"); +const port = Number(process.env.PORT ?? 4173); +if (!Number.isInteger(port) || port < 1 || port > 65535) + throw new Error("PORT must be an integer between 1 and 65535"); const mime = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", @@ -38,8 +41,8 @@ const server = createServer(async (request, response) => { response.writeHead(404).end("Not found"); } }); -server.listen(4173, "127.0.0.1", () => +server.listen(port, "127.0.0.1", () => console.log( - "Built UI with extension CSP: http://127.0.0.1:4173 (no platform permissions)", + `Built UI with extension CSP: http://127.0.0.1:${port} (no platform permissions)`, ), ); diff --git a/src/core/archive.ts b/src/core/archive.ts new file mode 100644 index 0000000..446f01b --- /dev/null +++ b/src/core/archive.ts @@ -0,0 +1,36 @@ +import { zipSync } from "fflate"; + +export function zipFiles(files: Record): Promise { + if (typeof Worker === "undefined") + return Promise.resolve( + new Blob([zipSync(files, { level: 3 }) as Uint8Array], { + type: "application/zip", + }), + ); + return new Promise((resolve, reject) => { + const worker = new Worker(new URL("./zip-worker.ts", import.meta.url), { + type: "module", + }); + const finish = () => { + clearTimeout(timeout); + worker.terminate(); + }; + const timeout = setTimeout(() => { + finish(); + reject(new Error("文件压缩超时,请减少导出数量后重试。")); + }, 60000); + worker.onmessage = ( + event: MessageEvent<{ bytes?: Uint8Array; error?: string }>, + ) => { + finish(); + if (event.data.bytes) + resolve(new Blob([event.data.bytes], { type: "application/zip" })); + else reject(new Error(event.data.error ?? "文件压缩失败")); + }; + worker.onerror = () => { + finish(); + reject(new Error("文件压缩进程未能运行,本地数据仍保留。")); + }; + worker.postMessage(files); + }); +} diff --git a/src/core/assets.ts b/src/core/assets.ts index 07609b7..665f670 100644 --- a/src/core/assets.ts +++ b/src/core/assets.ts @@ -8,9 +8,8 @@ const mimeByExtension: Record = { webp: "image/webp", gif: "image/gif", }; -export async function addAsset( +export async function prepareAsset( file: Blob & { name?: string }, - database: WorkbenchDB = db, ): Promise { const type = file.type || @@ -18,19 +17,33 @@ export async function addAsset( if (!type || !Object.values(mimeByExtension).includes(type)) throw new Error("图片只支持 PNG、JPEG、WebP 和 GIF。"); if (file.size > 30 * 1024 * 1024) throw new Error("单张图片不能超过 30 MB。"); - const id = await sha256(await file.arrayBuffer()); - const existing = await database.assets.get(id); - if (existing) return existing; - const asset: Asset = { + const bytes = await file.arrayBuffer(); + const id = await sha256(bytes); + return { id, name: file.name ?? `${id.slice(0, 12)}.png`, type, - blob: new Blob([await file.arrayBuffer()], { type }), + blob: new Blob([bytes], { type }), createdAt: Date.now(), }; - await database.assets.add(asset); - await changed(database); - return asset; +} +export async function addAsset( + file: Blob & { name?: string }, + database: WorkbenchDB = db, +): Promise { + const asset = await prepareAsset(file); + return database.transaction( + "rw", + database.assets, + database.meta, + async () => { + const existing = await database.assets.get(asset.id); + if (existing) return existing; + await database.assets.add(asset); + await changed(database); + return asset; + }, + ); } export function blobDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { diff --git a/src/core/autosave.ts b/src/core/autosave.ts new file mode 100644 index 0000000..bf178bb --- /dev/null +++ b/src/core/autosave.ts @@ -0,0 +1,68 @@ +import { messageOf } from "./model"; + +export interface SaveStatus { + pending: boolean; + error: string; +} + +// Coalesce each document independently. A failed write blocks every later write +// until an explicit retry, so an unrelated success cannot hide unsaved content. +export class SaveQueue { + private jobs = new Map Promise>(); + private running: Promise | null = null; + private timer?: ReturnType; + private error = ""; + constructor( + private notify: (status: SaveStatus) => void, + private delay = 350, + ) {} + + get status(): SaveStatus { + return { pending: !!this.running || this.jobs.size > 0, error: this.error }; + } + enqueue(key: string, save: () => Promise) { + this.jobs.set(key, save); + clearTimeout(this.timer); + this.notify(this.status); + if (!this.error) + this.timer = setTimeout(() => void this.flush(), this.delay); + } + async flush(): Promise { + clearTimeout(this.timer); + if (this.error) return false; + if (!this.running && this.jobs.size) { + this.running = this.drain().finally(() => { + this.running = null; + this.notify(this.status); + }); + } + await this.running; + return !this.error && this.jobs.size === 0; + } + async retry() { + this.error = ""; + return this.flush(); + } + // Only used after every local edit has been saved as a separate copy. + discard() { + clearTimeout(this.timer); + this.jobs.clear(); + this.error = ""; + this.notify(this.status); + } + private async drain() { + while (this.jobs.size) { + const [key, save] = this.jobs.entries().next().value!; + this.jobs.delete(key); + try { + await save(); + } catch (error) { + const latest = this.jobs.get(key) ?? save; + this.jobs.delete(key); + this.jobs = new Map([[key, latest], ...this.jobs]); + this.error = messageOf(error); + break; + } + } + } +} diff --git a/src/core/backup.ts b/src/core/backup.ts index 35506eb..e4b1b5d 100644 --- a/src/core/backup.ts +++ b/src/core/backup.ts @@ -1,4 +1,5 @@ -import { zipSync, unzipSync, strToU8, strFromU8 } from "fflate"; +import { unzipSync, strToU8, strFromU8 } from "fflate"; +import { zipFiles } from "./archive"; import { z } from "zod"; import { db, changed, type WorkbenchDB } from "./db"; import { @@ -200,36 +201,7 @@ export async function exportBackup( assets: records.assets.map(({ blob, ...asset }) => asset), }), ); - if (typeof Worker === "undefined") - return new Blob([zipSync(files, { level: 3 }) as Uint8Array], { - type: "application/zip", - }); - return new Promise((resolve, reject) => { - const worker = new Worker(new URL("./zip-worker.ts", import.meta.url), { - type: "module", - }); - const finish = () => { - clearTimeout(timeout); - worker.terminate(); - }; - const timeout = setTimeout(() => { - finish(); - reject(new Error("备份压缩超时,本地数据仍保留。")); - }, 60000); - worker.onmessage = ( - event: MessageEvent<{ bytes?: Uint8Array; error?: string }>, - ) => { - finish(); - if (event.data.bytes) - resolve(new Blob([event.data.bytes], { type: "application/zip" })); - else reject(new Error(event.data.error ?? "备份压缩失败")); - }; - worker.onerror = () => { - finish(); - reject(new Error("备份压缩进程未能运行,本地数据仍保留。")); - }; - worker.postMessage(files); - }); + return zipFiles(files); } export async function inspectBackup(blob: Blob) { if (blob.size > 200 * 1024 * 1024) diff --git a/src/core/db.ts b/src/core/db.ts index 6e0203d..18081a3 100644 --- a/src/core/db.ts +++ b/src/core/db.ts @@ -51,13 +51,27 @@ export async function getMeta( ): Promise { return ((await database.meta.get(key))?.value as T | undefined) ?? fallback; } -export async function saveArticle(article: Article, database = db) { +interface SaveGuard { + expected: T | undefined; +} +function checkExpected(previous: T | undefined, guard?: SaveGuard) { + if (guard && JSON.stringify(previous) !== JSON.stringify(guard.expected)) + throw new Error( + "稿件已在其他窗口更新。请保留当前编辑,另存为新稿后再打开最新版本。", + ); +} +export async function saveArticle( + article: Article, + database = db, + guard?: SaveGuard
, +) { await database.transaction( "rw", database.articles, database.meta, async () => { const previous = await database.articles.get(article.id); + checkExpected(previous, guard); if ( previous && (previous.revision > article.revision || @@ -70,7 +84,19 @@ export async function saveArticle(article: Article, database = db) { }, ); } -export async function saveVariant(variant: Variant, database = db) { - await database.variants.put(variant); - await changed(database); +export async function saveVariant( + variant: Variant, + database = db, + guard?: SaveGuard, +) { + await database.transaction( + "rw", + database.variants, + database.meta, + async () => { + checkExpected(await database.variants.get(variant.id), guard); + await database.variants.put(variant); + await changed(database); + }, + ); } diff --git a/src/core/export.ts b/src/core/export.ts new file mode 100644 index 0000000..7aa585f --- /dev/null +++ b/src/core/export.ts @@ -0,0 +1,144 @@ +import { strToU8 } from "fflate"; +import { db, type WorkbenchDB } from "./db"; +import { assetIdsIn } from "./assets"; +import { zipFiles } from "./archive"; +import { resolveContent } from "./variants"; +import type { Asset, Content, Metadata } from "./model"; + +export function safeFilename(title: string) { + const name = title + .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") + .replace(/[. ]+$/g, "") + .trim() + .slice(0, 72) + .replace(/[. ]+$/g, ""); + return !name + ? "未命名稿件" + : /^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(name) + ? `_${name}` + : name; +} +interface ExportDocument { + path: string; + content: Content; + metadata?: Metadata; +} +const extensions: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", +}; + +async function pack(documents: ExportDocument[], assets: Asset[]) { + const files: Record = {}; + const available = new Map(assets.map((a) => [a.id, a])); + for (const doc of documents) { + const ids = [ + ...new Set([ + ...assetIdsIn(doc.content.markdown), + ...doc.content.imageIds, + ...(doc.metadata?.coverId ? [doc.metadata.coverId] : []), + ]), + ]; + const paths = new Map(); + for (const id of ids) { + const asset = available.get(id); + if (!asset) + throw new Error( + `稿件「${doc.content.title || "未命名稿件"}」缺少图片,请先恢复素材后再导出。`, + ); + const path = `images/${id}.${extensions[asset.type] ?? "png"}`; + paths.set(id, path); + const fullPath = `${doc.path}/${path}`; + if (!files[fullPath]) + files[fullPath] = new Uint8Array(await asset.blob.arrayBuffer()); + } + files[`${doc.path}/正文.md`] = strToU8( + doc.content.markdown.replace( + /asset:\/\/([a-f0-9]{64})/g, + (_, id: string) => paths.get(id)!, + ), + ); + files[`${doc.path}/稿件信息.json`] = strToU8( + JSON.stringify( + { + title: doc.content.title, + imagePaths: doc.content.imageIds.map((id) => paths.get(id)), + ...(doc.metadata + ? { + metadata: { + ...doc.metadata, + coverId: undefined, + coverPath: doc.metadata.coverId + ? paths.get(doc.metadata.coverId) + : undefined, + }, + } + : {}), + }, + null, + 2, + ), + ); + } + files["使用说明.txt"] = strToU8( + "每个版本包含正文.md、稿件信息.json 与 images 图片目录。解压后可在任意 Markdown 编辑器打开,或将版本文件夹导入 zMatrix。标题、配图顺序及平台设置保存在稿件信息.json,重新导入 Markdown 时不会自动恢复这些设置。此文件为内容导出;完整工作空间恢复请使用“平台与备份”中的 ZIP 备份。\n", + ); + return zipFiles(files); +} +async function collectAssets( + documents: ExportDocument[], + database: WorkbenchDB, +) { + const ids = [ + ...new Set( + documents.flatMap((d) => [ + ...assetIdsIn(d.content.markdown), + ...d.content.imageIds, + ...(d.metadata?.coverId ? [d.metadata.coverId] : []), + ]), + ), + ]; + return (await database.assets.bulkGet(ids)).filter((a): a is Asset => !!a); +} +export async function exportCurrentContent( + content: Content, + metadata?: Metadata, + database: WorkbenchDB = db, +) { + const documents = [{ path: safeFilename(content.title), content, metadata }]; + return pack(documents, await collectAssets(documents, database)); +} +export async function exportArticles( + ids: string[], + database: WorkbenchDB = db, +) { + const { documents, assets } = await database.transaction( + "r", + database.articles, + database.variants, + database.assets, + async () => { + const documents: ExportDocument[] = []; + for (const id of new Set(ids)) { + const article = await database.articles.get(id); + if (!article) throw new Error("部分稿件已不存在,请刷新内容库后重试。"); + const root = `${safeFilename(article.title)}-${article.id.slice(0, 8)}`; + documents.push({ path: `${root}/母稿`, content: article }); + for (const variant of await database.variants + .where("articleId") + .equals(id) + .toArray()) + documents.push({ + path: `${root}/${variant.channel.replace(":", "-")}`, + content: resolveContent(article, variant), + metadata: variant.metadata, + }); + } + if (!documents.length) throw new Error("请先选择需要导出的稿件。"); + return { documents, assets: await collectAssets(documents, database) }; + }, + ); + return pack(documents, assets); +} diff --git a/src/core/import.ts b/src/core/import.ts index de2fb1b..6259b3b 100644 --- a/src/core/import.ts +++ b/src/core/import.ts @@ -2,9 +2,9 @@ import { unified } from "unified"; import remarkParse from "remark-parse"; import remarkGfm from "remark-gfm"; import { db, changed, type WorkbenchDB } from "./db"; -import { addAsset, assetIdsIn } from "./assets"; +import { prepareAsset, assetIdsIn } from "./assets"; import { newArticle } from "./variants"; -import type { Article } from "./model"; +import type { Article, Asset } from "./model"; interface LinkNode { type: string; @@ -38,6 +38,8 @@ export async function importMarkdownFiles( if (!markdownFiles.length) throw new Error("请选择 Markdown 文件,或包含 Markdown 和图片的文件夹。"); const articles: Article[] = []; + const staged = new Map(); + const preparedFiles = new Map(); const indexed = files.map((file) => ({ file, path: normalizePath(file.webkitRelativePath || file.name), @@ -79,7 +81,10 @@ export async function importMarkdownFiles( throw new Error( `找不到配套图片「${decoded}」。请连同图片文件夹一起导入。`, ); - const asset = await addAsset(matches[0]!.file, database); + const image = matches[0]!.file; + const asset = preparedFiles.get(image) ?? (await prepareAsset(image)); + preparedFiles.set(image, asset); + staged.set(asset.id, asset); const start = ref.position?.start.offset; const end = ref.position?.end.offset; if (start === undefined || end === undefined) @@ -107,7 +112,19 @@ export async function importMarkdownFiles( article.imageIds = assetIdsIn(markdown); articles.push(article); } - await database.articles.bulkAdd(articles); - await changed(database); + await database.transaction( + "rw", + database.articles, + database.assets, + database.meta, + async () => { + for (const asset of staged.values()) { + if (!(await database.assets.get(asset.id))) + await database.assets.add(asset); + } + await database.articles.bulkAdd(articles); + await changed(database); + }, + ); return articles; } diff --git a/src/core/library.ts b/src/core/library.ts new file mode 100644 index 0000000..949d141 --- /dev/null +++ b/src/core/library.ts @@ -0,0 +1,156 @@ +import { db, changed, type WorkbenchDB } from "./db"; +import { + clone, + type Article, + type ChannelId, + type RemotePost, + type Variant, +} from "./model"; +import { newArticle } from "./variants"; + +export type LibrarySort = "updated" | "created" | "title"; +export interface LibraryFilter { + query: string; + archived: boolean; + channel: ChannelId | "all"; + publication: "all" | "published" | "unpublished"; + sort: LibrarySort; +} +export interface LibraryEntry { + article: Article; + variants: Variant[]; + posts: RemotePost[]; +} +export function libraryEntries( + articles: Article[], + variants: Variant[], + posts: RemotePost[], +): LibraryEntry[] { + const entries = new Map( + articles.map((article) => [ + article.id, + { article, variants: [] as Variant[], posts: [] as RemotePost[] }, + ]), + ); + for (const variant of variants) + entries.get(variant.articleId)?.variants.push(variant); + for (const post of posts) + if (post.articleId) entries.get(post.articleId)?.posts.push(post); + return [...entries.values()]; +} +export function filterLibrary(entries: LibraryEntry[], filter: LibraryFilter) { + const terms = filter.query + .trim() + .toLocaleLowerCase() + .split(/\s+/) + .filter(Boolean); + return entries + .filter(({ article, variants, posts }) => { + if (article.archived !== filter.archived) return false; + if ( + filter.channel !== "all" && + !variants.some((v) => v.channel === filter.channel) && + !posts.some((p) => p.channel === filter.channel) + ) + return false; + const published = posts.some((p) => p.status === "published"); + if (filter.publication === "published" && !published) return false; + if (filter.publication === "unpublished" && published) return false; + const text = [ + article.title, + article.markdown, + ...variants.flatMap((v) => [ + v.overrides.title ?? "", + ...v.metadata.tags, + ]), + ] + .join("\n") + .toLocaleLowerCase(); + return terms.every((term) => text.includes(term)); + }) + .sort((a, b) => { + const delta = + filter.sort === "title" + ? (a.article.title || "未命名稿件").localeCompare( + b.article.title || "未命名稿件", + "zh-CN", + { numeric: true }, + ) + : filter.sort === "created" + ? b.article.createdAt - a.article.createdAt + : b.article.updatedAt - a.article.updatedAt; + return delta || a.article.id.localeCompare(b.article.id); + }); +} + +export async function archiveArticles( + ids: string[], + archived: boolean, + database: WorkbenchDB = db, +) { + await database.transaction( + "rw", + database.articles, + database.meta, + async () => { + for (const id of new Set(ids)) { + const article = await database.articles.get(id); + if (!article) throw new Error("部分稿件已不存在,请刷新内容库后重试。"); + // Archiving is organization, not a new content revision. + await database.articles.update(id, { archived, updatedAt: Date.now() }); + } + await changed(database); + }, + ); +} + +export async function copyDraft( + article: Article, + variants: Variant[], + database: WorkbenchDB = db, +) { + const copy = { + ...newArticle(`${article.title || "未命名稿件"} · 副本`, article.markdown), + imageIds: [...article.imageIds], + }; + await database.transaction( + "rw", + database.articles, + database.variants, + database.meta, + async () => { + await database.articles.add(copy); + await database.variants.bulkAdd( + variants + .filter((v) => v.articleId === article.id) + .map((v) => ({ + ...clone(v), + id: `${copy.id}/${v.channel}`, + articleId: copy.id, + baseRevision: copy.revision, + updatedAt: copy.updatedAt, + })), + ); + await changed(database); + }, + ); + return copy; +} + +export async function duplicateArticle(id: string, database: WorkbenchDB = db) { + return database.transaction( + "rw", + database.articles, + database.variants, + database.meta, + async () => { + const article = await database.articles.get(id); + if (!article) throw new Error("稿件已不存在,请刷新内容库。"); + return copyDraft( + article, + await database.variants.where("articleId").equals(id).toArray(), + database, + ); + }, + ); +} diff --git a/src/core/preferences.ts b/src/core/preferences.ts new file mode 100644 index 0000000..ae6c232 --- /dev/null +++ b/src/core/preferences.ts @@ -0,0 +1,69 @@ +import { db } from "./db"; + +export const PREFERENCES_KEY = "preferences"; +export const defaultPreferences = { + fontSize: 16, + editorFontSize: 17, + libraryLayout: "grid" as "grid" | "list", + editorLayout: "split" as "split" | "source" | "preview", + refreshOnOpen: true, + autoCheckUpdates: true, + includePrereleases: true, +}; +export type Preferences = typeof defaultPreferences; + +export function normalizePreferences(value: unknown): Preferences { + const source = + value && typeof value === "object" + ? (value as Record) + : {}; + const size = ( + key: "fontSize" | "editorFontSize", + min: number, + max: number, + ) => { + const value = source[key]; + return typeof value === "number" && Number.isFinite(value) + ? Math.min(max, Math.max(min, Math.round(value))) + : defaultPreferences[key]; + }; + return { + fontSize: size("fontSize", 14, 22), + editorFontSize: size("editorFontSize", 14, 26), + libraryLayout: source.libraryLayout === "list" ? "list" : "grid", + editorLayout: + source.editorLayout === "source" || source.editorLayout === "preview" + ? source.editorLayout + : "split", + refreshOnOpen: + typeof source.refreshOnOpen === "boolean" ? source.refreshOnOpen : true, + autoCheckUpdates: + typeof source.autoCheckUpdates === "boolean" + ? source.autoCheckUpdates + : true, + includePrereleases: + typeof source.includePrereleases === "boolean" + ? source.includePrereleases + : true, + }; +} + +export async function getPreferences(database = db) { + return normalizePreferences( + (await database.meta.get(PREFERENCES_KEY))?.value, + ); +} + +export async function savePreferences( + patch: Partial, + database = db, +) { + return database.transaction("rw", database.meta, async () => { + const value = normalizePreferences({ + ...(await getPreferences(database)), + ...patch, + }); + await database.meta.put({ key: PREFERENCES_KEY, value }); + return value; + }); +} diff --git a/src/core/tasks.ts b/src/core/tasks.ts index 6aa16af..feadeed 100644 --- a/src/core/tasks.ts +++ b/src/core/tasks.ts @@ -29,12 +29,20 @@ export const terminalStates: TaskState[] = [ "published", "cancelled", ]; +export function hasRemoteActivity(task: Task) { + return ( + task.tabId !== undefined || + !!task.remoteId || + !!task.remoteUrl || + task.events.some((e) => + ["准备向平台提交", "开始准备平台内容"].includes(e.message), + ) + ); +} export function canStart(task: Task) { return ( ["queued", "paused", "failed"].includes(task.state) && - !task.remoteId && - !task.remoteUrl && - !task.events.some((e) => e.message === "准备向平台提交") + !hasRemoteActivity(task) ); } export function recoverTask(task: Task): Task { @@ -80,7 +88,8 @@ export async function enqueue( e.snapshot.fingerprint === task.snapshot.fingerprint && e.channel === task.channel && e.mode === task.mode && - !["cancelled", "failed"].includes(e.state), + (!["cancelled", "failed"].includes(e.state) || + hasRemoteActivity(e)), ) ) throw new Error("相同版本已有任务。请先查看任务记录,避免重复发布。"); @@ -91,6 +100,40 @@ export async function enqueue( }); return tasks; } +export async function claimTask( + id: string, + owner: string, + database: WorkbenchDB = db, +) { + return database.transaction("rw", database.tasks, database.meta, async () => { + const task = await database.tasks.get(id); + if (!task || !canStart(task)) return undefined; + const claimed: Task = { + ...task, + state: "preparing", + owner, + updatedAt: Date.now(), + }; + await database.tasks.put(claimed); + await changed(database); + return claimed; + }); +} +export async function cancelTask(id: string, database: WorkbenchDB = db) { + await database.transaction("rw", database.tasks, database.meta, async () => { + const task = await database.tasks.get(id); + if (!task || !canStart(task)) + throw new Error( + "任务已开始执行或需要核实原站结果,不能取消。请刷新队列。", + ); + await patchTask( + id, + { state: "cancelled", step: "用户取消待执行任务" }, + "用户取消待执行任务", + database, + ); + }); +} export async function patchTask( id: string, values: Partial, diff --git a/src/core/updates.ts b/src/core/updates.ts new file mode 100644 index 0000000..506d4b4 --- /dev/null +++ b/src/core/updates.ts @@ -0,0 +1,274 @@ +import { z } from "zod"; +import { version as currentVersion } from "../../package.json"; +import { db, getMeta } from "./db"; +import { getPreferences } from "./preferences"; + +export const RELEASES_URL = "https://github.com/zJay26/zMatrix/releases"; +export const UPDATE_API = + "https://api.github.com/repos/zJay26/zMatrix/releases?per_page=100"; +export const UPDATE_KEY = "softwareUpdate"; +export const UPDATE_INTERVAL = 6 * 60 * 60 * 1000; +export const UPDATE_ALARM = "zmatrix-check-updates"; +export interface ReleaseInfo { + version: string; + name: string; + notes: string; + url: string; + publishedAt: string; + prerelease: boolean; + downloadUrl?: string; + size?: number; +} +export interface UpdateState { + checkedAt?: number; + attemptedAt?: number; + includePrereleases?: boolean; + release?: ReleaseInfo; + error?: string; + dismissedVersion?: string; +} + +function parseVersion(value: string) { + const match = + /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+[\da-zA-Z.-]+)?$/.exec( + value, + ); + if (!match) return null; + return { numbers: match.slice(1, 4).map(Number), pre: match[4]?.split(".") }; +} + +export function compareVersions(left: string, right: string): number { + const a = parseVersion(left), + b = parseVersion(right); + if (!a || !b) throw new Error("版本号格式无效"); + for (let i = 0; i < 3; i++) { + if (a.numbers[i] !== b.numbers[i]) + return Math.sign(a.numbers[i]! - b.numbers[i]!); + } + if (!a.pre && !b.pre) return 0; + if (!a.pre) return 1; + if (!b.pre) return -1; + for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) { + const x = a.pre[i], + y = b.pre[i]; + if (x === undefined) return -1; + if (y === undefined) return 1; + if (x === y) continue; + const xn = /^\d+$/.test(x), + yn = /^\d+$/.test(y); + if (xn && yn) return Math.sign(Number(x) - Number(y)); + if (xn !== yn) return xn ? -1 : 1; + return x < y ? -1 : 1; + } + return 0; +} + +const releaseSchema = z.object({ + tag_name: z.string(), + name: z.string().nullable(), + body: z.string().nullable().optional(), + html_url: z.string(), + published_at: z.string().nullable(), + draft: z.boolean(), + prerelease: z.boolean(), + assets: z.array( + z.object({ + name: z.string(), + browser_download_url: z.string(), + size: z.number().nonnegative(), + }), + ), +}); + +function isReleaseUrl(value: string, path: string) { + try { + const url = new URL(value); + return ( + url.origin === "https://github.com" && + !url.username && + !url.password && + url.pathname.startsWith(`/zJay26/zMatrix/releases/${path}/`) + ); + } catch { + return false; + } +} + +export function latestRelease( + data: unknown, + includePrereleases: boolean, +): ReleaseInfo | undefined { + if (!Array.isArray(data)) throw new Error("版本服务返回了无法识别的数据"); + const releases = data.flatMap((value) => { + const parsed = releaseSchema.safeParse(value); + if (!parsed.success) throw new Error("版本信息不完整,请稍后重试"); + const r = parsed.data; + const semver = parseVersion(r.tag_name); + if ( + r.draft || + !r.published_at || + !semver || + (!includePrereleases && (r.prerelease || semver.pre)) || + !isReleaseUrl(r.html_url, "tag") + ) + return []; + const version = r.tag_name.replace(/^v/, ""); + const asset = r.assets.find( + (asset) => + asset.name === `zMatrix-edge-${version}.zip` && + asset.size > 0 && + isReleaseUrl(asset.browser_download_url, "download") && + new URL(asset.browser_download_url).pathname === + `/zJay26/zMatrix/releases/download/${r.tag_name}/${asset.name}`, + ); + return [ + { + version, + name: r.name || r.tag_name, + notes: (r.body ?? "").slice(0, 16000), + url: r.html_url, + publishedAt: r.published_at, + prerelease: r.prerelease || !!semver.pre, + downloadUrl: asset?.browser_download_url, + size: asset?.size, + }, + ]; + }); + return releases.sort((a, b) => compareVersions(b.version, a.version))[0]; +} + +export const hasUpdate = (state?: UpdateState, version = currentVersion) => + !!state?.release && compareVersions(state.release.version, version) > 0; + +export async function getUpdateState(database = db) { + return getMeta(UPDATE_KEY, {}, database); +} + +// Only fetch release metadata. Downloading and reloading are separate user actions. +export async function checkForUpdates({ + manual = false, + database = db, + fetcher = fetch, + now = Date.now(), +}: { + manual?: boolean; + database?: typeof db; + fetcher?: typeof fetch; + now?: number; +} = {}): Promise { + const preferences = await getPreferences(database); + const previous = await getUpdateState(database); + if ( + !manual && + (!preferences.autoCheckUpdates || + (previous.includePrereleases === preferences.includePrereleases && + previous.attemptedAt !== undefined && + now >= previous.attemptedAt && + now - previous.attemptedAt < UPDATE_INTERVAL)) + ) + return previous; + + // A persisted lease also guards multiple preview tabs and service-worker wakeups. + const owner = crypto.randomUUID(); + const acquired = await database.transaction("rw", database.meta, async () => { + const lease = await getMeta<{ owner: string; at: number } | null>( + "updateCheckLease", + null, + database, + ); + if (lease && now >= lease.at && now - lease.at < 30_000) return false; + await database.meta.put({ + key: "updateCheckLease", + value: { owner, at: now }, + }); + return true; + }); + if (!acquired) return getUpdateState(database); + try { + let next: UpdateState; + try { + const response = await fetcher(UPDATE_API, { + headers: { Accept: "application/vnd.github+json" }, + credentials: "omit", + cache: "no-store", + referrerPolicy: "no-referrer", + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) + throw new Error( + response.status === 403 || response.status === 429 + ? "版本检查暂时受限,请稍后重试" + : `无法获取版本信息(${response.status}),请稍后重试`, + ); + next = { + ...previous, + attemptedAt: now, + checkedAt: now, + includePrereleases: preferences.includePrereleases, + release: latestRelease( + await response.json(), + preferences.includePrereleases, + ), + error: undefined, + }; + } catch (error) { + next = { + ...previous, + attemptedAt: now, + includePrereleases: preferences.includePrereleases, + release: + previous.includePrereleases === preferences.includePrereleases + ? previous.release + : undefined, + checkedAt: + previous.includePrereleases === preferences.includePrereleases + ? previous.checkedAt + : undefined, + error: + error instanceof Error && error.message.includes("版本") + ? error.message + : "检查失败,请检查网络后重试", + }; + } + await database.transaction("rw", database.meta, async () => { + // Retain a dismissal made while a request was in flight. + const latest = await getUpdateState(database); + next.dismissedVersion = latest.dismissedVersion; + await database.meta.put({ key: UPDATE_KEY, value: next }); + }); + return next; + } finally { + await database.transaction("rw", database.meta, async () => { + const lease = await getMeta<{ owner: string } | null>( + "updateCheckLease", + null, + database, + ); + if (lease?.owner === owner) + await database.meta.delete("updateCheckLease"); + }); + } +} + +export async function dismissUpdate(version: string, database = db) { + await database.transaction("rw", database.meta, async () => { + const state = await getUpdateState(database); + await database.meta.put({ + key: UPDATE_KEY, + value: { ...state, dismissedVersion: version }, + }); + }); +} + +export async function syncUpdateAlarm(database = db, alarms = chrome.alarms) { + const preferences = await getPreferences(database); + if (!preferences.autoCheckUpdates) { + await alarms.clear(UPDATE_ALARM); + return; + } + if (!(await alarms.get(UPDATE_ALARM))) { + await alarms.create(UPDATE_ALARM, { + periodInMinutes: UPDATE_INTERVAL / 60_000, + }); + } +} diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 47d6e04..1f40f89 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -1,7 +1,7 @@ import { defineBackground } from "wxt/utils/define-background"; import { db, changed } from "../core/db"; import { adapterFor } from "../platforms/browser-adapter"; -import { canStart, patchTask, recoverTask } from "../core/tasks"; +import { claimTask, patchTask, recoverTask } from "../core/tasks"; import { messageOf, uid, @@ -11,8 +11,17 @@ import { } from "../core/model"; import { refreshPost } from "../core/collection"; import { z } from "zod"; +import { + checkForUpdates, + syncUpdateAlarm, + UPDATE_ALARM, + UPDATE_KEY, + getUpdateState, +} from "../core/updates"; const command = z.discriminatedUnion("type", [ + z.object({ type: z.literal("checkUpdates"), manual: z.boolean().optional() }), + z.object({ type: z.literal("configureUpdates") }), z.object({ type: z.literal("run"), ids: z.array(z.string()).max(100) }), z.object({ type: z.literal("pause") }), z.object({ type: z.literal("probe"), channel: z.enum(channelIds) }), @@ -24,6 +33,28 @@ const command = z.discriminatedUnion("type", [ }), ]); export default defineBackground(() => { + const automaticUpdateCheck = async () => { + try { + await syncUpdateAlarm(); + await checkForUpdates(); + } catch { + await db.meta + .put({ + key: UPDATE_KEY, + value: { + ...(await getUpdateState()), + error: "自动检查未完成,请在设置中重试", + }, + }) + .catch(() => {}); + } + }; + chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === UPDATE_ALARM) void automaticUpdateCheck(); + }); + chrome.runtime.onInstalled.addListener(() => void automaticUpdateCheck()); + chrome.runtime.onStartup.addListener(() => void automaticUpdateCheck()); + void automaticUpdateCheck(); const owner = uid(); let running = false; let collectionRunning = false; @@ -96,20 +127,10 @@ export default defineBackground(() => { try { for (const id of ids) { if (pauseRequested) break; - let task = await db.tasks.get(id); - if (!task || !canStart(task)) continue; + let task = await claimTask(id, owner); + if (!task) continue; let mutated = false; try { - await db.transaction("rw", db.tasks, async () => { - const latest = await db.tasks.get(id); - if (!latest || !canStart(latest)) - throw new Error("任务已被其他执行器领取"); - await db.tasks.update(id, { - state: "preparing", - owner, - updatedAt: Date.now(), - }); - }); const adapter = adapterFor(task.channel); const context = await adapter.prepare( task.snapshot, @@ -183,6 +204,15 @@ export default defineBackground(() => { } const request = parsed.data; void (async () => { + if (request.type === "checkUpdates") { + await checkForUpdates({ manual: request.manual }); + return { ok: true }; + } + if (request.type === "configureUpdates") { + await syncUpdateAlarm(); + await checkForUpdates(); + return { ok: true }; + } await initialized; if (request.type === "pause") { pauseRequested = true; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 0fca252..1247df2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,4 +1,12 @@ -import { useEffect, useRef, useState, useCallback } from "react"; +import { + lazy, + Suspense, + useEffect, + useRef, + useState, + useCallback, + useLayoutEffect, +} from "react"; import { useLiveQuery } from "dexie-react-hooks"; import { BookOpenText, @@ -7,119 +15,175 @@ import { MessageSquareText, Settings2, Plus, - FileUp, - Search, ArrowUpRight, - FileText, - Archive, - FolderInput, + Download, + X, } from "lucide-react"; -import { db, getMeta, saveArticle } from "../core/db"; +import { db, saveArticle } from "../core/db"; import { newArticle } from "../core/variants"; -import { importMarkdownFiles } from "../core/import"; -import { directoryBackup } from "../core/backup"; import { messageOf, type Article } from "../core/model"; -import { EditorPage } from "./EditorPage"; -import { QueuePage } from "./QueuePage"; -import { DataPage } from "./DataPage"; -import { SettingsPage } from "./SettingsPage"; -import { Alert, Empty, command, timeLabel } from "./shared"; +import { LibraryPage, defaultLibraryView } from "./LibraryPage"; +import { Alert, command, timeLabel } from "./shared"; import { isExtension } from "../platforms/browser-adapter"; -import { sampleMarkdown } from "../core/sample"; +import { getPreferences } from "../core/preferences"; +import { + checkForUpdates, + dismissUpdate, + getUpdateState, + hasUpdate, +} from "../core/updates"; +import type { SettingsTab } from "./SettingsPage"; + +const EditorPage = lazy(() => + import("./EditorPage").then((m) => ({ default: m.EditorPage })), +); +const QueuePage = lazy(() => + import("./QueuePage").then((m) => ({ default: m.QueuePage })), +); +const DataPage = lazy(() => + import("./DataPage").then((m) => ({ default: m.DataPage })), +); +const SettingsPage = lazy(() => + import("./SettingsPage").then((m) => ({ default: m.SettingsPage })), +); type Screen = "library" | "queue" | "metrics" | "comments" | "settings"; + export function App() { - const articles = useLiveQuery( - () => db.articles.orderBy("updatedAt").reverse().toArray(), + const preferences = useLiveQuery(() => getPreferences()); + const update = useLiveQuery(() => getUpdateState()); + const [settingsTab, setSettingsTab] = useState("general"); + const unreadCount = useLiveQuery( + () => db.comments.filter((c) => !c.readAt).count(), [], + 0, + ); + const queuedCount = useLiveQuery( + () => db.tasks.where("state").equals("queued").count(), [], + 0, ); - const comments = useLiveQuery(() => db.comments.toArray(), [], []); - const tasks = useLiveQuery(() => db.tasks.toArray(), [], []); const changedAt = useLiveQuery(() => db.meta.get("dataChangedAt")); const directory = useLiveQuery(() => db.meta.get("backupDirectory")); const backupAt = useLiveQuery(() => db.meta.get("backupCompletedAt")); const coveredAt = useLiveQuery(() => db.meta.get("backupCoveredChangeAt")); const [screen, setScreen] = useState("library"); + const [libraryView, setLibraryView] = useState(defaultLibraryView); const [editing, setEditing] = useState
(null); - const [query, setQuery] = useState(""); - const [archived, setArchived] = useState(false); const [error, setError] = useState(""); const [backupError, setBackupError] = useState(""); - const [busy, setBusy] = useState(false); - const file = useRef(null); - const folder = useRef(null); + const [backupRound, setBackupRound] = useState(0); const backupBusy = useRef(false); const flushEditor = useRef<() => Promise>(async () => true); + const refreshed = useRef(false); const onFlushReady = useCallback((flush: () => Promise) => { flushEditor.current = flush; }, []); const navigate = async (action: () => void) => { - if (await flushEditor.current()) action(); + if (await flushEditor.current()) { + setError(""); + action(); + } }; useEffect(() => { - if (isExtension()) void command({ type: "refresh" }).catch(() => {}); - }, []); + if (!preferences || refreshed.current) return; + refreshed.current = true; + if (preferences.refreshOnOpen && isExtension()) + void command({ type: "refresh" }).catch(() => {}); + }, [preferences]); + useLayoutEffect(() => { + if (!preferences) return; + document.documentElement.style.fontSize = `${preferences.fontSize}px`; + document.documentElement.style.setProperty( + "--editor-font-size", + `${preferences.editorFontSize}px`, + ); + }, [preferences?.fontSize, preferences?.editorFontSize]); + useEffect(() => { + if (preferences) + setLibraryView((view) => ({ + ...view, + layout: preferences.libraryLayout, + })); + }, [preferences?.libraryLayout]); + useEffect(() => { + // Extension checks run in the service worker; the browser preview checks while open. + if (isExtension() || !preferences?.autoCheckUpdates) return; + const check = () => void checkForUpdates().catch(() => {}); + check(); + const timer = setInterval(check, 60_000); + return () => clearInterval(timer); + }, [preferences?.autoCheckUpdates, preferences?.includePrereleases]); useEffect(() => { if ( !directory || !changedAt || - Number(changedAt.value) <= Number(coveredAt?.value ?? 0) + Number(changedAt.value) <= Number(coveredAt?.value ?? 0) || + backupError ) return; const timer = setTimeout(() => { if (backupBusy.current) return; backupBusy.current = true; - void directoryBackup(directory.value as FileSystemDirectoryHandle) + void import("../core/backup") + .then((m) => + m.directoryBackup(directory.value as FileSystemDirectoryHandle), + ) .then(() => setBackupError("")) .catch((e) => setBackupError(messageOf(e))) .finally(() => { backupBusy.current = false; + setBackupRound((n) => n + 1); }); }, 5000); return () => clearTimeout(timer); - }, [changedAt?.value, directory?.value, coveredAt?.value]); + }, [ + changedAt?.value, + directory?.value, + coveredAt?.value, + backupRound, + backupError, + ]); + useEffect(() => setBackupError(""), [directory?.value, backupAt?.value]); const create = async (sample = false) => { if (!(await flushEditor.current())) return; try { + const markdown = sample + ? (await import("../core/sample")).sampleMarkdown + : ""; const article = newArticle( sample ? "用一篇稿件,连接每个平台" : "", - sample ? sampleMarkdown : "", + markdown, ); await saveArticle(article); + setError(""); + setScreen("library"); setEditing(article); } catch (e) { setError(messageOf(e)); } }; - const importFiles = async (files: File[]) => { - setBusy(true); - setError(""); - try { - const imported = await importMarkdownFiles(files); - if (imported[0]) setEditing(imported[0]); - } catch (e) { - setError(messageOf(e)); - } finally { - setBusy(false); - } - }; const nav = [ { id: "library", name: "内容库", icon: BookOpenText }, { id: "queue", name: "发布队列", icon: ListChecks }, { id: "metrics", name: "文章数据", icon: ChartNoAxesCombined }, { id: "comments", name: "评论收件箱", icon: MessageSquareText }, - { id: "settings", name: "平台与备份", icon: Settings2 }, + { id: "settings", name: "设置", icon: Settings2 }, ] as const; - const filtered = articles.filter( - (a) => - a.archived === archived && - (a.title + a.markdown).toLowerCase().includes(query.toLowerCase()), - ); + const updateAvailable = + hasUpdate(update) && + update?.includePrereleases === preferences?.includePrereleases; + if (!preferences) + return ( +
+ 正在打开工作台… +
+ ); return (
- {backupError && {backupError}} - {editing ? ( - setEditing(null)} - onQueue={() => { - setEditing(null); - setScreen("queue"); - }} - /> - ) : screen === "library" ? ( -
-
-
-

写作,从这里继续

-

我的内容库

-

一份母稿,各有表达。把写作与发布放在一起。

-
-
- - - -
-
- {error && {error}} -
-
- - -
- -
- {!filtered.length ? ( - + + 新版本 v{update.release.version} 已发布 + - -
- ) : null + > + 查看更新 + + + -
- -
- {timeLabel(article.updatedAt)} - - {article.imageIds.length} 张配图 - - -
-
- ))} - - )} - { - void importFiles(Array.from(e.target.files ?? [])); - e.target.value = ""; + + + + )} + {error && {error}} + {backupError && ( + + {backupError} + + + )} + + 正在打开工作区… + + } + > + {editing ? ( + setEditing(null)} + onOpenCopy={setEditing} + onQueue={() => { + setEditing(null); + setScreen("queue"); }} /> - { - void importFiles(Array.from(e.target.files ?? [])); - e.target.value = ""; - }} + ) : screen === "library" ? ( + void create(sample)} /> - - ) : screen === "queue" ? ( - - ) : screen === "metrics" ? ( - - ) : screen === "comments" ? ( - - ) : ( - - )} + ) : screen === "queue" ? ( + + ) : screen === "metrics" ? ( + + ) : screen === "comments" ? ( + + ) : ( + + )} + ); diff --git a/src/ui/CardStudio.tsx b/src/ui/CardStudio.tsx index 6866576..d841e6c 100644 --- a/src/ui/CardStudio.tsx +++ b/src/ui/CardStudio.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { Download, Images, LoaderCircle } from "lucide-react"; -import { zipSync } from "fflate"; +import { zipFiles } from "../core/archive"; import { defaultCardOptions, makeCards, type CardOptions } from "../core/cards"; import { downloadBlob } from "../core/assets"; import { messageOf } from "../core/model"; @@ -45,15 +45,20 @@ export function CardStudio({ } }; const download = async () => { - const files: Record = {}; - for (const [index, card] of cards.entries()) - files[`图文-${index + 1}.png`] = new Uint8Array(await card.arrayBuffer()); - downloadBlob( - new Blob([zipSync(files) as Uint8Array], { - type: "application/zip", - }), - "zMatrix-图文.zip", - ); + setBusy(true); + setError(""); + try { + const files: Record = {}; + for (const [index, card] of cards.entries()) + files[`图文-${index + 1}.png`] = new Uint8Array( + await card.arrayBuffer(), + ); + downloadBlob(await zipFiles(files), "zMatrix-图文.zip"); + } catch (e) { + setError(messageOf(e)); + } finally { + setBusy(false); + } }; return ( diff --git a/src/ui/DataPage.tsx b/src/ui/DataPage.tsx index 19ab827..fd53a9d 100644 --- a/src/ui/DataPage.tsx +++ b/src/ui/DataPage.tsx @@ -82,13 +82,7 @@ export function DataPage({ commentsMode = false }: { commentsMode?: boolean }) {
-

已登记的文章

{commentsMode ? "评论收件箱" : "文章数据"}

-

- {commentsMode - ? "集中查看讨论,回复时回到原站。" - : "保留各平台的指标名称、来源和采集时间。"} -

+ + +
+
+
+ + {active.length} 篇稿件 + + + {active.reduce((n, e) => n + e.variants.length, 0)}{" "} + 份平台版本 + + + {published} 篇已登记发布 + +
+ {error && {error}} + {notice && ( +
+ {notice} + {undo && ( + + )} + +
+ )} +
+
+ + +
+ +
+
+ + + + {filtered.length} 篇 +
+ + +
+
+ {!!visible.length && ( +
+ + {!!selectedIds.length && ( + <> + 已选 {selectedIds.length} 篇 + + + + + )} + {busy && ( + + 正在处理… + + )} +
+ )} + {!data ? ( +
+ 正在打开内容库… +
+ ) : !filtered.length ? ( + { + setFilter(defaults); + setPage(1); + setSelected(new Set()); + }} + > + 查看全部稿件 + + ) : ( +
+ + +
+ ) + } + > + {entries.length + ? "可以调整关键词或筛选条件。归档的稿件仍保留所有版本和素材。" + : "导入已有 Markdown,或在这里开始写作。每个平台都能保留自己的标题、正文与配图。"} +
+ ) : ( +
+ {visible.map(({ article, variants, posts }) => { + const linkedChannels = [ + ...new Set([ + ...variants.map((v) => v.channel), + ...posts.map((p) => p.channel), + ]), + ]; + return ( +
+
+ + setSelected((current) => { + const next = new Set(current); + next.has(article.id) + ? next.delete(article.id) + : next.add(article.id); + return next; + }) + } + /> + + 母稿 · v{article.revision} +
+ + +
+
+ +
+ {linkedChannels.slice(0, 3).map((id) => ( + + ))} + {linkedChannels.length > 3 && ( + +{linkedChannels.length - 3} + )} + {!linkedChannels.length && ( + 等待第一份平台版本 + )} + {posts.some((p) => p.status === "published") && ( + 已发布 + )} +
+
+ {timeLabel(article.updatedAt)} + + {article.imageIds.length} 张配图 + + +
+
+ ); + })} +
+ )} + {pages > 1 && ( + + )} + { + importFiles(Array.from(e.target.files ?? [])); + e.target.value = ""; + }} + /> + { + importFiles(Array.from(e.target.files ?? [])); + e.target.value = ""; + }} + /> +
+ ); +} diff --git a/src/ui/Markdown.tsx b/src/ui/Markdown.tsx index 12e0a7b..1f18e02 100644 --- a/src/ui/Markdown.tsx +++ b/src/ui/Markdown.tsx @@ -6,7 +6,8 @@ import { highlightActiveLine, drawSelection, } from "@codemirror/view"; -import { EditorState } from "@codemirror/state"; +import { Annotation, EditorState, Transaction } from "@codemirror/state"; +import { Bold, Italic, Heading2, List, Quote, Code, Link } from "lucide-react"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { markdown } from "@codemirror/lang-markdown"; import { @@ -17,6 +18,48 @@ import { db } from "../core/db"; import { assetIdsIn, blobDataUrl } from "../core/assets"; import { renderMarkdown, renderMermaid } from "../core/render"; import { messageOf } from "../core/model"; +const externalUpdate = Annotation.define(); +type Format = + "bold" | "italic" | "heading" | "list" | "quote" | "code" | "link"; +function format(view: EditorView, kind: Format) { + const range = view.state.selection.main; + const selected = view.state.sliceDoc(range.from, range.to); + const wrappers = { + bold: ["**", "**"], + italic: ["*", "*"], + code: ["`", "`"], + link: ["[", "](https://example.com)"], + } as const; + if (kind === "heading" || kind === "list" || kind === "quote") { + const from = view.state.doc.lineAt(range.from).from; + const to = view.state.doc.lineAt(range.to).to; + const prefix = kind === "heading" ? "## " : kind === "list" ? "- " : "> "; + const insert = view.state + .sliceDoc(from, to) + .split("\n") + .map((line) => prefix + line) + .join("\n"); + view.dispatch({ + changes: { from, to, insert }, + selection: { anchor: from + prefix.length, head: from + insert.length }, + }); + } else { + const [before, after] = wrappers[kind]; + const text = selected || "文字"; + view.dispatch({ + changes: { + from: range.from, + to: range.to, + insert: before + text + after, + }, + selection: { + anchor: range.from + before.length, + head: range.from + before.length + text.length, + }, + }); + } + view.focus(); +} export function MarkdownEditor({ value, onChange, @@ -43,15 +86,35 @@ export function MarkdownEditor({ history(), drawSelection(), highlightActiveLine(), - keymap.of([...defaultKeymap, ...historyKeymap]), + keymap.of([ + { + key: "Mod-b", + run: (view) => { + format(view, "bold"); + return true; + }, + }, + { + key: "Mod-i", + run: (view) => { + format(view, "italic"); + return true; + }, + }, + ...defaultKeymap, + ...historyKeymap, + ]), markdown(), syntaxHighlighting(defaultHighlightStyle), EditorView.lineWrapping, EditorView.contentAttributes.of({ "aria-label": "Markdown 正文" }), EditorView.updateListener.of((update) => { - if (update.docChanged) + if ( + update.docChanged && + !update.transactions.some((t) => t.annotation(externalUpdate)) + ) callbacks.current.onChange(update.state.doc.toString()); - if (update.selectionSet) { + if (update.selectionSet || update.docChanged) { const range = update.state.selection.main; callbacks.current.onSelect( update.state.sliceDoc(range.from, range.to), @@ -85,34 +148,80 @@ export function MarkdownEditor({ if (view && view.state.doc.toString() !== value) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, + annotations: [ + externalUpdate.of(true), + Transaction.addToHistory.of(false), + ], }); }, [value]); - return
; + const formats = [ + { id: "bold", name: "加粗(Ctrl+B)", icon: Bold }, + { id: "italic", name: "斜体(Ctrl+I)", icon: Italic }, + { id: "heading", name: "二级标题", icon: Heading2 }, + { id: "list", name: "无序列表", icon: List }, + { id: "quote", name: "引用", icon: Quote }, + { id: "code", name: "行内代码", icon: Code }, + { id: "link", name: "插入链接", icon: Link }, + ] as const; + return ( + <> +
+ {formats.map((item) => ( + + ))} + Markdown +
+
+ + ); } export function MarkdownPreview({ value }: { value: string }) { const host = useRef(null); const [error, setError] = useState(""); + const cachedImages = useRef(new Map()); useEffect(() => { let stopped = false; const timeout = setTimeout(() => { void (async () => { try { + const ids = assetIdsIn(value); const urls = new Map(); - for (const id of assetIdsIn(value)) { - const asset = await db.assets.get(id); - if (asset) urls.set(id, await blobDataUrl(asset.blob)); - } + const assets = await db.assets.bulkGet(ids); + await Promise.all( + assets.map(async (asset) => { + if (asset) + urls.set( + asset.id, + cachedImages.current.get(asset.id) ?? + (await blobDataUrl(asset.blob)), + ); + }), + ); const html = await renderMarkdown(value, urls); + if (stopped) return; + const rendered = document.createElement("div"); + rendered.innerHTML = html; + await renderMermaid(rendered); if (!stopped && host.current) { - host.current.innerHTML = html; - await renderMermaid(host.current); + cachedImages.current = urls; + host.current.replaceChildren(...Array.from(rendered.childNodes)); setError(""); } } catch (error) { if (!stopped) setError(messageOf(error)); } })(); - }, 120); + }, 200); return () => { stopped = true; clearTimeout(timeout); diff --git a/src/ui/PublishDialog.tsx b/src/ui/PublishDialog.tsx index ca7c9ae..5619d4a 100644 --- a/src/ui/PublishDialog.tsx +++ b/src/ui/PublishDialog.tsx @@ -116,10 +116,6 @@ export function PublishDialog({ ))}
-

- 使用 Edge - 中当前登录账号。准备发布会停在最终提交前,由你在原站手动发布。未核对齐全的设置会单独提示。 -

{error && {error}} {!!prepared.length && ( diff --git a/src/ui/QueuePage.tsx b/src/ui/QueuePage.tsx index 244037a..ee69fac 100644 --- a/src/ui/QueuePage.tsx +++ b/src/ui/QueuePage.tsx @@ -1,11 +1,34 @@ import { useState } from "react"; import { useLiveQuery } from "dexie-react-hooks"; -import { Play, Pause, ExternalLink, RefreshCw } from "lucide-react"; +import { Play, Pause, ExternalLink, RefreshCw, Search } from "lucide-react"; import { db } from "../core/db"; -import { canStart, patchTask, stateNames } from "../core/tasks"; +import { + canStart, + cancelTask, + hasRemoteActivity, + stateNames, +} from "../core/tasks"; import { messageOf } from "../core/model"; import { Alert, Empty, PlatformPill, command, timeLabel } from "./shared"; import { isExtension } from "../platforms/browser-adapter"; +import { channels } from "../platforms/catalog"; +const taskGroups = { + all: { label: "全部任务", states: [] as string[] }, + pending: { label: "待执行", states: ["queued", "paused"] }, + active: { label: "执行中", states: ["preparing", "submitting", "verifying"] }, + attention: { + label: "需要处理", + states: [ + "failed", + "uncertain", + "awaiting_publish", + "awaiting_review", + "submitted", + "reviewing", + ], + }, + done: { label: "已完成", states: ["draft_saved", "published", "cancelled"] }, +}; export function QueuePage() { const tasks = useLiveQuery( () => db.tasks.orderBy("createdAt").reverse().toArray(), @@ -15,6 +38,19 @@ export function QueuePage() { const queueError = useLiveQuery(() => db.meta.get("queueError")); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const [group, setGroup] = useState("all"); + const [channel, setChannel] = useState("all"); + const [query, setQuery] = useState(""); + const [limit, setLimit] = useState(30); + const filtered = tasks.filter( + (task) => + (group === "all" || taskGroups[group].states.includes(task.state)) && + (channel === "all" || task.channel === channel) && + task.snapshot.title + .toLocaleLowerCase() + .includes(query.trim().toLocaleLowerCase()), + ); + const runnable = filtered.filter(canStart); const act = async (message: unknown) => { setBusy(true); setError(""); @@ -30,43 +66,91 @@ export function QueuePage() {
-

发布进度

任务队列

-

稿件版本固定保存。准备发布停在最终提交前,由你在原站手动完成。

-
{!!(error || queueError?.value) && ( {error || String(queueError?.value)} )} +
+
+ {Object.entries(taskGroups).map(([id, item]) => ( + + ))} +
+
+ + +
+
{!tasks.length ? ( 在稿件中打开“发布预览”,选择平台后加入队列。 + ) : !filtered.length ? ( + 调整平台、状态或关键词后再试。 ) : (
- {tasks.map((task) => ( + {filtered.slice(0, limit).map((task) => (
@@ -113,14 +197,17 @@ export function QueuePage() { "reviewing", "awaiting_publish", "awaiting_review", - ].includes(task.state) && ( + ].includes(task.state) || + (["failed", "cancelled"].includes(task.state) && + hasRemoteActivity(task)) ? ( - )} + ) : null} {(task.remoteUrl || task.editorUrl) && ( )} - {["queued", "paused", "failed"].includes(task.state) && ( + {canStart(task) && (
)} + {filtered.length > limit && ( +
+ + + 已显示 {limit} / {filtered.length} 条 + +
+ )}
); } diff --git a/src/ui/SettingsPage.tsx b/src/ui/SettingsPage.tsx index 171e2b1..83a25b8 100644 --- a/src/ui/SettingsPage.tsx +++ b/src/ui/SettingsPage.tsx @@ -7,6 +7,10 @@ import { ExternalLink, Plug, ScanLine, + SlidersHorizontal, + RefreshCw, + Type, + Check, } from "lucide-react"; import { db } from "../core/db"; import { @@ -22,7 +26,28 @@ import { connectChannel, isExtension } from "../platforms/browser-adapter"; import { messageOf } from "../core/model"; import { Alert, Modal, PlatformPill, command, timeLabel } from "./shared"; import { version } from "../../package.json"; -export function SettingsPage() { +import { + defaultPreferences, + savePreferences, + type Preferences, +} from "../core/preferences"; +import { UpdateSettings } from "./UpdateSettings"; +export type SettingsTab = "general" | "platforms" | "backup" | "updates"; +const settingsTabs = [ + { id: "general", label: "通用偏好", icon: SlidersHorizontal }, + { id: "platforms", label: "平台连接", icon: Plug }, + { id: "backup", label: "数据备份", icon: FolderOpen }, + { id: "updates", label: "软件更新", icon: RefreshCw }, +] as const; +export function SettingsPage({ + preferences, + tab, + onTabChange, +}: { + preferences: Preferences; + tab: SettingsTab; + onTabChange: (tab: SettingsTab) => void; +}) { const probes = useLiveQuery(() => db.probes.toArray(), [], []); const backup = useLiveQuery(() => db.meta.get("backupCompletedAt")); const directory = useLiveQuery(() => db.meta.get("backupDirectory")); @@ -57,176 +82,348 @@ export function SettingsPage() { await directoryBackup(handle); }, "备份目录已设置,首次备份已完成。"); }; + const changePreference = (patch: Partial) => { + setError(""); + void savePreferences(patch) + .then(async () => { + if ( + isExtension() && + ("autoCheckUpdates" in patch || "includePrereleases" in patch) + ) + await command({ type: "configureUpdates" }); + }) + .catch((e) => setError(messageOf(e))); + }; return ( -
+
-

连接与保管

-

平台与备份

-

登录保留在浏览器,稿件和素材保留在本机。

+

设置

v{version} · 开发预览
+ {error && {error}} {notice && (
{notice}
)} - {!isExtension() && ( - - 当前为网页界面预览。平台操作需要加载 Edge - 扩展;此处的本地数据与扩展数据相互独立。 - + {!isExtension() && tab === "platforms" && ( + 平台连接需要在 Edge 扩展中使用。 )} -
-
-

平台连接

-

连接时只申请该平台的访问权限。“检查页面”只读,不填稿、不发布。

-
-
- {channels.map((channel) => { - const probe = probes.find((p) => p.channel === channel.id); - return ( -
-
- -

{channel.name}

-

- {channel.manual - ? "人工发布辅助" - : probe?.problems.length - ? probe.problems.join(";") - : probe?.editorFound - ? "已识别编辑器 · 发布结果尚待实测" - : "尚未检查编辑器"} -

- - {probe - ? `检查于 ${timeLabel(probe.checkedAt)}${probe.account ? " · " + probe.account : ""}` - : "草稿、发布、数据、评论分别验收"} - + {tab === "general" && ( + <> +
+
+

+ + 字体与阅读 +

+ + + 自动保存 + +
+
+
+
+ + + {preferences.fontSize} + px + + + changePreference({ fontSize: Number(e.target.value) }) + } + /> +
+ 14 + 标准 16 + 22 +
-
- - 原站 - - - - + /> +
+ 14 + 标准 17 + 26 +
- ); - })} -
-
-
-
-

文件夹备份

-

- 本地自动保存独立运行。目录备份保留当前与上一份自动备份,手动导出生成独立文件。 -

-
-
- -
-

{directory ? "已选择备份目录" : "尚未设置备份目录"}

-

最近完成:{timeLabel(backup?.value as number | undefined)}

-
- - -
-
-
+
+
- - 导出完整备份 - - - { - const file = e.target.files?.[0]; - if (file) - void act(async () => { - const data = await inspectBackup(file); - setRestore({ - file, - articles: data.articles.length, - assets: data.assets.length, +
+

工作区偏好

+
+ + + +
+ +
+
+ + )} + {tab === "updates" && ( + + )} + {tab === "platforms" && ( +
+
+

平台连接

+
+
+ {channels.map((channel) => { + const probe = probes.find((p) => p.channel === channel.id); + return ( +
+
+ +

{channel.name}

+

+ {channel.manual + ? "人工发布辅助" + : probe?.problems.length + ? probe.problems.join(";") + : probe?.editorFound + ? "已识别编辑器" + : "尚未检查编辑器"} +

+ {probe && ( + + 检查于 {timeLabel(probe.checkedAt)} + {probe.account ? " · " + probe.account : ""} + + )} +
+
+ + 原站 + + + + +
+
+ ); + })} +
+
+ )} + {tab === "backup" && ( +
+
+

文件夹备份

+
+
+ +
+

{directory ? "已选择备份目录" : "尚未设置备份目录"}

+

最近完成:{timeLabel(backup?.value as number | undefined)}

+
+ + +
+
+ + + { + const file = e.target.files?.[0]; + if (file) + void act(async () => { + const data = await inspectBackup(file); + setRestore({ + file, + articles: data.articles.length, + assets: data.assets.length, + }); }); - }); - e.target.value = ""; - }} - /> -
-
-
-
-

验收状态

-

- 当前验收为 6 条草稿保存与 6 - 条发布前准备流程。最终发布由你在原站手动完成;五站数据与评论另行验证。 -

-
-

- 首次打开工作台刷新已登记文章;关闭后不定期采集。来源指标缺失时保留为空,不补零。 -

-
+ e.target.value = ""; + }} + /> +
+
+ )} {restore && ( setRestore(null)}>
diff --git a/src/ui/UpdateSettings.tsx b/src/ui/UpdateSettings.tsx new file mode 100644 index 0000000..2929fc9 --- /dev/null +++ b/src/ui/UpdateSettings.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; +import { + ArrowUpRight, + Check, + Download, + RefreshCw, + PackageOpen, +} from "lucide-react"; +import { version } from "../../package.json"; +import { + checkForUpdates, + getUpdateState, + hasUpdate, + RELEASES_URL, +} from "../core/updates"; +import type { Preferences } from "../core/preferences"; +import { isExtension } from "../platforms/browser-adapter"; +import { messageOf } from "../core/model"; +import { Alert, command, timeLabel } from "./shared"; + +export function UpdateSettings({ + preferences, + onChange, +}: { + preferences: Preferences; + onChange: (patch: Partial) => void; +}) { + const state = useLiveQuery(() => getUpdateState()); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const release = state?.release; + const channelMatches = + state?.includePrereleases === preferences.includePrereleases; + const available = channelMatches && hasUpdate(state); + const check = async () => { + setBusy(true); + setError(""); + try { + if (isExtension()) await command({ type: "checkUpdates", manual: true }); + else await checkForUpdates({ manual: true }); + } catch (e) { + setError(messageOf(e)); + } finally { + setBusy(false); + } + }; + return ( +
+
+

软件更新

+
+
+
+ {available ? : } +
+
+

+ {busy + ? "正在检查更新…" + : available + ? `发现新版本 v${release!.version}` + : state?.error + ? "暂时无法检查更新" + : state?.checkedAt && channelMatches + ? release + ? "已是最新版本" + : "暂无可用版本" + : "检查新版本"} +

+

+ 当前版本 v{version} + · + {state?.checkedAt + ? `最近检查 ${timeLabel(state.checkedAt)}` + : "尚未检查"} +

+
+ +
+ {(error || state?.error) && {error || state?.error}} + + + {available && release && ( +
+
+

{release.name}

+ + {release.prerelease ? "开发预览" : "正式版"} + +
+ {release.notes && ( +
+ 查看更新内容 +
{release.notes}
+
+ )} + +
+ )} +
+ + 发现新版本时提醒,由你决定是否下载。 +
+
+ 如何安装更新 +
    +
  1. 在“数据备份”中导出一份备份。
  2. +
  3. 下载扩展包并解压,替换原来的扩展目录,保留相同的扩展身份。
  4. +
  5. + 打开 Edge 扩展管理页,点击 zMatrix + 的“重新加载”,再打开工作台。不要卸载扩展,以免清除数据。 +
  6. +
+
+ + 所有发布版本 + + +
+ ); +} diff --git a/src/ui/styles.css b/src/ui/styles.css index ac4073d..925e772 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -1,22 +1,27 @@ :root { font-family: "Segoe UI Variable", "Microsoft YaHei UI", "Microsoft YaHei", sans-serif; - color: #24304b; - background: #f4f7fb; + font-size: 16px; + color: #1f2d46; + background: #f3f6fb; font-synthesis: none; - --ink: #24304b; - --muted: #738099; - --blue: #355beb; - --line: #e1e7f0; - --soft: #edf2fd; - --teal: #198477; + --ink: #1f2d46; + --muted: #65748b; + --blue: #2d64e8; + --line: #dfe6f1; + --soft: #edf3ff; + --teal: #167965; --amber: #9d671e; + --editor-font-size: 17px; + --shadow: 0 4px 22px #1b365c06; + text-rendering: optimizeLegibility; } * { box-sizing: border-box; } body { margin: 0; + line-height: 1.6; } button, input, @@ -33,14 +38,14 @@ button { align-items: center; justify-content: center; gap: 7px; - min-height: 36px; - padding: 8px 13px; + min-height: 2.625rem; + padding: 0.55rem 0.9rem; border: 1px solid var(--line); - border-radius: 8px; + border-radius: 10px; background: white; color: var(--ink); cursor: pointer; - font-size: 13px; + font-size: 1rem; font-weight: 550; transition: background 0.15s, @@ -68,16 +73,16 @@ input, select, textarea { border: 1px solid #dce3ef; - border-radius: 7px; - padding: 9px 11px; + border-radius: 9px; + padding: 0.6rem 0.8rem; background: #fff; color: var(--ink); min-width: 0; } input[type="checkbox"] { accent-color: var(--blue); - width: 15px; - height: 15px; + width: 18px; + height: 18px; padding: 0; } input[type="range"] { @@ -105,6 +110,7 @@ textarea:focus-visible { background: var(--blue); border-color: var(--blue); color: white; + box-shadow: 0 3px 8px #2d64e81a; } .primary:hover { background: #284bd1; @@ -121,20 +127,20 @@ textarea:focus-visible { background: transparent; } .text-button { - font-size: 12px; + font-size: 0.9375rem; padding: 4px 7px; min-height: 28px; color: var(--blue); } .muted { color: var(--muted); - font-size: 12px; + font-size: 0.9375rem; line-height: 1.7; } .error-text, .warning-text { color: var(--amber); - font-size: 12px; + font-size: 0.9375rem; line-height: 1.7; } .app-shell { @@ -142,86 +148,117 @@ textarea:focus-visible { min-height: 100vh; } .sidebar { - width: 218px; + width: clamp(232px, 16rem, 290px); flex-shrink: 0; - background: #fafbfe; - border-right: 1px solid var(--line); + background: #172944; + color: #d5e0f1; + border-right: 1px solid #172944; height: 100vh; position: sticky; top: 0; - padding: 29px 18px 22px; + padding: 32px 18px 24px; display: flex; flex-direction: column; + overflow-y: auto; } .brand { - color: var(--ink); - gap: 11px; - margin: 0 9px 32px; - font-size: 25px; - letter-spacing: 2px; - font-weight: 750; + color: #f4f7ff; + gap: 12px; + margin: 0 10px 38px; + font-size: 1.875rem; + letter-spacing: -0.5px; + font-weight: 600; + font-family: "Bahnschrift", "Segoe UI", sans-serif; } .brand:hover { text-decoration: none; } .brand-mark { - width: 43px; - height: 43px; + width: 46px; + height: 46px; display: block; flex-shrink: 0; object-fit: contain; } .brand small { display: block; - font-size: 10px; - letter-spacing: 3px; - color: #8b96aa; + font-size: 0.8125rem; + letter-spacing: 2px; + color: #9fb1cd; + font-family: "Microsoft YaHei UI", sans-serif; font-weight: 500; margin-top: 2px; } .new-article { - margin: 0 4px 29px; - min-height: 42px; - background: #eaf0fe; - color: var(--blue); + margin: 0 6px 32px; + min-height: 3rem; + background: #326bea; + color: #fff; border: 0; + box-shadow: 0 6px 18px #071a3b40; +} +.new-article:hover { + background: #437bf5; } .sidebar nav { display: grid; - gap: 6px; + gap: 8px; } .sidebar nav button { justify-content: flex-start; background: transparent; border: 0; gap: 12px; - color: #6e7b94; - height: 43px; - font-size: 13px; + color: #b9c8df; + min-height: 3rem; + font-size: 1rem; +} +.sidebar nav button:hover { + background: #223a5d; + color: white; } .sidebar nav button.active { - color: var(--blue); - background: #edf2ff; + color: #fff; + background: #2b4267; + box-shadow: inset 3px 0 #77a8ff; } .sidebar nav button b { - font-size: 10px; + font-size: 0.8125rem; margin-left: auto; font-weight: 500; border-radius: 5px; - background: #dbe5fe; + background: #395783; padding: 2px 5px; } .sidebar-foot { margin-top: auto; padding: 20px 10px 0; - border-top: 1px solid var(--line); - font-size: 12px; + border-top: 1px solid #31425f; + font-size: 0.9375rem; } .sidebar-foot p { - color: var(--muted); - font-size: 11px; + color: #9eafc9; + font-size: 0.875rem; line-height: 1.7; } +.sidebar-foot .text-button { + color: #b6ceff; + padding-left: 0; +} +.sidebar-foot .text-button:hover { + background: transparent; + color: white; +} +.sidebar nav .settings-nav { + margin-top: 26px; +} +.nav-update-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #78d9ba; + margin-left: auto; +} .sidebar-foot > div { display: flex; align-items: center; @@ -240,7 +277,7 @@ textarea:focus-visible { flex: 1; } .page { - padding: 42px 40px 50px; + padding: 44px clamp(24px, 3.2vw, 56px) 56px; max-width: 1600px; margin: auto; } @@ -249,22 +286,23 @@ textarea:focus-visible { align-items: center; justify-content: space-between; gap: 20px; - margin-bottom: 32px; + margin-bottom: 30px; + flex-wrap: wrap; } .eyebrow { - font-size: 11px; + font-size: 0.875rem; letter-spacing: 2px; color: #8996ac; margin: 0 0 11px; } .page-heading h1 { - font-size: 30px; - font-weight: 680; - letter-spacing: 0.2px; - margin: 0 0 10px; + font-size: 2.25rem; + font-weight: 700; + letter-spacing: -0.8px; + margin: 0; } .page-heading p:last-child { - font-size: 13px; + font-size: 1rem; color: var(--muted); margin: 0; line-height: 1.7; @@ -304,7 +342,7 @@ textarea:focus-visible { border-bottom-color: var(--blue); } .tabs span { - font-size: 11px; + font-size: 0.875rem; border-radius: 5px; background: #e5ebf8; padding: 1px 5px; @@ -312,33 +350,37 @@ textarea:focus-visible { .search-box { display: flex; align-items: center; - color: #98a4b8; + color: #65748b; gap: 8px; + padding: 5px 12px; + border: 1px solid var(--line); + background: white; + border-radius: 10px; } .search-box input { background: transparent; border: 0; width: 190px; - font-size: 12px; + font-size: 0.9375rem; padding: 7px 0; } .article-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(275px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); gap: 20px; } .article-card { border: 1px solid var(--line); - border-radius: 11px; + border-radius: 15px; background: white; overflow: hidden; - box-shadow: 0 3px 5px #23335c03; + box-shadow: var(--shadow); transition: box-shadow 0.18s, transform 0.18s; } .article-card:hover { - box-shadow: 0 8px 25px #23335c09; + box-shadow: 0 10px 30px #23335c0e; transform: translateY(-2px); } .article-card-top { @@ -349,8 +391,8 @@ textarea:focus-visible { color: #6f86bc; } .article-card-top span { - font-size: 11px; - color: #97a2b5; + font-size: 0.875rem; + color: var(--muted); } .article-card-top .icon-button { margin-left: auto; @@ -382,15 +424,15 @@ textarea:focus-visible { background: #fcfdff; } .article-link h2 { - font-size: 18px; + font-size: 1.25rem; line-height: 1.5; margin: 0 0 13px; font-weight: 650; overflow-wrap: anywhere; } .article-link p { - font-size: 12px; - color: #8b97ab; + font-size: 0.9375rem; + color: var(--muted); line-height: 1.9; display: -webkit-box; -webkit-line-clamp: 3; @@ -404,8 +446,9 @@ textarea:focus-visible { align-items: center; border-top: 1px solid #f0f3f8; padding: 12px 20px; - font-size: 10px; - color: #8b97ab; + font-size: 0.8125rem; + color: var(--muted); + background: #fafbfe; } .article-card footer span { display: flex; @@ -420,12 +463,15 @@ textarea:focus-visible { justify-content: center; text-align: center; padding: 50px 25px; + border: 1px dashed #cbd7e9; + border-radius: 18px; + background: #ffffffb3; } .empty-mark { - font-size: 33px; + font-size: 2.375rem; color: #7992cb; border: 1px solid #cedaf2; - background: linear-gradient(150deg, #fcfdff, #e8effe); + background: #edf3ff; width: 80px; height: 92px; display: grid; @@ -437,12 +483,12 @@ textarea:focus-visible { margin-bottom: 24px; } .empty-state h2 { - font-size: 21px; + font-size: 1.5rem; font-weight: 630; margin: 12px 0; } .empty-state p { - font-size: 13px; + font-size: 1rem; line-height: 1.9; color: var(--muted); max-width: 470px; @@ -458,7 +504,7 @@ textarea:focus-visible { display: flex; gap: 10px; align-items: flex-start; - font-size: 12px; + font-size: 0.9375rem; line-height: 1.7; overflow-wrap: anywhere; } @@ -476,7 +522,7 @@ textarea:focus-visible { padding: 12px 15px; margin-bottom: 18px; border-radius: 8px; - font-size: 13px; + font-size: 1rem; } .editor-page { background: white; @@ -486,7 +532,8 @@ textarea:focus-visible { display: flex; align-items: center; gap: 10px; - padding: 16px 28px; + padding: 20px 30px; + flex-wrap: wrap; border-bottom: 1px solid var(--line); } .save-state { @@ -494,7 +541,7 @@ textarea:focus-visible { align-items: center; gap: 6px; color: var(--muted); - font-size: 11px; + font-size: 0.875rem; margin-right: auto; } .save-state i.pending { @@ -511,7 +558,7 @@ textarea:focus-visible { .version-strip button { flex: 1; min-width: 106px; - min-height: 68px; + min-height: 78px; position: relative; border: 0; border-bottom: 2px solid transparent; @@ -521,7 +568,7 @@ textarea:focus-visible { align-items: center; flex-wrap: wrap; gap: 5px 7px; - font-size: 12px; + font-size: 0.9375rem; padding: 15px 10px 10px; } .version-strip button i, @@ -535,7 +582,7 @@ textarea:focus-visible { .version-strip button span { display: block; width: 100%; - font-size: 10px; + font-size: 0.8125rem; font-weight: 400; color: #99a4b7; } @@ -559,24 +606,24 @@ textarea:focus-visible { background: #fff9ec; padding: 9px 30px; color: #91642c; - font-size: 12px; + font-size: 0.9375rem; } .version-notice button { min-height: 26px; padding: 4px 8px; - font-size: 11px; + font-size: 0.875rem; background: transparent; border-color: #e8dcc5; } .title-row { - padding: 24px 30px 15px; + padding: 32px 32px 22px; display: flex; align-items: center; } .article-title { border: 0; outline: none !important; - font-size: 27px; + font-size: 2rem; line-height: 1.5; font-weight: 650; padding: 0; @@ -585,19 +632,19 @@ textarea:focus-visible { } .metadata-row { display: flex; - gap: 15px; + gap: 18px; padding: 0 30px 20px; } .metadata-row label { - font-size: 10px; - color: #8190a8; + font-size: 0.8125rem; + color: var(--muted); display: flex; flex-direction: column; gap: 6px; flex: 1; } .metadata-row input { - font-size: 12px; + font-size: 0.9375rem; padding: 7px 8px; background: #fbfcfe; } @@ -610,7 +657,7 @@ textarea:focus-visible { gap: 15px; padding: 9px 30px; background: #f9f7f1; - font-size: 12px; + font-size: 0.9375rem; } .manual-tools span { margin-right: auto; @@ -630,14 +677,14 @@ textarea:focus-visible { background: #fcfdff; } .writing-grid header { - height: 40px; + min-height: 44px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 0 18px; - color: #8794aa; - font-size: 10px; + color: var(--muted); + font-size: 0.8125rem; letter-spacing: 0.5px; border-bottom: 1px solid #eff2f7; } @@ -647,16 +694,17 @@ textarea:focus-visible { gap: 5px; } .writing-grid header .text-button { - font-size: 10px; + font-size: 0.8125rem; min-height: 24px; } .markdown-editor .cm-editor { min-height: 490px; - font-family: Consolas, "Microsoft YaHei", monospace; - font-size: 13px; + font-family: "Cascadia Code", Consolas, "Microsoft YaHei", monospace; + font-size: var(--editor-font-size); line-height: 1.85; } .markdown-editor .cm-scroller { + font-family: inherit; max-height: 720px; overflow: auto; } @@ -666,8 +714,8 @@ textarea:focus-visible { .markdown-editor .cm-gutters { background: transparent; border: 0; - color: #b9c4d6; - font-size: 10px; + color: #8291aa; + font-size: 0.8125rem; } .markdown-editor .cm-activeLine, .markdown-editor .cm-activeLineGutter { @@ -684,26 +732,26 @@ textarea:focus-visible { } .article-prose { font-family: "Segoe UI", "Microsoft YaHei", sans-serif; - font-size: 14px; + font-size: var(--editor-font-size); line-height: 1.9; color: #34415b; overflow-wrap: anywhere; } .article-prose h1 { - font-size: 25px; + font-size: 1.8em; line-height: 1.5; margin: 8px 0 22px; color: #24304b; font-weight: 700; } .article-prose h2 { - font-size: 19px; + font-size: 1.4em; color: #2d3b58; line-height: 1.6; margin: 28px 0 12px; } .article-prose h3 { - font-size: 16px; + font-size: 1.15em; margin: 23px 0 10px; } .article-prose p { @@ -805,11 +853,11 @@ textarea:focus-visible { } .attachments h3, .publication-list h3 { - font-size: 13px; + font-size: 1rem; margin: 0; } .attachments h3 span { - font-size: 11px; + font-size: 0.875rem; color: #96a1b5; margin-left: 7px; } @@ -832,7 +880,7 @@ textarea:focus-visible { background: #f0f3f9; } .asset-tile > span { - font-size: 10px; + font-size: 0.8125rem; white-space: nowrap; overflow: hidden; display: block; @@ -846,7 +894,7 @@ textarea:focus-visible { gap: 1px; } .asset-tile button { - font-size: 9px; + font-size: 0.75rem; min-height: 22px; padding: 3px 5px; border: 0; @@ -858,7 +906,7 @@ textarea:focus-visible { background: #fcfdff; color: #8ca1cc; flex-direction: column; - font-size: 11px; + font-size: 0.875rem; } .publication-list { border-top: 1px solid var(--line); @@ -869,7 +917,7 @@ textarea:focus-visible { gap: 12px; padding: 14px 0; border-bottom: 1px solid #eef1f7; - font-size: 11px; + font-size: 0.875rem; flex-wrap: wrap; } .publication-row > span:nth-child(2) { @@ -881,7 +929,7 @@ textarea:focus-visible { gap: 6px; color: #52637f; white-space: nowrap; - font-size: 11px; + font-size: 0.875rem; } .platform-pill i { width: 6px; @@ -922,7 +970,7 @@ textarea:focus-visible { border-bottom: 1px solid var(--line); } .modal > header h2 { - font-size: 17px; + font-size: 1.1875rem; margin: 0; font-weight: 650; } @@ -955,7 +1003,7 @@ textarea:focus-visible { gap: 16px; } .form-stack p { - font-size: 13px; + font-size: 1rem; line-height: 1.8; margin: 0; } @@ -963,7 +1011,7 @@ textarea:focus-visible { .studio-options label { display: grid; gap: 7px; - font-size: 12px; + font-size: 0.9375rem; color: #6b7b94; } .form-stack input, @@ -974,7 +1022,7 @@ textarea:focus-visible { display: flex !important; align-items: center; gap: 7px; - font-size: 12px; + font-size: 0.9375rem; } .card-studio { display: grid; @@ -1008,12 +1056,12 @@ textarea:focus-visible { min-height: 350px; } .studio-placeholder h3 { - font-size: 17px; + font-size: 1.1875rem; color: #5b709c; font-weight: 550; } .studio-placeholder p { - font-size: 12px; + font-size: 0.9375rem; } .card-grid { display: grid; @@ -1034,6 +1082,7 @@ textarea:focus-visible { z-index: -1; } .image-card { + line-height: normal; width: 540px; height: 720px; box-sizing: border-box; @@ -1119,7 +1168,7 @@ textarea:focus-visible { } .preview-gallery figcaption { text-align: center; - font-size: 12px; + font-size: 0.9375rem; color: var(--muted); } .image-card.template-technical { @@ -1154,6 +1203,7 @@ textarea:focus-visible { margin-bottom: 30px; } .raster-surface { + --editor-font-size: 14px; width: 760px; padding: 24px; background: white; @@ -1194,7 +1244,7 @@ textarea:focus-visible { display: flex; align-items: center; gap: 8px; - font-size: 12px; + font-size: 0.9375rem; padding: 12px; cursor: pointer; } @@ -1207,7 +1257,7 @@ textarea:focus-visible { border-top: 1px solid var(--line); } .publish-preview > h2 { - font-size: 23px; + font-size: 1.625rem; } .publish-preview .preview-content { max-height: 400px; @@ -1220,9 +1270,10 @@ textarea:focus-visible { } .task-card { border: 1px solid var(--line); - border-radius: 10px; + border-radius: 14px; background: white; padding: 20px 24px; + box-shadow: var(--shadow); } .task-card header { display: flex; @@ -1233,15 +1284,15 @@ textarea:focus-visible { margin-left: auto; } .task-card h3 { - font-size: 17px; + font-size: 1.1875rem; margin: 15px 0 7px; } .task-card > p { - font-size: 12px; + font-size: 0.9375rem; color: var(--muted); } .status { - font-size: 10px; + font-size: 0.8125rem; padding: 3px 7px; border-radius: 5px; background: #edf2f9; @@ -1270,13 +1321,13 @@ textarea:focus-visible { align-items: center; gap: 10px; margin-top: 16px; - font-size: 12px; + font-size: 0.9375rem; } .task-card details { border-top: 1px solid #edf1f7; margin-top: 16px; padding-top: 11px; - font-size: 11px; + font-size: 0.875rem; color: #8b98ac; } .task-card summary { @@ -1290,7 +1341,7 @@ textarea:focus-visible { } .events time { margin-right: 12px; - font-size: 10px; + font-size: 0.8125rem; color: #a0aabd; } .filter-bar { @@ -1301,7 +1352,7 @@ textarea:focus-visible { flex-wrap: wrap; } .filter-bar select { - font-size: 12px; + font-size: 0.9375rem; max-width: 250px; } .filter-bar button { @@ -1314,7 +1365,7 @@ textarea:focus-visible { } .metrics-card { border: 1px solid var(--line); - border-radius: 10px; + border-radius: 14px; background: #fff; padding: 20px; } @@ -1322,7 +1373,7 @@ textarea:focus-visible { display: flex; align-items: center; gap: 10px; - font-size: 13px; + font-size: 1rem; } .metrics-card header > a { color: var(--ink); @@ -1346,15 +1397,15 @@ textarea:focus-visible { gap: 7px; } .metrics-values span { - font-size: 10px; + font-size: 0.8125rem; color: var(--muted); } .metrics-values strong { - font-size: 24px; + font-size: 1.75rem; font-weight: 650; } .metrics-card footer { - font-size: 10px; + font-size: 0.8125rem; color: #97a2b7; display: flex; justify-content: space-between; @@ -1366,7 +1417,7 @@ textarea:focus-visible { gap: 8px; } .sync-summaries > div { - font-size: 12px; + font-size: 0.9375rem; padding: 12px 15px; background: white; border: 1px solid var(--line); @@ -1393,7 +1444,7 @@ textarea:focus-visible { border: 1px solid #d8e2f6; border-left: 3px solid #859feb; background: #fff; - border-radius: 8px; + border-radius: 12px; } .comment-card.read { border-left-color: #dfe5ef; @@ -1402,7 +1453,7 @@ textarea:focus-visible { display: flex; align-items: center; gap: 11px; - font-size: 12px; + font-size: 0.9375rem; } .comment-avatar { width: 29px; @@ -1412,15 +1463,15 @@ textarea:focus-visible { border-radius: 8px; display: grid; place-items: center; - font-size: 13px; + font-size: 1rem; } .comment-card header .muted { margin-left: auto; - font-size: 10px; + font-size: 0.8125rem; } .comment-body { white-space: pre-wrap; - font-size: 14px; + font-size: 1.0625rem; line-height: 1.9; margin: 15px 0 17px; } @@ -1428,7 +1479,7 @@ textarea:focus-visible { display: flex; align-items: center; gap: 12px; - font-size: 11px; + font-size: 0.875rem; color: #8290a7; } .comment-card footer > span { @@ -1442,18 +1493,19 @@ textarea:focus-visible { } .settings-section { border: 1px solid var(--line); - border-radius: 10px; + border-radius: 16px; background: white; margin: 20px 0; - padding: 25px; + padding: 28px 32px; + box-shadow: var(--shadow); } .section-intro h2 { - font-size: 17px; + font-size: 1.1875rem; margin: 0 0 8px; font-weight: 650; } .section-intro p { - font-size: 12px; + font-size: 0.9375rem; color: var(--muted); line-height: 1.8; margin: 0 0 20px; @@ -1467,20 +1519,20 @@ textarea:focus-visible { gap: 18px; } .platform-setting h3 { - font-size: 13px; + font-size: 1rem; margin: 7px 0; } .platform-setting p { - font-size: 12px; + font-size: 0.9375rem; margin: 5px 0; - color: #7f8da4; + color: var(--muted); } .platform-setting small { - font-size: 10px; - color: #a0aabd; + font-size: 0.8125rem; + color: var(--muted); } .platform-setting .button-row a { - font-size: 11px; + font-size: 0.875rem; margin-right: 6px; } .backup-panel { @@ -1497,21 +1549,22 @@ textarea:focus-visible { margin-right: auto; } .backup-panel h3 { - font-size: 13px; + font-size: 1rem; margin: 0 0 7px; color: var(--ink); } .backup-panel p { - font-size: 11px; + font-size: 0.875rem; margin: 0; color: var(--muted); } .version-badge { - font-size: 11px; - color: #91a0b9; + font-size: 0.875rem; + color: #65748b; border: 1px solid var(--line); padding: 7px 11px; border-radius: 6px; + white-space: nowrap; } .diff-body { padding: 24px; @@ -1521,7 +1574,7 @@ textarea:focus-visible { .diff-body pre { white-space: pre-wrap; font-family: Consolas, "Microsoft YaHei", monospace; - font-size: 12px; + font-size: 0.9375rem; line-height: 1.8; padding: 9px 13px; margin: 0; @@ -1553,12 +1606,12 @@ textarea:focus-visible { padding: 48px 55px; } .article-grid { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(auto-fill, minmax(min(100%, 19rem), 1fr)); } } @media (max-width: 1100px) { .sidebar { - width: 180px; + width: 208px; padding: 24px 12px; } .page { @@ -1630,19 +1683,19 @@ textarea:focus-visible { position: absolute; right: 0; top: 2px; - font-size: 8px; + font-size: 0.6875rem; } .page { padding: 25px 18px; } .page-heading h1 { - font-size: 25px; + font-size: 1.875rem; } .button-row { gap: 6px; } .button-row button { - font-size: 11px; + font-size: 0.875rem; padding: 7px 9px; } .library-toolbar { @@ -1668,16 +1721,16 @@ textarea:focus-visible { font-size: 0; } .editor-top .save-state { - font-size: 10px; + font-size: 0.8125rem; } .editor-top button { - font-size: 11px; + font-size: 0.875rem; } .version-strip { padding: 0 5px; } .article-title { - font-size: 22px; + font-size: 1.625rem; } .metadata-row { flex-wrap: wrap; @@ -1693,7 +1746,7 @@ textarea:focus-visible { padding: 0 10px; } .writing-grid header > div button { - font-size: 9px; + font-size: 0.75rem; } .card-studio { grid-template-columns: 1fr; @@ -1730,7 +1783,7 @@ textarea:focus-visible { grid-template-columns: 1fr 1fr; } .target { - font-size: 10px; + font-size: 0.8125rem; padding: 8px; } .platform-setting { @@ -1777,13 +1830,892 @@ textarea:focus-visible { margin-left: 0; } .publication-row { - font-size: 10px; + font-size: 0.8125rem; } .version-notice { padding: 10px 18px; flex-wrap: wrap; } } +.page-loading { + padding: 70px 30px; + text-align: center; + color: var(--muted); +} +.library-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0; + margin: 0 0 30px; + padding: 22px 0; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + box-shadow: var(--shadow); + color: var(--muted); + font-size: 0.9375rem; +} +.library-summary > span { + flex: 1; + padding: 0 24px; + border-right: 1px solid var(--line); +} +.library-summary > span:last-child { + border-right: 0; +} +.library-summary > span::before { + content: ""; + display: inline-block; + width: 4px; + height: 18px; + margin-right: 14px; + background: var(--blue); + border-radius: 2px; + vertical-align: middle; +} +.library-summary > span:nth-child(2)::before { + background: #cc8a28; +} +.library-summary > span:nth-child(3)::before { + background: #219278; +} +.library-summary strong { + font-size: 1.65rem; + font-weight: 650; + color: var(--ink); + margin-right: 4px; + font-variant-numeric: tabular-nums; + font-family: "Bahnschrift", "Segoe UI", sans-serif; +} +.summary-note { + margin-left: auto; + font-size: 0.875rem; +} +.library-page .library-toolbar { + margin-bottom: 20px; + padding-bottom: 8px; +} +.library-page .search-box input { + width: 270px; + max-width: 100%; +} +.library-filters, +.selection-bar, +.view-switch, +.library-feedback, +.writing-toolbar { + display: flex; + align-items: center; + gap: 10px; +} +.library-filters { + flex-wrap: wrap; + margin-bottom: 14px; +} +.library-filters select { + font-size: 0.9375rem; + min-height: 36px; +} +.filter-count { + margin-left: auto; +} +.view-switch { + gap: 3px; + padding: 3px; + border: 1px solid var(--line); + border-radius: 9px; + background: white; +} +.view-switch button { + min-height: 28px; + padding: 5px 8px; + border: 0; + border-radius: 6px; + font-size: 0.9375rem; +} +.selection-bar { + min-height: 44px; + margin-bottom: 12px; + flex-wrap: wrap; +} +.selection-bar label { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.9375rem; + color: var(--muted); + cursor: pointer; +} +.selection-bar button { + min-height: 30px; + padding: 5px 9px; + font-size: 0.9375rem; +} +.library-feedback > span { + flex: 1; +} +.article-card.is-selected { + border-color: var(--blue); + box-shadow: 0 0 0 1px var(--blue); +} +.article-card-actions { + display: flex; + gap: 3px; + margin-left: auto; +} +.article-card-actions .icon-button { + opacity: 1; + margin: 0; +} +.article-card-top > input { + flex-shrink: 0; +} +.article-platforms { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 0 20px 15px; + min-height: 40px; +} +.article-platforms .platform-pill { + font-size: 0.8125rem; + background: #f1f5fb; + padding: 3px 8px; + border-radius: 5px; +} +.published-mark { + color: var(--teal); + font-size: 0.8125rem; + margin-left: auto; +} +.article-list { + grid-template-columns: 1fr; + gap: 10px; +} +.article-list .article-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 230px; +} +.article-list .article-card-top { + grid-column: 1 / -1; + padding: 12px 18px 0; +} +.article-list .article-link { + padding: 12px 18px 16px; + min-height: 100px; +} +.article-list .article-link h2 { + font-size: 1.125rem; + margin-bottom: 5px; +} +.article-list .article-link p { + -webkit-line-clamp: 1; +} +.article-list .article-platforms { + padding: 12px 18px; + align-content: center; +} +.article-list footer { + grid-column: 1 / -1; + padding: 9px 18px; +} +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 14px; + padding: 26px 0 8px; + font-size: 0.9375rem; + color: var(--muted); +} +.writing-toolbar { + justify-content: space-between; + padding: 12px 28px; + flex-wrap: wrap; +} +.writing-grid section[hidden] { + display: none; +} +.writing-source, +.writing-preview { + grid-template-columns: minmax(0, 1fr); +} +.writing-preview .preview-content { + max-width: 880px; + margin: 0 auto; + padding: 28px 45px; +} +.writing-source .markdown-editor .cm-content { + padding: 22px 30px; +} +.format-toolbar { + display: flex; + align-items: center; + gap: 3px; + padding: 5px 12px; + border-bottom: 1px solid var(--line); +} +.format-toolbar button { + border: 0; + background: transparent; + padding: 5px 7px; + min-height: 30px; + color: #5f7092; +} +.format-toolbar button:hover { + color: var(--blue); + background: var(--soft); +} +.format-toolbar > span { + font-size: 0.8125rem; + margin-left: auto; + color: var(--muted); +} +.editor-notice { + margin: 12px 28px; + display: flex; + align-items: center; + justify-content: space-between; +} +.article-title:focus-visible { + outline: 2px solid #a8bcff !important; + outline-offset: 5px; +} +.queue-filters { + margin-bottom: 20px; +} +.queue-filters .tabs { + margin-bottom: 14px; + border-bottom: 1px solid var(--line); +} +.task-actions { + flex-wrap: wrap; +} +@media (max-width: 1100px) { + .summary-note { + display: none; + } + .library-page .page-heading { + align-items: flex-start; + flex-wrap: wrap; + gap: 18px; + } + .editor-top { + flex-wrap: wrap; + } +} +@media (max-width: 650px) { + .library-summary { + gap: 12px; + font-size: 0.8125rem; + } + .library-summary strong { + font-size: 1.125rem; + } + .library-filters { + gap: 7px; + } + .library-filters select { + flex: 1; + min-width: 110px; + max-width: 100%; + } + .library-page .search-box input { + width: 100%; + } + .article-list .article-card { + grid-template-columns: 1fr; + } + .article-list .article-platforms { + padding: 0 18px 12px; + } + .selection-bar { + gap: 7px; + } + .writing-toolbar { + padding: 10px; + } + .writing-toolbar > .muted { + font-size: 0.8125rem; + } + .writing-preview .preview-content { + padding: 20px; + } + .writing-source .markdown-editor .cm-content { + padding: 18px 10px; + } + .format-toolbar { + gap: 0; + padding: 4px 6px; + } + .format-toolbar > span { + display: none; + } + .editor-notice { + margin: 10px; + } + .queue-filters .tabs { + gap: 12px; + } + .queue-filters .tabs button { + font-size: 0.875rem; + } +} +.settings-page { + max-width: 1330px; +} +.settings-tabs { + display: flex; + gap: 8px; + border-bottom: 1px solid var(--line); + padding-bottom: 16px; + margin-bottom: 28px; + flex-wrap: wrap; +} +.settings-tabs button { + border-color: transparent; + background: transparent; + color: var(--muted); + gap: 9px; + padding: 12px 18px; +} +.settings-tabs button.active { + background: #fff; + border-color: var(--line); + color: var(--blue); + box-shadow: var(--shadow); +} +.settings-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 28px; +} +.settings-section-heading h2 { + display: flex; + align-items: center; + gap: 10px; + margin: 0; + font-size: 1.25rem; +} +.settings-section-heading h2 svg { + color: var(--blue); +} +.settings-saved { + color: var(--teal); + display: flex; + align-items: center; + gap: 6px; + font-size: 0.8125rem; +} +.appearance-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 40px; + align-items: stretch; +} +.font-setting { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; +} +.font-setting + .font-setting { + margin-top: 28px; +} +.font-setting label { + font-weight: 550; + align-self: center; +} +.font-setting output { + font: + 500 1.5rem "Bahnschrift", + "Segoe UI", + sans-serif; + color: var(--blue); +} +.font-setting output span { + color: var(--muted); + font-size: 0.8125rem; + margin-left: 4px; +} +.font-setting input { + grid-column: 1 / -1; + width: 100%; + height: 22px; + margin: 0; + cursor: pointer; + border: 0; +} +.range-labels { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 0.75rem; +} +.type-preview { + background: #f4f7fe; + border: 1px solid #e2e9f8; + border-radius: 12px; + padding: 26px; + display: flex; + flex-direction: column; + justify-content: center; + min-width: 0; +} +.preview-caption { + color: var(--muted); + font-size: 0.8125rem; +} +.type-preview h3 { + font-size: 1.5rem; + font-weight: 650; + margin: 16px 0 12px; + letter-spacing: 0.2px; +} +.type-preview p { + font-size: var(--editor-font-size); + color: #516582; + line-height: 1.95; + margin: 0 0 20px; +} +.type-preview-meta { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + border-top: 1px solid #dae4f5; + padding-top: 16px; + font-size: 0.8125rem; + color: #667b9d; +} +.preference-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 22px 0; + border-bottom: 1px solid #edf1f7; +} +.preference-row > span { + min-width: 0; +} +.preference-row select { + max-width: 56%; + min-width: min(11rem, 50%); + font-size: 0.9375rem; +} +.setting-value { + color: var(--muted); + font-size: 0.8125rem; + margin-left: 12px; + display: inline-block; +} +.settings-reset { + margin-top: 24px; +} +input.switch-input { + appearance: none; + width: 44px; + height: 26px; + flex: 0 0 44px; + border-radius: 18px; + border: 0; + background: #aebbd0; + position: relative; + cursor: pointer; + transition: background 0.15s; + margin: 0; +} +input.switch-input::after { + content: ""; + width: 20px; + height: 20px; + position: absolute; + top: 3px; + left: 3px; + border-radius: 50%; + background: white; + box-shadow: 0 1px 4px #10234330; + transition: transform 0.15s; +} +input.switch-input:checked { + background: var(--blue); +} +input.switch-input:checked::after { + transform: translateX(18px); +} +.update-overview { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 18px; + background: #f4f7fd; + border: 1px solid #e5ebf7; + padding: 24px; + border-radius: 12px; + margin: 24px 0 10px; +} +.update-overview.has-update { + background: #eef8f4; + border-color: #cee8dc; +} +.update-symbol { + display: grid; + place-items: center; + width: 56px; + height: 56px; + border: 1px solid #dce5f5; + color: var(--blue); + background: #fff; + border-radius: 14px; + flex-shrink: 0; +} +.has-update .update-symbol { + color: var(--teal); + border-color: #cee8dc; +} +.update-overview > div:nth-child(2) { + flex: 1; + min-width: min(200px, 100%); +} +.update-overview h3 { + margin: 0 0 7px; + font-size: 1.1875rem; +} +.update-overview p { + margin: 0; + font-size: 0.8125rem; + color: var(--muted); +} +.update-overview p span { + padding: 0 8px; +} +.update-overview button { + margin-left: auto; +} +.update-choice { + display: flex; + align-items: center; + gap: 8px; + margin-top: 24px; + color: var(--teal); + font-size: 0.875rem; +} +.update-choice svg { + flex-shrink: 0; +} +.release-panel { + border: 1px solid #d7e4f7; + padding: 24px; + border-radius: 12px; + margin-top: 22px; +} +.release-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.release-heading h3 { + margin: 0; +} +.release-notes { + margin: 16px 0; +} +.release-notes summary, +.installation-help summary { + cursor: pointer; + font-size: 0.9375rem; + color: #546988; +} +.release-notes pre { + font: inherit; + font-size: 0.9375rem; + line-height: 1.85; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: #f7f9fc; + border-radius: 8px; + padding: 20px; + max-height: 400px; + overflow: auto; +} +.button-link { + padding: 0.55rem 0.9rem; + border: 1px solid var(--line); + border-radius: 10px; + min-height: 2.625rem; + justify-content: center; + gap: 7px; + font-size: 1rem; + font-weight: 550; +} +.button-link.primary { + border-color: var(--blue); +} +.button-link:hover { + text-decoration: none; + filter: brightness(0.98); +} +.button-link span { + font-size: 0.8125rem; +} +.installation-help { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--line); +} +.installation-help ol { + font-size: 0.9375rem; + line-height: 1.9; + padding-left: 25px; + color: var(--muted); +} +.installation-help li + li { + margin-top: 10px; +} +.release-history { + margin-top: 18px; + font-size: 0.875rem; +} +.update-banner { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + background: #e9f3ff; + color: #2353b3; + padding: 10px 30px; + font-size: 0.9375rem; + border-bottom: 1px solid #d5e4fa; +} +.update-banner .text-button { + margin-left: auto; +} +.update-banner .icon-button { + color: #5473a6; +} +.settings-section .section-intro h2 { + margin-bottom: 18px; +} +.platform-setting .platform-pill { + font-size: 0.875rem; +} +.platform-setting h3 { + font-size: 1.0625rem; + margin: 8px 0 4px; +} +.platform-setting .button-row { + justify-content: flex-end; +} +.settings-section .backup-panel { + margin-top: 24px; +} +.editor-page .preview-content { + padding: 24px 30px; +} +.markdown-editor .cm-content { + padding-top: 24px; + padding-bottom: 24px; +} +.asset-tile, +.add-asset { + width: 160px; +} +.asset-tile > img { + height: 100px; +} +.version-strip button small, +.version-strip button span { + color: #667895; +} +.metadata-row input { + min-height: 42px; +} +.writing-grid header { + flex-wrap: wrap; + height: auto; + padding-top: 6px; + padding-bottom: 6px; +} +.queue-filters, +.filter-bar { + min-width: 0; +} +.queue-filters .tabs { + flex-wrap: wrap; + gap: 8px 20px; +} +.metrics-list { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); +} +@media (max-width: 1100px) { + .brand { + font-size: 1.625rem; + margin-left: 4px; + gap: 8px; + } + .brand-mark { + width: 38px; + height: 38px; + } + .appearance-grid { + grid-template-columns: 1fr; + gap: 28px; + } + .library-summary > span { + padding: 0 16px; + } + .library-toolbar { + flex-wrap: wrap; + } + .platform-setting { + flex-wrap: wrap; + } + .platform-setting .button-row { + justify-content: flex-start; + } + .backup-panel { + flex-wrap: wrap; + } +} +@media (max-width: 720px) { + .sidebar { + width: 64px; + padding: 20px 7px; + } + .sidebar nav button { + min-height: 46px; + } + .sidebar nav .settings-nav { + margin-top: 16px; + } + .nav-update-dot { + position: absolute; + top: 7px; + right: 6px; + } + .brand { + margin: 0 0 25px; + gap: 0; + } + .brand-mark { + width: 38px; + height: 38px; + } + .page { + padding: 28px 18px 40px; + } + .page-heading { + gap: 18px; + } + .page-heading h1 { + font-size: 1.875rem; + } + .page-heading .button-row { + gap: 8px; + } + .library-summary { + padding: 10px 16px; + flex-direction: column; + align-items: stretch; + } + .library-summary > span { + padding: 10px 0; + border: 0; + font-size: 0.875rem; + } + .library-summary > span + span { + border-top: 1px solid var(--line); + } + .library-summary strong { + font-size: 1.35rem; + } + .search-box { + width: 100%; + padding: 6px 10px; + } + .library-page .search-box input { + width: 100%; + } + .library-toolbar { + align-items: stretch; + gap: 12px; + padding-bottom: 14px; + } + .settings-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + .settings-tabs button { + padding: 10px 8px; + font-size: 0.875rem; + } + .settings-section { + padding: 22px 18px; + } + .settings-section-heading { + margin-bottom: 22px; + } + .preference-row { + flex-wrap: wrap; + gap: 12px; + padding: 20px 0; + } + .preference-row select { + max-width: 100%; + } + .preference-row:has(.switch-input) { + flex-wrap: nowrap; + } + .update-overview { + padding: 18px; + } + .update-overview button { + margin-left: 0; + } + .update-banner { + padding: 12px 18px; + } + .type-preview { + padding: 20px; + } + .release-panel { + padding: 18px; + } + .release-panel .button-link { + max-width: 100%; + flex-wrap: wrap; + } + .type-preview h3 { + font-size: 1.375rem; + } + .target-grid { + grid-template-columns: 1fr; + } + .format-toolbar { + flex-wrap: wrap; + } + .task-actions > a { + overflow-wrap: anywhere; + } + .task-card header { + flex-wrap: wrap; + } + .writing-toolbar .button-row { + flex-wrap: wrap; + } + .editor-page .preview-content { + padding: 20px; + } +} @media (prefers-reduced-motion: reduce) { *, *:before, diff --git a/tests/autosave.test.ts b/tests/autosave.test.ts new file mode 100644 index 0000000..d95e2ac --- /dev/null +++ b/tests/autosave.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SaveQueue } from "../src/core/autosave"; + +afterEach(() => vi.useRealTimers()); +describe("自动保存队列", () => { + it("连续输入仅保存同一文档的最新内容,切换页面立即落盘", async () => { + vi.useFakeTimers(); + const saved: string[] = []; + const queue = new SaveQueue(() => {}); + queue.enqueue("master", async () => { + saved.push("旧内容"); + }); + queue.enqueue("master", async () => { + saved.push("最新内容"); + }); + queue.enqueue("variant", async () => { + saved.push("独立版本"); + }); + expect(queue.status.pending).toBe(true); + expect(saved).toEqual([]); + expect(await queue.flush()).toBe(true); + expect(saved).toEqual(["最新内容", "独立版本"]); + expect(queue.status).toEqual({ pending: false, error: "" }); + }); + + it("一次保存失败后不被其他成功写入掩盖,重试写入保留的最新编辑", async () => { + vi.useFakeTimers(); + const saved: string[] = []; + const queue = new SaveQueue(() => {}); + queue.enqueue("master", async () => { + throw new Error("存储暂不可用"); + }); + queue.enqueue("variant", async () => { + saved.push("独立版本"); + }); + expect(await queue.flush()).toBe(false); + queue.enqueue("master", async () => { + saved.push("失败后继续输入的内容"); + }); + await vi.advanceTimersByTimeAsync(1000); + expect(saved).toEqual([]); + expect(await queue.flush()).toBe(false); + expect(queue.status.error).toBe("存储暂不可用"); + expect(await queue.retry()).toBe(true); + expect(saved).toEqual(["失败后继续输入的内容", "独立版本"]); + }); + + it("保存进行中又输入,仍会串行保存新内容且不能提前离开", async () => { + const saved: string[] = []; + let finish!: () => void; + const wait = new Promise((resolve) => { + finish = resolve; + }); + const queue = new SaveQueue(() => {}); + queue.enqueue("master", async () => { + await wait; + saved.push("第一版"); + }); + const flushed = queue.flush(); + queue.enqueue("master", async () => { + saved.push("第二版"); + }); + expect(queue.status.pending).toBe(true); + finish(); + expect(await flushed).toBe(true); + expect(saved).toEqual(["第一版", "第二版"]); + queue.discard(); + }); + + it("停笔后自动保存,不需要手动点击", async () => { + vi.useFakeTimers(); + const write = vi.fn(async () => {}); + const queue = new SaveQueue(() => {}); + queue.enqueue("master", write); + await vi.advanceTimersByTimeAsync(349); + expect(write).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(write).toHaveBeenCalledOnce(); + expect(queue.status.pending).toBe(false); + }); +}); diff --git a/tests/preferences-updates.test.ts b/tests/preferences-updates.test.ts new file mode 100644 index 0000000..2ecf42d --- /dev/null +++ b/tests/preferences-updates.test.ts @@ -0,0 +1,353 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WorkbenchDB } from "../src/core/db"; +import { + defaultPreferences, + getPreferences, + normalizePreferences, + savePreferences, +} from "../src/core/preferences"; +import { + checkForUpdates, + compareVersions, + dismissUpdate, + getUpdateState, + hasUpdate, + latestRelease, + syncUpdateAlarm, + UPDATE_ALARM, + UPDATE_API, + UPDATE_INTERVAL, + UPDATE_KEY, +} from "../src/core/updates"; + +const databases: WorkbenchDB[] = []; +const database = () => { + const db = new WorkbenchDB(`preferences-update-test-${crypto.randomUUID()}`); + databases.push(db); + return db; +}; +afterEach(async () => { + await Promise.all(databases.splice(0).map((db) => db.delete())); +}); + +function release(version = "0.2.0", prerelease = false) { + return { + tag_name: `v${version}`, + name: `zMatrix ${version}`, + body: "新功能\n改进字号", + html_url: `https://github.com/zJay26/zMatrix/releases/tag/v${version}`, + published_at: "2026-09-26T10:00:00Z", + draft: false, + prerelease, + assets: [ + { + name: `zMatrix-edge-${version}.zip`, + size: 1024, + browser_download_url: `https://github.com/zJay26/zMatrix/releases/download/v${version}/zMatrix-edge-${version}.zip`, + }, + ], + }; +} +const response = (data: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => data, + }) as Response; +const network = (data: unknown = [release()], status = 200) => + vi.fn().mockResolvedValue(response(data, status)); + +describe("常用设置", () => { + it("首次打开使用更大字号,并能在重新打开数据库后恢复偏好", async () => { + const db = database(); + expect(await getPreferences(db)).toEqual(defaultPreferences); + await savePreferences( + { + fontSize: 20, + editorFontSize: 23, + libraryLayout: "list", + editorLayout: "source", + autoCheckUpdates: false, + }, + db, + ); + db.close(); + await db.open(); + expect(await getPreferences(db)).toMatchObject({ + fontSize: 20, + editorFontSize: 23, + libraryLayout: "list", + editorLayout: "source", + autoCheckUpdates: false, + }); + expect(await db.articles.count()).toBe(0); + }); + it("并发更改不同设置不会相互覆盖,也不会触发稿件备份", async () => { + const db = database(); + await Promise.all([ + savePreferences({ fontSize: 18 }, db), + savePreferences({ refreshOnOpen: false }, db), + ]); + expect(await getPreferences(db)).toMatchObject({ + fontSize: 18, + refreshOnOpen: false, + }); + expect(await db.meta.get("dataChangedAt")).toBeUndefined(); + }); + it("损坏和旧设置回退到可用范围", () => { + expect(normalizePreferences(null)).toEqual(defaultPreferences); + expect( + normalizePreferences({ + fontSize: 100, + editorFontSize: -1, + libraryLayout: "broken", + refreshOnOpen: "false", + autoCheckUpdates: false, + }), + ).toMatchObject({ + fontSize: 22, + editorFontSize: 14, + libraryLayout: "grid", + refreshOnOpen: true, + autoCheckUpdates: false, + }); + expect(normalizePreferences({ fontSize: NaN }).fontSize).toBe(16); + }); +}); + +describe("版本选择与下载入口", () => { + it.each([ + ["0.10.0", "0.9.9", 1], + ["v1.0.0", "1.0.0", 0], + ["1.0.0-beta.10", "1.0.0-beta.2", 1], + ["1.0.0", "1.0.0-rc.1", 1], + ["1.0.0-alpha", "1.0.0-alpha.1", -1], + ["1.0.0+build1", "1.0.0+build2", 0], + ])("按版本语义比较 %s 和 %s", (a, b, result) => + expect(compareVersions(a, b)).toBe(result), + ); + it("适配当前数字版本号的 GitHub 预发布标记,不误将源码包当成扩展", () => { + const preview = release("0.3.0", true); + preview.assets.unshift({ + name: "zMatrix-source-0.3.0.zip", + size: 123, + browser_download_url: + "https://github.com/zJay26/zMatrix/releases/download/v0.3.0/zMatrix-source-0.3.0.zip", + }); + const data = [ + release("0.1.2"), + preview, + { ...release("9.0.0"), draft: true }, + ]; + expect(latestRelease(data, true)).toMatchObject({ + version: "0.3.0", + prerelease: true, + downloadUrl: preview.assets[1]!.browser_download_url, + }); + expect(latestRelease(data, false)?.version).toBe("0.1.2"); + }); + it("正式频道排除预发布后缀和没有发布时间的版本", () => { + expect( + latestRelease( + [release("1.0.0-beta.1"), { ...release("3.0.0"), published_at: null }], + false, + ), + ).toBeUndefined(); + }); + it("拒绝外部站点、其他仓库和其他版本的下载地址", () => { + for (const url of [ + "https://example.org/update.zip", + "javascript:alert(1)", + "https://github.com/other/repo/releases/download/v0.2.0/zMatrix-edge-0.2.0.zip", + "https://github.com/zJay26/zMatrix/releases/download/v0.1.2/zMatrix-edge-0.2.0.zip", + ]) { + const value = release(); + value.assets[0]!.browser_download_url = url; + expect(latestRelease([value], true)?.downloadUrl).toBeUndefined(); + } + expect( + latestRelease( + [ + { + ...release(), + html_url: + "https://github.com.evil.org/zJay26/zMatrix/releases/tag/v0.2.0", + }, + ], + true, + ), + ).toBeUndefined(); + }); + it("相同或更旧版本不提示升级,损坏响应不能假报最新版", () => { + expect( + hasUpdate({ release: latestRelease([release("0.1.2")], true) }, "0.1.2"), + ).toBe(false); + expect( + hasUpdate({ release: latestRelease([release("0.1.2")], true) }, "0.2.0"), + ).toBe(false); + expect(() => latestRelease({ message: "Not found" }, true)).toThrow(); + expect(() => latestRelease([{ tag_name: "v1.0.0" }], true)).toThrow(); + }); +}); + +describe("自动检测与用户选择", () => { + it("只请求版本元数据,不请求下载地址;六小时内不重复自动检查", async () => { + const db = database(), + fetcher = network(); + const first = await checkForUpdates({ database: db, fetcher, now: 1000 }); + expect(hasUpdate(first, "0.1.2")).toBe(true); + await checkForUpdates({ database: db, fetcher, now: 1001 }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalledWith( + UPDATE_API, + expect.objectContaining({ credentials: "omit", cache: "no-store" }), + ); + await checkForUpdates({ + database: db, + fetcher, + now: 1000 + UPDATE_INTERVAL, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + it("关闭自动检查后不联网,但仍可手动检查", async () => { + const db = database(), + fetcher = network(); + await savePreferences({ autoCheckUpdates: false }, db); + await checkForUpdates({ database: db, fetcher }); + expect(fetcher).not.toHaveBeenCalled(); + await checkForUpdates({ database: db, fetcher, manual: true }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it("切换发布频道立即刷新,失败保留上次结果并明确报错", async () => { + const db = database(), + fetcher = network([release("0.3.0", true)]); + await checkForUpdates({ database: db, fetcher, now: 1000 }); + await savePreferences({ includePrereleases: false }, db); + await checkForUpdates({ database: db, fetcher, now: 1001 }); + expect(fetcher).toHaveBeenCalledTimes(2); + expect((await getUpdateState(db)).release).toBeUndefined(); + const failed = await checkForUpdates({ + database: db, + fetcher: network({}, 403), + manual: true, + now: 1002, + }); + expect(failed.error).toContain("受限"); + expect(failed.checkedAt).toBe(1001); + const offline = vi + .fn() + .mockRejectedValue(new TypeError("Failed to fetch")); + expect( + ( + await checkForUpdates({ + database: db, + fetcher: offline, + manual: true, + now: 1003, + }) + ).error, + ).toContain("网络"); + expect( + ( + await checkForUpdates({ + database: db, + fetcher: network({}, 404), + manual: true, + now: 1004, + }) + ).error, + ).toContain("404"); + }); + it("切换为仅正式版时,即使检查失败也不展示缓存的预览版下载", async () => { + const db = database(); + await checkForUpdates({ + database: db, + fetcher: network([release("0.3.0", true)]), + now: 1000, + }); + await savePreferences({ includePrereleases: false }, db); + const result = await checkForUpdates({ + database: db, + fetcher: network({}, 500), + now: 1001, + }); + expect(result.error).toBeTruthy(); + expect(result.release).toBeUndefined(); + expect(result.checkedAt).toBeUndefined(); + expect(hasUpdate(result)).toBe(false); + }); + it("失败也遵守自动检测间隔,避免网络故障或限流时反复请求", async () => { + const db = database(), + fetcher = network({}, 500); + await checkForUpdates({ database: db, fetcher, now: 1000 }); + await checkForUpdates({ database: db, fetcher, now: 1001 }); + expect(fetcher).toHaveBeenCalledTimes(1); + expect((await getUpdateState(db)).checkedAt).toBeUndefined(); + }); + it("多窗口只执行一个检查,服务进程中断后的租约可以恢复", async () => { + const db = database(); + let resolve!: (response: Response) => void; + const fetcher = vi.fn().mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + const first = checkForUpdates({ database: db, fetcher, now: 1000 }); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + await checkForUpdates({ database: db, fetcher, manual: true, now: 1001 }); + expect(fetcher).toHaveBeenCalledTimes(1); + await dismissUpdate("0.2.0", db); + resolve(response([release()])); + await first; + expect((await getUpdateState(db)).dismissedVersion).toBe("0.2.0"); + expect(await db.meta.get("updateCheckLease")).toBeUndefined(); + await db.meta.put({ + key: "updateCheckLease", + value: { owner: "expired", at: 1000 }, + }); + expect( + ( + await checkForUpdates({ + database: db, + fetcher: network([release("0.4.0")]), + manual: true, + now: 32_000, + }) + ).release?.version, + ).toBe("0.4.0"); + }); + it("稍后提醒持久化,但不会屏蔽下一版,也不会影响手动查看", async () => { + const db = database(); + await checkForUpdates({ database: db, fetcher: network(), now: 1000 }); + await dismissUpdate("0.2.0", db); + const state = await checkForUpdates({ + database: db, + fetcher: network([release("0.3.0")]), + manual: true, + now: 1001, + }); + expect(state.dismissedVersion).toBe("0.2.0"); + expect(state.release?.version).toBe("0.3.0"); + expect(hasUpdate(state, "0.1.2")).toBe(true); + }); + it("重建丢失的后台闹钟,已有闹钟不延期,关闭时清除", async () => { + const db = database(); + const alarms = { + get: vi.fn().mockResolvedValue(undefined), + create: vi.fn(), + clear: vi.fn(), + }; + await syncUpdateAlarm(db, alarms as unknown as typeof chrome.alarms); + expect(alarms.create).toHaveBeenCalledWith(UPDATE_ALARM, { + periodInMinutes: 360, + }); + alarms.get.mockResolvedValue({ name: UPDATE_ALARM }); + await syncUpdateAlarm(db, alarms as unknown as typeof chrome.alarms); + expect(alarms.create).toHaveBeenCalledTimes(1); + await savePreferences({ autoCheckUpdates: false }, db); + await syncUpdateAlarm(db, alarms as unknown as typeof chrome.alarms); + expect(alarms.clear).toHaveBeenCalledWith(UPDATE_ALARM); + expect(await db.meta.get(UPDATE_KEY)).toBeUndefined(); + }); +}); diff --git a/tests/versions-tasks.test.ts b/tests/versions-tasks.test.ts index d30de1e..961e610 100644 --- a/tests/versions-tasks.test.ts +++ b/tests/versions-tasks.test.ts @@ -9,7 +9,14 @@ import { freezeSnapshot, snapshotDiffers, } from "../src/core/variants"; -import { enqueue, canStart, recoverTask, patchTask } from "../src/core/tasks"; +import { + enqueue, + canStart, + recoverTask, + patchTask, + claimTask, + cancelTask, +} from "../src/core/tasks"; import { saveArticle } from "../src/core/db"; const db = database(); @@ -62,6 +69,37 @@ describe("母稿和平台版本", () => { }); }); describe("任务恢复与重复提交", () => { + it("先取消的任务不能再被领取,也不会被改回失败", async () => { + const [task] = await enqueue([await fixture()], "draft", db); + await cancelTask(task!.id, db); + expect(await claimTask(task!.id, "worker", db)).toBeUndefined(); + expect((await db.tasks.get(task!.id))?.state).toBe("cancelled"); + }); + + it("已被后台领取的任务不能由旧页面取消或重复领取", async () => { + const [task] = await enqueue([await fixture()], "draft", db); + expect((await claimTask(task!.id, "worker-a", db))?.state).toBe( + "preparing", + ); + await expect(cancelTask(task!.id, db)).rejects.toThrow("不能取消"); + expect(await claimTask(task!.id, "worker-b", db)).toBeUndefined(); + expect((await db.tasks.get(task!.id))?.owner).toBe("worker-a"); + }); + + it("有提交痕迹的失败或已取消任务不能绕过重复排队保护", async () => { + for (const state of ["failed", "cancelled"] as const) { + await db.tasks.clear(); + const f = await fixture(); + const [task] = await enqueue([f], "draft", db); + await patchTask(task!.id, { state, tabId: 42 }, "准备向平台提交", db); + await expect(enqueue([f], "draft", db)).rejects.toThrow("已有任务"); + } + }); + + it("已经打开并填充原站页面的失败任务需要核实,不能重新执行", async () => { + const [task] = await enqueue([await fixture()], "draft", db); + expect(canStart({ ...task!, state: "failed", tabId: 42 })).toBe(false); + }); it("等待手动发布或核对的任务在重启后保持暂停边界,不能重复执行", async () => { const [task] = await enqueue([await fixture()], "publish", db); for (const state of ["awaiting_publish", "awaiting_review"] as const) { diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts new file mode 100644 index 0000000..4936ad2 --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { database, imageFile } from "./helpers"; +import { saveArticle, saveVariant } from "../src/core/db"; +import { newArticle, newVariant, setOverride } from "../src/core/variants"; +import { importMarkdownFiles } from "../src/core/import"; +import { addAsset } from "../src/core/assets"; +import { + archiveArticles, + duplicateArticle, + filterLibrary, + libraryEntries, + type LibraryFilter, +} from "../src/core/library"; +import { + exportArticles, + exportCurrentContent, + safeFilename, +} from "../src/core/export"; +import { strFromU8, unzipSync } from "fflate"; + +const db = database(); +beforeEach(() => db.open()); +afterEach(() => db.delete()); + +describe("多窗口保存与导入完整性", () => { + it("两个窗口首次创建同一平台版本时,后写入者不能覆盖先保存的内容", async () => { + const variant = newVariant(newArticle(), "zhihu:article"); + await saveVariant(setOverride(variant, "title", "先写入"), db, { + expected: undefined, + }); + await expect( + saveVariant(setOverride(variant, "title", "后写入"), db, { + expected: undefined, + }), + ).rejects.toThrow("其他窗口"); + expect((await db.variants.get(variant.id))?.overrides.title).toBe("先写入"); + }); + + it("同时添加相同图片只保留一个二进制记录", async () => { + const file = imageFile(); + const [first, second] = await Promise.all([ + addAsset(file, db), + addAsset(file, db), + ]); + expect(first.id).toBe(second.id); + expect(await db.assets.count()).toBe(1); + }); + + it("旧窗口继续输入更高版本号,也不能覆盖另一个窗口的正文", async () => { + const base = newArticle("初稿", "原文"); + await saveArticle(base, db); + await saveArticle({ ...base, markdown: "另一个窗口", revision: 2 }, db); + await expect( + saveArticle({ ...base, markdown: "旧窗口继续输入", revision: 3 }, db, { + expected: base, + }), + ).rejects.toThrow("其他窗口"); + expect((await db.articles.get(base.id))?.markdown).toBe("另一个窗口"); + }); + + it("平台版本也拒绝基于过期内容的覆盖", async () => { + const base = newVariant(newArticle(), "csdn:article"); + await saveVariant(base, db); + await saveVariant(setOverride(base, "title", "其他窗口的标题"), db); + await expect( + saveVariant(setOverride(base, "markdown", "过期窗口的正文"), db, { + expected: base, + }), + ).rejects.toThrow("其他窗口"); + expect((await db.variants.get(base.id))?.overrides.title).toBe( + "其他窗口的标题", + ); + }); + + it("第二篇导入失败时,第一篇及其图片均不留下半成品", async () => { + const first = new File(["# 第一篇\n![图片](photo.png)"], "first.md"); + const second = new File(["![缺失](missing.png)"], "second.md"); + await expect( + importMarkdownFiles([first, second, imageFile("photo.png")], db), + ).rejects.toThrow("找不到配套图片"); + expect(await db.articles.count()).toBe(0); + expect(await db.assets.count()).toBe(0); + expect(await db.meta.get("dataChangedAt")).toBeUndefined(); + }); +}); + +describe("内容库与可携带导出", () => { + it("复制包含独立版本和图片引用,不继承原稿的发布记录", async () => { + const asset = await addAsset(imageFile(), db); + const article = { + ...newArticle("原稿", `![图片](asset://${asset.id})`), + imageIds: [asset.id], + archived: true, + }; + const variant = setOverride( + newVariant(article, "csdn:article"), + "title", + "独立标题", + ); + await db.articles.add(article); + await db.variants.add(variant); + await db.posts.add({ + id: "post", + articleId: article.id, + channel: "csdn:article", + remoteId: "1", + url: "https://blog.csdn.net/demo/article/details/1", + title: "原稿", + status: "published", + registeredAt: 1, + updatedAt: 1, + }); + const copy = await duplicateArticle(article.id, db); + expect(copy).toMatchObject({ + title: "原稿 · 副本", + revision: 1, + archived: false, + imageIds: [asset.id], + }); + expect(copy.id).not.toBe(article.id); + expect( + (await db.variants.where("articleId").equals(copy.id).first())?.overrides + .title, + ).toBe("独立标题"); + expect(await db.posts.where("articleId").equals(copy.id).count()).toBe(0); + expect(await db.assets.count()).toBe(1); + }); + + it("批量归档读取最新内容;缺失记录使整批回滚,归档不制造版本差异", async () => { + const article = newArticle("最新内容"); + await db.articles.add(article); + await expect( + archiveArticles([article.id, "missing"], true, db), + ).rejects.toThrow("已不存在"); + expect((await db.articles.get(article.id))?.archived).toBe(false); + await archiveArticles([article.id], true, db); + expect(await db.articles.get(article.id)).toMatchObject({ + title: "最新内容", + archived: true, + revision: 1, + }); + await archiveArticles([article.id], false, db); + expect((await db.articles.get(article.id))?.archived).toBe(false); + }); + + it("多词搜索可组合平台独立标题和标签,发布状态仅按登记记录筛选", () => { + const article = newArticle("母稿"); + const variant = setOverride( + newVariant(article, "csdn:article"), + "title", + "TypeScript 实践", + ); + variant.metadata.tags = ["工具"]; + const entries = libraryEntries([article], [variant], []); + const filter: LibraryFilter = { + query: " TYPESCRIPT 工具 ", + archived: false, + channel: "csdn:article", + publication: "unpublished", + sort: "title", + }; + expect(filterLibrary(entries, filter)).toHaveLength(1); + expect( + filterLibrary(entries, { ...filter, publication: "published" }), + ).toHaveLength(0); + expect( + filterLibrary(entries, { ...filter, channel: "zhihu:article" }), + ).toHaveLength(0); + }); + + it("导出每个平台的实际内容并携带图片,Markdown 可以连同目录重新导入", async () => { + const asset = await addAsset(imageFile(), db); + const article = { + ...newArticle("含图母稿", `# 含图母稿\n![图片](asset://${asset.id})`), + imageIds: [asset.id], + }; + const variant = setOverride( + newVariant(article, "zhihu:article"), + "markdown", + `# 独立正文\n![图片](asset://${asset.id})`, + ); + variant.metadata.coverId = asset.id; + await db.articles.add(article); + await db.variants.add(variant); + const zip = unzipSync( + new Uint8Array( + await (await exportArticles([article.id], db)).arrayBuffer(), + ), + ); + const source = Object.keys(zip).find((path) => + path.endsWith("母稿/正文.md"), + )!; + const platform = Object.keys(zip).find((path) => + path.endsWith("zhihu-article/正文.md"), + )!; + expect(strFromU8(zip[source]!)).toContain(`images/${asset.id}.png`); + expect(strFromU8(zip[platform]!)).toContain("独立正文"); + expect(strFromU8(zip[platform]!)).not.toContain("asset://"); + const folder = source.slice(0, -"正文.md".length); + const files = Object.entries(zip) + .filter( + ([path]) => + path.startsWith(folder) && + (path.endsWith(".md") || path.endsWith(".png")), + ) + .map(([path, bytes]) => { + const f = new File( + [bytes as Uint8Array], + path.split("/").at(-1)!, + { type: path.endsWith(".png") ? "image/png" : "text/markdown" }, + ); + Object.defineProperty(f, "webkitRelativePath", { value: path }); + return f; + }); + const [restored] = await importMarkdownFiles(files, db); + expect(restored?.markdown).toBe(article.markdown); + expect(restored?.imageIds).toEqual([asset.id]); + expect(await db.assets.count()).toBe(1); + }); + + it("缺失素材不能导出成假完整文件,Windows 文件名可安全保存", async () => { + await expect( + exportCurrentContent( + { title: "丢图", markdown: "", imageIds: ["a".repeat(64)] }, + undefined, + db, + ), + ).rejects.toThrow("缺少图片"); + expect(safeFilename("CON.txt")).toBe("_CON.txt"); + expect(safeFilename("题/目:*?. ")).toBe("题_目___"); + }); +}); diff --git a/wxt.config.ts b/wxt.config.ts index 0a0f56f..6be6001 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -22,7 +22,8 @@ export default defineConfig({ 48: "icon/48.png", 128: "icon/128.png", }, - permissions: ["storage", "scripting", "unlimitedStorage"], + permissions: ["storage", "scripting", "unlimitedStorage", "alarms"], + host_permissions: ["https://api.github.com/*"], optional_host_permissions: [ "https://*.zhihu.com/*", "https://juejin.cn/*",