feat(V0.2): 标题30字+智能拆分 / 时间标记 MM-dd / 时间词前缀 (T-TK-14~16)
Docker Build and Push / build-image (push) Successful in 1m25s
Docker Build and Push / build-image (push) Successful in 1m25s
- 标题上限收紧至 30 字 (服务端校验+详情编辑框); 快速添加输入框 放宽到 200 字: 超长时在 30 字内分隔符处截断为标题, 完整原文进描述 - Task.markDate (DateTime, 迁移 20261025120000): 详情页 DatePicker 选择/清除, 列表行与看板卡显示 MM-dd 小标签 - 快速添加时间词前缀: 今天/明天/后天/本周/下周/本月/下月 开头 (后跟分隔符) 自动设为时间标记并移出标题; 本周=本周日, 本月=月末 - 需求/功能/数据库设计文档同步更新 T-TK-14~16 与 Task 表字段
This commit is contained in:
@@ -155,6 +155,9 @@
|
|||||||
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
|
| T-TK-11 | `TaskMember` 关联表 | 多对多,`@@unique([taskId,userId])` 去重 |
|
||||||
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
|
| T-TK-12 | `TaskTag` 表 + 卡片标签渲染 | 多标签,展示于卡片/条目 |
|
||||||
| T-TK-13 | 服务端维护 | `creatorId`/`createdAt` 不可改;`updatedAt` 自动刷新 |
|
| 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` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -176,8 +176,10 @@
|
|||||||
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
| `projectId` | `INT` | 否 | — | FK → `Project.id` `ON DELETE CASCADE` |
|
||||||
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
|
| `columnId` | `INT` | 否 | — | FK → `BoardColumn.id` `ON DELETE RESTRICT`(T-BD-01) |
|
||||||
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
|
| `parentId` | `INT` | **是** | `NULL` | 非空 = 一级子任务;FK → `Task.id` `ON DELETE CASCADE`(T-ST-01/11) |
|
||||||
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02) |
|
| `title` | `VARCHAR(200)` | 否 | — | 唯一必填(T-TK-02);应用层限 30 字(T-TK-14) |
|
||||||
| `description` | `TEXT` | 否 | `''` | — |
|
| `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) |
|
| `status` | `VARCHAR(8)` | 否 | `'todo'` | `todo`/`doing`/`done`(T-TK-03) |
|
||||||
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
|
| `doneAt` | `DATETIME(3)` | **是** | `NULL` | 完成时刻;**保留选项判定依据**(T-FL-10) |
|
||||||
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
|
| `assigneeId` | `INT` | **是** | `NULL` | 负责人(单人);新建默认创建者(T-TK-09) |
|
||||||
|
|||||||
@@ -240,6 +240,9 @@
|
|||||||
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
|
| T-TK-11 | 每条任务有**任务成员(多人)** | 可设置 0 ~ 多个成员,成员不重复 | P0 |
|
||||||
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
|
| T-TK-12 | 任务支持**标签** | 可添加多个标签,展示于条目 / 卡片 | P1 |
|
||||||
| T-TK-13 | 任务的创建人、创建时间、更新时间由系统维护,用户不可直接编辑 | 编辑任务不改变创建人与创建时间;任何修改刷新更新时间 | 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)
|
### 3.6 负责人 & 成员 UI 展示规则(T-AS)
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 任务时间标记: 完整日期存储, 前端展示 MM-dd
|
||||||
|
ALTER TABLE `Task` ADD COLUMN `markDate` DATETIME(3) NULL;
|
||||||
@@ -449,6 +449,7 @@ model Task {
|
|||||||
title String @db.VarChar(200)
|
title String @db.VarChar(200)
|
||||||
description String @default("") @db.Text
|
description String @default("") @db.Text
|
||||||
kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一)
|
kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一)
|
||||||
|
markDate DateTime? // 时间标记 (完整日期存储, 展示为 MM-dd)
|
||||||
status String @default("todo") @db.VarChar(8)
|
status String @default("todo") @db.VarChar(8)
|
||||||
doneAt DateTime?
|
doneAt DateTime?
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -70,10 +70,11 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
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()
|
const cleanTitle = String(title || '').trim()
|
||||||
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
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)
|
const columns = await loadColumns(project.id)
|
||||||
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
|
if (!columns.length) return res.status(400).json({ error: '项目没有可用的看板列' })
|
||||||
@@ -108,6 +109,7 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
title: cleanTitle,
|
title: cleanTitle,
|
||||||
description: String(description || ''),
|
description: String(description || ''),
|
||||||
kind: kind ? String(kind).trim().slice(0, 20) : null,
|
kind: kind ? String(kind).trim().slice(0, 20) : null,
|
||||||
|
markDate: mark,
|
||||||
status: firstCol.boundStatus || 'todo',
|
status: firstCol.boundStatus || 'todo',
|
||||||
assigneeId: finalAssignee,
|
assigneeId: finalAssignee,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
@@ -158,6 +160,14 @@ router.get('/tasks/:id', async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} 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 关键: 列/状态联动裁决
|
// PUT /api/tasks/:id 关键: 列/状态联动裁决
|
||||||
router.put('/tasks/:id', async (req, res, next) => {
|
router.put('/tasks/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -174,7 +184,7 @@ router.put('/tasks/:id', async (req, res, next) => {
|
|||||||
if (body.title !== undefined) {
|
if (body.title !== undefined) {
|
||||||
const t = String(body.title).trim()
|
const t = String(body.title).trim()
|
||||||
if (!t) return res.status(400).json({ error: '任务标题不能为空' })
|
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
|
data.title = t
|
||||||
}
|
}
|
||||||
if (body.description !== undefined) data.description = String(body.description)
|
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)
|
const k = String(body.kind || '').trim().slice(0, 20)
|
||||||
data.kind = k || null
|
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 (body.extras !== undefined) {
|
||||||
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
||||||
data.extras = body.extras.map((e) => ({
|
data.extras = body.extras.map((e) => ({
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export default function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bmeta">
|
<div className="bmeta">
|
||||||
|
{task.markDate && (
|
||||||
|
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||||
|
)}
|
||||||
{task.kind && (
|
{task.kind && (
|
||||||
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export default function TaskRow({
|
|||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
||||||
<div className="task-meta">
|
<div className="task-meta">
|
||||||
|
{task.markDate && (
|
||||||
|
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||||
|
)}
|
||||||
{task.kind && (
|
{task.kind && (
|
||||||
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
|
|||||||
import { projectApi, taskApi } from '../../api/task.js'
|
import { projectApi, taskApi } from '../../api/task.js'
|
||||||
import { filterVisible, msToNextBoundary } from './date.js'
|
import { filterVisible, msToNextBoundary } from './date.js'
|
||||||
import { getViewMode, setViewMode } from './prefs.js'
|
import { getViewMode, setViewMode } from './prefs.js'
|
||||||
|
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||||
import ListView from './ListView.jsx'
|
import ListView from './ListView.jsx'
|
||||||
import BoardView from './BoardView.jsx'
|
import BoardView from './BoardView.jsx'
|
||||||
import TaskDetail from './TaskDetail.jsx'
|
import TaskDetail from './TaskDetail.jsx'
|
||||||
@@ -141,12 +142,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新建任务 */
|
/** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
|
||||||
async function createTask(title, parentId = null) {
|
* 前段为标题, 余文并入描述 (T-TK-14/16) */
|
||||||
const t = String(title || '').trim()
|
async function createTask(rawTitle, parentId = null) {
|
||||||
if (!t) return
|
const raw = String(rawTitle || '').trim()
|
||||||
|
if (!raw) return
|
||||||
|
const { markDate, rest } = parseTimePrefix(raw)
|
||||||
|
const { title, desc } = splitTitle(rest)
|
||||||
|
if (!title) return
|
||||||
try {
|
try {
|
||||||
await taskApi.create(projectId, { title: t, parentId })
|
await taskApi.create(projectId, {
|
||||||
|
title,
|
||||||
|
description: desc || undefined,
|
||||||
|
markDate: markDate || undefined,
|
||||||
|
parentId,
|
||||||
|
})
|
||||||
setAdding(false)
|
setAdding(false)
|
||||||
load()
|
load()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -232,7 +242,8 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
<input
|
<input
|
||||||
className="inp"
|
className="inp"
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="输入任务标题,回车创建"
|
maxLength={200}
|
||||||
|
placeholder="输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
||||||
if (e.key === 'Escape') setAdding(false)
|
if (e.key === 'Escape') setAdding(false)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
import { ActionSheet, Popup, Toast, DatePicker } from 'antd-mobile'
|
||||||
import { taskApi } from '../../api/task.js'
|
import { taskApi } from '../../api/task.js'
|
||||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||||
import { kindColor } from './kind.js'
|
import { kindColor } from './kind.js'
|
||||||
|
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||||
@@ -20,12 +21,14 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
|||||||
assigneeId: task.assignee?.id ?? null,
|
assigneeId: task.assignee?.id ?? null,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
kind: task.kind || null,
|
kind: task.kind || null,
|
||||||
|
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
}))
|
}))
|
||||||
const [subs, setSubs] = useState(task.children || [])
|
const [subs, setSubs] = useState(task.children || [])
|
||||||
const [newSub, setNewSub] = useState('')
|
const [newSub, setNewSub] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||||
|
const [pickDate, setPickDate] = useState(false) // 时间标记日期选择
|
||||||
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
||||||
|
|
||||||
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
|
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
|
||||||
@@ -50,6 +53,7 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
|||||||
assigneeId: task.assignee?.id ?? null,
|
assigneeId: task.assignee?.id ?? null,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
kind: task.kind || null,
|
kind: task.kind || null,
|
||||||
|
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
})
|
})
|
||||||
setSubs(task.children || [])
|
setSubs(task.children || [])
|
||||||
@@ -66,6 +70,7 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
|||||||
memberIds: form.memberIds,
|
memberIds: form.memberIds,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
kind: form.kind,
|
kind: form.kind,
|
||||||
|
markDate: form.markDate || null,
|
||||||
tags: form.tags,
|
tags: form.tags,
|
||||||
})
|
})
|
||||||
Toast.show({ content: '已保存' })
|
Toast.show({ content: '已保存' })
|
||||||
@@ -111,10 +116,19 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addSub() {
|
async function addSub() {
|
||||||
const t = newSub.trim()
|
// 子任务快速添加同样支持时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||||
if (!t) return
|
const raw = newSub.trim()
|
||||||
|
if (!raw) return
|
||||||
|
const { markDate, rest } = parseTimePrefix(raw)
|
||||||
|
const { title, desc } = splitTitle(rest)
|
||||||
|
if (!title) return
|
||||||
try {
|
try {
|
||||||
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
const r = await taskApi.create(task.projectId, {
|
||||||
|
title,
|
||||||
|
description: desc || undefined,
|
||||||
|
markDate: markDate || undefined,
|
||||||
|
parentId: task.id,
|
||||||
|
})
|
||||||
setSubs((prev) => [...prev, r.item])
|
setSubs((prev) => [...prev, r.item])
|
||||||
setNewSub('')
|
setNewSub('')
|
||||||
onChanged?.()
|
onChanged?.()
|
||||||
@@ -156,7 +170,7 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
|||||||
<input
|
<input
|
||||||
className="inp"
|
className="inp"
|
||||||
value={form.title}
|
value={form.title}
|
||||||
maxLength={200}
|
maxLength={30}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,6 +202,29 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 时间标记: 可选日期, 卡片上以 MM-dd 显示 */}
|
||||||
|
<div className="field">
|
||||||
|
<label>时间标记</label>
|
||||||
|
<div className="pick-row">
|
||||||
|
<span className="pn">{form.markDate ? form.markDate.slice(5).replace('-', '/') : '未设置'}</span>
|
||||||
|
{form.markDate && (
|
||||||
|
<span className="link" onClick={() => setForm((p) => ({ ...p, markDate: null }))}>清除</span>
|
||||||
|
)}
|
||||||
|
<span className="link" onClick={() => setPickDate(true)}>选择 ›</span>
|
||||||
|
</div>
|
||||||
|
<DatePicker
|
||||||
|
visible={pickDate}
|
||||||
|
onClose={() => setPickDate(false)}
|
||||||
|
precision="day"
|
||||||
|
value={form.markDate ? new Date(form.markDate + 'T00:00:00') : new Date()}
|
||||||
|
onConfirm={(d) => {
|
||||||
|
const p2 = (n) => String(n).padStart(2, '0')
|
||||||
|
setForm((p) => ({ ...p, markDate: `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` }))
|
||||||
|
setPickDate(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 类型: 项目任务类型标签, 可清空 */}
|
{/* 类型: 项目任务类型标签, 可清空 */}
|
||||||
{(taskKinds || []).length > 0 && (
|
{(taskKinds || []).length > 0 && (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// 快速添加的标题解析: 时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||||
|
export const MAX_TITLE = 30
|
||||||
|
|
||||||
|
// 分隔符: 空格 / 中英文标点 / 斜杠等 —— 截断优先落在分隔符上, 不切词
|
||||||
|
const SEPARATOR = /[\s,,。;;、!!??::“”"'’‘()()\[\]【】\-_/\\|]/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 长文本拆分: ≤30 字原样; 超长时在 30 字内(且不少于 8 字)找最后一个分隔符截断,
|
||||||
|
* 前段为标题, **完整原文**作为描述 (整体进详情, 不丢内容)。
|
||||||
|
*/
|
||||||
|
export function splitTitle(raw) {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
if (text.length <= MAX_TITLE) return { title: text, desc: '' }
|
||||||
|
let cut = -1
|
||||||
|
for (let i = MAX_TITLE; i >= 8; i--) {
|
||||||
|
if (SEPARATOR.test(text[i - 1])) { cut = i; break }
|
||||||
|
}
|
||||||
|
if (cut < 0) cut = MAX_TITLE
|
||||||
|
return { title: text.slice(0, cut).trim(), desc: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间词 → 日期 (返回 YYYY-MM-DD):
|
||||||
|
// 今天/明天/后天 = 当日 +0/+1/+2
|
||||||
|
// 本周 = 本周日, 下周 = 下周日 (周一定义为一周开始)
|
||||||
|
// 本月 = 本月最后一天, 下月 = 下月最后一天
|
||||||
|
const toISO = (d) => {
|
||||||
|
const p = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateFromWord(word, now = new Date()) {
|
||||||
|
const day = (n) => { const d = new Date(now); d.setDate(d.getDate() + n); return toISO(d) }
|
||||||
|
const endOfWeek = (offsetWeeks) => {
|
||||||
|
const d = new Date(now)
|
||||||
|
const dow = (d.getDay() + 6) % 7 // 周一=0
|
||||||
|
d.setDate(d.getDate() + offsetWeeks * 7 + (6 - dow))
|
||||||
|
return toISO(d)
|
||||||
|
}
|
||||||
|
const endOfMonth = (offsetMonths) => {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() + offsetMonths + 1, 0)
|
||||||
|
return toISO(d)
|
||||||
|
}
|
||||||
|
switch (word) {
|
||||||
|
case '今天': return day(0)
|
||||||
|
case '明天': return day(1)
|
||||||
|
case '后天': return day(2)
|
||||||
|
case '本周': return endOfWeek(0)
|
||||||
|
case '下周': return endOfWeek(1)
|
||||||
|
case '本月': return endOfMonth(0)
|
||||||
|
case '下月': return endOfMonth(1)
|
||||||
|
default: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配最长的时间词前缀 (后面跟分隔符或直接结束)
|
||||||
|
const TIME_WORDS = ['本周', '下周', '本月', '下月', '今天', '明天', '后天']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析时间词前缀: "明天 交周报" → { markDate: '2026-10-26', rest: '交周报' }
|
||||||
|
* 无匹配时 rest 为原文, markDate 为 null。
|
||||||
|
*/
|
||||||
|
export function parseTimePrefix(raw) {
|
||||||
|
const text = String(raw || '').trim()
|
||||||
|
for (const w of TIME_WORDS) {
|
||||||
|
if (text.startsWith(w)) {
|
||||||
|
const after = text.slice(w.length)
|
||||||
|
if (after === '' || SEPARATOR.test(after[0])) {
|
||||||
|
return { markDate: dateFromWord(w), rest: after.trim() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { markDate: null, rest: text }
|
||||||
|
}
|
||||||
@@ -707,6 +707,12 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||||
|
|
||||||
/* 任务类型小标签: 列表行左下 / 看板卡左下 */
|
/* 任务类型小标签: 列表行左下 / 看板卡左下 */
|
||||||
|
.date-tag {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
font-size: 10px; line-height: 1; font-weight: 600;
|
||||||
|
color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 3px 5px;
|
||||||
|
flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.kind-tag {
|
.kind-tag {
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex; align-items: center;
|
||||||
font-size: 10px; line-height: 1; font-weight: 600; color: #fff;
|
font-size: 10px; line-height: 1; font-weight: 600; color: #fff;
|
||||||
|
|||||||
Reference in New Issue
Block a user