diff --git a/docs/V0.2/任务模块/任务模块功能设计.md b/docs/V0.2/任务模块/任务模块功能设计.md index 69fd4e8..868c1e2 100644 --- a/docs/V0.2/任务模块/任务模块功能设计.md +++ b/docs/V0.2/任务模块/任务模块功能设计.md @@ -155,6 +155,9 @@ | T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 | | T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 | | T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 | +| T-TK-14 | 标题 30 字上限 + 快速添加截断 | 服务端 create/update 校验 `title ≤ 30`;前端 `quickAdd.js#splitTitle`:≤30 原样,超长在 30 字内(≥8 字)找最后一个分隔符截断,前段为标题、**完整原文**写入 `description`;快速添加输入框 `maxLength=200` | +| T-TK-15 | `Task.markDate DATETIME(3) NULL`(迁移 20261025120000) | create/update 接受 `YYYY-MM-DD`(空/null 清除),完整日期存储;详情页 DatePicker(precision=day)选择/清除;卡片展示 `MM-dd` 小标签(`date-tag`) | +| T-TK-16 | `quickAdd.js#parseTimePrefix` | 前缀词表:今天/明天/后天/本周/下周/本月/下月(须后跟分隔符或结束);今天=当日,明天/后天=+1/+2,本周=本周日、下周=下周日(周一为一周开始),本月/下月=当月/下月最后一天;命中后前缀移出标题并写入 `markDate` | --- diff --git a/docs/V0.2/任务模块/任务模块数据库设计文档.md b/docs/V0.2/任务模块/任务模块数据库设计文档.md index 02acd7e..e7e138e 100644 --- a/docs/V0.2/任务模块/任务模块数据库设计文档.md +++ b/docs/V0.2/任务模块/任务模块数据库设计文档.md @@ -176,8 +176,10 @@ | `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` | | `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) | | `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) | -| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) | -| `description` | `TEXT` | 否 | `''` | — | +| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02);应用层限 30 字(T-TK-14) | +| `description` | `TEXT` | 否 | `''` | 快速添加超长文本的余文并入此处(T-TK-14) | +| `kind` | `VARCHAR(20)` | **是** | `NULL` | 任务类型标签,取值见 `Project.taskKinds` | +| `markDate` | `DATETIME(3)` | **是** | `NULL` | 时间标记,完整日期存储、展示 `MM-dd`(T-TK-15;迁移 20261025120000) | | `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) | | `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) | | `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) | diff --git a/docs/V0.2/任务模块/任务模块需求文档.md b/docs/V0.2/任务模块/任务模块需求文档.md index 7d9ffe9..8ba1ee5 100644 --- a/docs/V0.2/任务模块/任务模块需求文档.md +++ b/docs/V0.2/任务模块/任务模块需求文档.md @@ -240,6 +240,9 @@ | T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 | | T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 | | T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | P1 | +| T-TK-14 | 任务标题收紧至**30 个汉字**;快速添加输入框允许输入 200 字,超过 30 字时自动在 30 字以内的**分隔符(空格/标点等)**处截断,前段作为标题,整体文本并入描述 | 输入长文本创建后:标题 ≤30 字且不在词中间切断;描述包含完整原文余文;直接编辑标题超 30 字时拒绝保存并提示 | P0 | +| T-TK-15 | 任务可选**时间标记**(完整日期存储),设置了时间标记的任务在列表行与看板卡片上以 `MM-dd` 小标签展示 | 详情页可选择/清除时间标记;设置后卡片出现 MM-dd 标签,清除后消失 | P1 | +| T-TK-16 | 快速添加输入框支持**时间词前缀**:以 今天/明天/后天/本周/下周/本月/下月 开头(后跟分隔符)时,自动为任务设置对应时间标记并把该前缀从标题中移除 | 输入「明天 交周报」→ 创建任务标题为「交周报」、时间标记为明天的日期;无匹配前缀时行为不变 | P1 | ### 3.6 负责人 & 成员 UI 展示规则(T-AS) diff --git a/docs/V0.2/离线编辑模块/PowerSync可行性研究报告.md b/docs/V0.2/离线编辑模块/PowerSync可行性研究报告.md new file mode 100644 index 0000000..17c4754 --- /dev/null +++ b/docs/V0.2/离线编辑模块/PowerSync可行性研究报告.md @@ -0,0 +1,127 @@ +# 基于 PowerSync 的离线编辑可行性研究报告 + +| 项 | 内容 | +| --- | --- | +| 版本 | V0.2 | +| 日期 | 2026-10-25 | +| 评估对象 | PowerSync(powersync.com,开源 self-hosted + 云服务) | +| 评估目标 | 项目离线操作(本地编辑)+ 联网后自动同步 | +| 结论(TL;DR) | **技术上可行,但落地成本高、风险中高。不建议全量接入;若确需离线,建议仅对「打卡 / 清单 / 任务」等用户编辑域做局部接入,且需要先解决 MariaDB 兼容与主键策略两个前置问题。** | + +--- + +## 1. 项目现状约束(评估输入) + +结合代码库(`package.json`、`prisma/schema.prisma`、`server/index.js`)梳理: + +1. **前端**:React 18 SPA(antd-mobile),通过 axios 调 `/api/*` REST 接口;`src/api`、`src/hooks`、`src/pages` 均以"请求 → state → 渲染"模式组织,无任何本地持久化数据层。 +2. **桌面/移动端**:Tauri 2(Windows WebView2 桌面 + Android System WebView),意味着前端运行在 WebView 内,浏览器类 SDK 是候选。 +3. **后端**:Express + Prisma,数据库为 **MariaDB / MySQL**(`provider = "mysql"`)。 +4. **认证**:自签 JWT(jsonwebtoken),登录接口在 `/api/auth`。 +5. **写路径**:全部业务写操作走 REST(打卡、清单、任务模块);同时存在大量**服务端定时写入**(cron:新闻、指标、监控、米家采样等,见 `server/cron.js`)。 +6. **数据模型**:约 25 张表,主键全部为 `Int @id @default(autoincrement())` 自增整数,多表带复合唯一约束(如 `CheckIn @@unique([habitId, date, seq])`)。 +7. **数据体量特征**:两类数据并存—— + - **用户编辑域**(Habit / CheckIn / CheckList* / 任务模块 10 张表):冲突敏感、离线价值高; + - **服务端只读时序域**(News / Indicator / ServerNodeHistory / MihomeEnv* 等):append-only、量大、离线价值中等(缓存即可满足)。 + +--- + +## 2. PowerSync 是什么、怎么工作 + +- 架构:客户端本地 **SQLite**(Web 端为 WASM SQLite,存 OPFS/IndexedDB)↔ **PowerSync Service**(self-hosted 开源组件或云服务)↔ 源数据库。服务端通过 **逻辑复制/变更流** 捕获源库写入,按 **Sync Rules** 定义的下推子集同步到每个客户端的本地 SQLite。 +- 写路径(官方推荐模式):客户端离线写本地 SQLite → 进入 **upload queue** → 联网后由前端把变更批量 POST 到**现有后端 API** → 后端用原有方式(本项目即 Prisma)写库 → 变更流捕获 → 广播回各端。 +- 认证:客户端持后端签发的 JWT 连接同步服务;PowerSync 只做校验,不签发(可复用现有 jsonwebtoken 体系)。 +- 服务端直写的表(cron 产生)也能被变更流捕获并下推,不需要改造写入方。 + +参考:[PowerSync Service(GitHub)](https://github.com/powersync-ja/powersync-service)、[源数据库支持](https://docs.powersync.com/configuration/source-db/setup)。 + +--- + +## 3. 逐项可行性分析 + +### 3.1 数据库兼容性 —— ⚠️ 主要风险点 + +- PowerSync 对源库支持:**PostgreSQL(GA)**、MongoDB、SQL Server;**MySQL 仍处于 Beta**(基于 binlog 复制,见 [MySQL Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta)、[Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha))。 +- 本项目用的是 **MariaDB**。MariaDB 的 binlog 格式与 MySQL 8 有差异(GTID、行事件细节),PowerSync MySQL 插件(@powersync/mysql-zongji 系)对 MariaDB **没有官方兼容承诺,需要实测验证**,很可能踩坑。 +- **结论**:要么实测 MariaDB + MySQL-beta 通路(风险高),要么把数据库迁到 PostgreSQL(PowerSync 最成熟路径,Prisma 迁移成本可控但涉及全部表和线上数据)。这是"能不能用"的第一道门槛。 + +### 3.2 数据模型适配 —— 改造成本大 + +- PowerSync Sync Rules 要求同步表有稳定的行标识,**强烈建议 UUID 文本主键**;而本项目全部是自增整数主键,且客户端离线创建记录时无法预知自增 id。 +- 离线创建的通用解法:主键改为客户端生成的 UUID(`char(36)`),或新增 `uuid` 列 + 应用层幂等 upsert。对用户编辑域的 10 余张表意味着**一次不小的 schema 迁移 + 全部相关路由改造**(迁移原则"只增不改"仍可遵守,但外键列类型变更工作量大)。 +- `DateTime`、`Json` 类型可映射(客户端以 ISO 文本/JSON 文本存储),但 `BigInt`(监控表)在 JS 端需按文本处理,注意精度。 +- 复合唯一约束(`CheckIn(habitId,date,seq)`、`BoardColumn(projectId,name)` 等)在离线多端并发下会冲突,需在 upload 处理端路由里做服务端仲裁(如 seq 重排、重名加后缀),现有路由大多不具备幂等/仲裁语义。 + +### 3.3 写路径(离线编辑 + 上行同步)—— 模式契合,但需逐路由改造 + +- PowerSync 的 upload-queue → 后端 API 模式与现有 Express 架构**天然契合**:不需要后端暴露直接写库的通口,各业务路由补成"接收客户端 UUID + 幂等 upsert"即可。 +- 打卡/清单/任务的路由数量约 3 个 router 文件(checkin / checklists / projects / tasks),逐个改造为幂等是可控的。 +- 服务端 cron 写入的表(新闻/指标/监控/米家)无需改造写入方,只需在 Sync Rules 里定义是否下推。 + +### 3.4 读路径与前端数据层 —— 全面前重写 + +这是**工作量最大**的部分: + +- 现有 `src/api` + axios + useEffect 模式必须替换为 PowerSync 本地 SQLite 查询 + `@powersync/react` 的响应式 hooks(useQuery 等),否则离线读不到数据。 +- 涉及 `src/pages` 下全部用户编辑域页面(checkin、task),以及若要离线浏览还需覆盖 news/macro/monitor 的只读缓存页。 +- 工作量量级:相当于把前端数据层重构一遍;可与"离线只读缓存(HTTP 缓存)"分层实施以降低风险。 + +### 3.5 客户端运行环境 —— 大体可行,Android 需验证 + +- **浏览器 / Tauri 桌面(WebView2)**:PowerSync Web SDK(WASM SQLite + OPFS)在 Chromium 系 WebView 已被官方支持,可行。 +- **Tauri Android(System WebView)**:WASM SQLite 性能与 OPFS 在部分国产/旧版 WebView 上存在兼容性风险,需真机验证;备选方案是封装原生 SQLite(官方 Capacitor 插件思路可借鉴,但 Tauri 无官方插件,需自研 Rust 命令桥接,成本额外增加)。 +- 时序大表(ServerNodeHistory 每分钟一条、MihomeEnv*)不建议纳入同步——Sync Rules 需做时间窗过滤(如仅近 7 天),否则客户端存储和同步流量都会失控。 + +### 3.6 认证与部署 —— 可行 + +- JWT 可复用:新增一个 `/api/powersync/token` 端点为已登录用户签发 PowerSync 接受的 token,改造小。 +- 部署:self-hosted 需在现有 Express 之外多跑一个 powersync-service 进程(含 Mongo 存储元数据或云实例),并暴露 WebSocket 端口;或使用 PowerSync Cloud。运维面增加一个组件。 + +### 3.7 冲突解决 + +- PowerSync 本身不做自动合并,只提供上传队列与冲突回调;对"最后写入胜出"友好的字段(清单勾选、任务标题/状态)默认策略够用;对 `CheckIn.seq`、`BoardColumn.sortOrder` 这类"位置/序号型"数据需要自定义仲裁逻辑(上传端或同步流中处理),设计上可解,但要专门做。 + +--- + +## 4. 风险与成本汇总 + +| # | 风险/成本项 | 等级 | 说明 | +| --- | --- | --- | --- | +| 1 | MariaDB 兼容性未验证,MySQL 支持仅 Beta | 高 | 很可能需要迁移 PostgreSQL | +| 2 | 前端数据层全面重构(axios → 本地 SQLite 查询) | 高 | 涉及所有相关页面 | +| 3 | 用户编辑域主键整数自增 → UUID 迁移 | 中高 | schema + 路由幂等改造 | +| 4 | 复合唯一约束的离线冲突仲裁 | 中 | CheckIn.seq、列名、排序等 | +| 5 | Android WebView WASM SQLite/OPFS 兼容 | 中 | 需真机验证 | +| 6 | 新增 self-hosted 组件的运维 | 中 | 多一个常驻服务 | +| 7 | 时序大表同步范围控制 | 低 | Sync Rules 时间窗过滤即可 | + +对照收益:真正需要离线**编辑**的只有打卡 / 清单 / 任务三个域(数据量小、单用户为主、冲突场景有限);其余域离线**只读缓存**即可满足。 + +--- + +## 5. 结论与建议 + +### 5.1 结论 + +**可行,但不建议以"全项目接入 PowerSync"的方式落地。** PowerSync 的同步模型与项目的 Express API 写路径契合、认证可复用、桌面端可行;但 MariaDB/MySQL-beta 兼容、整数自增主键、前端数据层全面重构三座大山使总成本明显高于当前阶段的收益。 + +### 5.2 建议(按优先序) + +1. **首选(低成本方案)**:不引入 PowerSync,为用户编辑域实现轻量离线队列——前端把写请求落到 localStorage/IndexedDB 待发队列(含客户端生成的 UUID),联网后重放到现有 REST API(路由补幂等 upsert);只读页用 HTTP 缓存 + SW。可覆盖约 80% 的离线诉求,改动局限在 `src/api` 层与少量路由。 +2. **若决定采用 PowerSync**: + - 前置 1:源库迁移 PostgreSQL(避开 MySQL Beta + MariaDB 兼容雷区); + - 前置 2:仅将用户编辑域(Habit/CheckIn/CheckList*/任务模块)纳入 Sync Rules,时序与监控表排除或做时间窗过滤; + - 前置 3:这些表的主键/唯一标识改造为客户端可生成的 UUID; + - 分阶段:先桌面端(WebView2)验证全链路,再攻 Android WebView 兼容性; + - 冲突策略:字段级 LWW 为主,seq/sortOrder 类服务端仲裁。 +3. **不建议**:为离线只读域(新闻/指标/监控)上 PowerSync——用简单缓存即可,同步引擎属于过度设计。 + +--- + +## 6. 参考资料 + +- [PowerSync Service(GitHub,self-hosted 开源)](https://github.com/powersync-ja/powersync-service) +- [PowerSync 源数据库配置文档](https://docs.powersync.com/configuration/source-db/setup) +- [MySQL 后端支持 Beta 公告](https://releases.powersync.com/announcements/mysql-backend-database-support-has-reached-beta) +- [MySQL 后端支持 Alpha 公告](https://releases.powersync.com/announcements/announcing-mysql-as-a-source-database-alpha) +- 本项目:`prisma/schema.prisma`、`server/index.js`、`docs/V0.2/数据库设计文档.md`、`docs/V0.2/任务模块/任务模块数据库设计文档.md` diff --git a/prisma/migrations/20261025120000_task_mark_date/migration.sql b/prisma/migrations/20261025120000_task_mark_date/migration.sql new file mode 100644 index 0000000..2581ee0 --- /dev/null +++ b/prisma/migrations/20261025120000_task_mark_date/migration.sql @@ -0,0 +1,2 @@ +-- 任务时间标记: 完整日期存储, 前端展示 MM-dd +ALTER TABLE `Task` ADD COLUMN `markDate` DATETIME(3) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c7219d..9e7d406 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -449,6 +449,7 @@ model Task { title String @db.VarChar(200) description String @default("") @db.Text kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一) + markDate DateTime? // 时间标记 (完整日期存储, 展示为 MM-dd) status String @default("todo") @db.VarChar(8) doneAt DateTime? diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 6ac5c71..b62f235 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -70,10 +70,11 @@ router.post('/projects/:id/tasks', async (req, res, next) => { const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind } = req.body || {} + const { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind, markDate } = req.body || {} const cleanTitle = String(title || '').trim() if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' }) - if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) + if (cleanTitle.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' }) + const mark = parseMarkDate(markDate) const columns = await loadColumns(project.id) if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' }) @@ -108,6 +109,7 @@ router.post('/projects/:id/tasks', async (req, res, next) => { title: cleanTitle, description: String(description || ''), kind: kind ? String(kind).trim().slice(0, 20) : null, + markDate: mark, status: firstCol.boundStatus || 'todo', assigneeId: finalAssignee, creatorId: userId, @@ -158,6 +160,14 @@ router.get('/tasks/:id', async (req, res, next) => { } catch (e) { next(e) } }) +// 时间标记: 接受 YYYY-MM-DD (或完整 ISO), 存 DateTime; 非法输入 400 由调用方处理 +function parseMarkDate(v) { + if (v === null || v === undefined || v === '') return null + const d = new Date(v) + if (Number.isNaN(d.getTime())) return undefined // 非法 + return d +} + // PUT /api/tasks/:id 关键: 列/状态联动裁决 router.put('/tasks/:id', async (req, res, next) => { try { @@ -174,7 +184,7 @@ router.put('/tasks/:id', async (req, res, next) => { if (body.title !== undefined) { const t = String(body.title).trim() if (!t) return res.status(400).json({ error: '任务标题不能为空' }) - if (t.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) + if (t.length > 30) return res.status(400).json({ error: '任务标题不能超过 30 字' }) data.title = t } if (body.description !== undefined) data.description = String(body.description) @@ -183,6 +193,12 @@ router.put('/tasks/:id', async (req, res, next) => { const k = String(body.kind || '').trim().slice(0, 20) data.kind = k || null } + // 时间标记: 完整日期存储 + if (body.markDate !== undefined) { + const mark = parseMarkDate(body.markDate) + if (mark === undefined) return res.status(400).json({ error: '时间标记格式非法' }) + data.markDate = mark + } if (body.extras !== undefined) { if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' }) data.extras = body.extras.map((e) => ({ diff --git a/src/components/task/TaskCard.jsx b/src/components/task/TaskCard.jsx index e2aff52..889e02b 100644 --- a/src/components/task/TaskCard.jsx +++ b/src/components/task/TaskCard.jsx @@ -47,6 +47,9 @@ export default function TaskCard({