Compare commits
4
Commits
1cd5b85a4b
...
368e99a380
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368e99a380 | ||
|
|
39282b9633 | ||
|
|
7f213b5c9e | ||
|
|
a4cc4e9871 |
@@ -68,6 +68,19 @@ android-1.0 ── 从 master 同步代码后 push ──▶ 只触发 APK 构
|
||||
> 此前 `dev-*` 与 `master` 共用同一套生产环境,推送开发分支等于直接发布,容易把半成品带上线。
|
||||
> 现在开发分支的提交只有合并进 `master` 并推送后才会部署。
|
||||
|
||||
### 日常操作规则(工作目录约定)
|
||||
|
||||
- 主工作目录(`iboard/`)**默认停留在 `dev-0.2`**,所有修改都在 `dev-0.2` 上提交;`dev-0.2` 可随时 push(只跑 CI 校验,不部署)。
|
||||
- 独立功能开发使用**独立分支 + 独立工作目录**(如离线编辑在 `iboard-dev-0.2.1/` 的 `tmp-offline-edit` 分支),不占用 `dev-0.2`。
|
||||
- **发布流程(仅在明确需要发布时执行,结束后立刻回到 `dev-0.2`)**:
|
||||
|
||||
```bash
|
||||
git switch master
|
||||
git merge --ff-only dev-0.2
|
||||
git push origin master # push 即部署生产
|
||||
git switch dev-0.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 版本管理
|
||||
|
||||
@@ -319,7 +319,63 @@
|
||||
|
||||
---
|
||||
|
||||
## 15. 覆盖自检表
|
||||
## 15. 任务归档(T-ARC)
|
||||
|
||||
> 归档与 T-FL 显示控制的分层:**隐藏 = 展示过滤(可逆、不落库)**;**归档 = 移出主工作集(落库、可恢复)**。
|
||||
> 自动归档的倒计时起点是任务**被隐藏的时刻**(由 `doneAt + 列保留窗口` 推导,不新增 hiddenAt 字段)。
|
||||
|
||||
### 15.1 数据
|
||||
|
||||
| 落点 | 说明 |
|
||||
| --- | --- |
|
||||
| `Project.autoArchive VARCHAR(8) NOT NULL DEFAULT 'never'` | 自动归档档位:`3d` / `1w` / `1m` / `never`;项目级共享(同 T-FL-12 口径) |
|
||||
| `Task.archived BOOLEAN NOT NULL DEFAULT false` | 归档标记;归档**不迁移表**,主查询统一追加 `archived = false` |
|
||||
| `Task.archivedAt DATETIME(3) NULL` | 归档时刻(查询过滤与排序键) |
|
||||
| `Task.archiveSource VARCHAR(8) NULL` | `'manual'` / `'auto'`,溯源用 |
|
||||
| 索引 | `idx_task_project_archived (projectId, archived, archivedAt)`;`idx_task_project_status_archived (projectId, status, archived)` |
|
||||
| 迁移(待实施) | `20261026000000_task_archive`:三列 + 两索引(MySQL DDL 见数据库设计文档 §3.1/§3.4) |
|
||||
|
||||
### 15.2 API
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `POST /api/tasks/:id/archive` | 手动归档:父任务连带全部后代置 `archived/archivedAt/archiveSource='manual'`;幂等(已归档直接返回当前态) |
|
||||
| `POST /api/tasks/:id/unarchive` | 恢复:父任务连带后代清除归档三字段;原列因 `ON DELETE RESTRICT` 必存在,状态保持不变 |
|
||||
| `GET /api/projects/:id/tasks/archived?keyword&kind&columnId&from&to&cursor&limit` | 归档查询:`keyword` 标题 LIKE、`kind`、`columnId`、`from/to`(归档日期,含边界);`limit` 默认 20、≤100;游标 = `archivedAt+id` 复合降序,返回 `{ items, nextCursor }`,`nextCursor` 为空即到底 |
|
||||
| `PUT /api/projects/:id` | 增接受 `autoArchive`,枚举校验(非法 400) |
|
||||
| 既有查询 | `GET /projects/:id/tasks`、统计、列计数等全部追加 `archived: false`;`taskCount`(projectSummary)同口径 |
|
||||
|
||||
### 15.3 自动归档执行
|
||||
|
||||
- **归档到期判定**:`due = 隐藏时刻 + autoArchive 档位`;隐藏时刻 = `doneAt + 列保留窗口`(复用 `retentionStart` 口径,含 `today/3d/week/month`;`forever` 列任务永不隐藏、不进入自动归档)。
|
||||
- **执行时机(双保险)**:
|
||||
1. **读时惰性**:加载项目任务时对该项目先执行一轮到期归档(保证用户看到的数据与判定一致,刚到期的任务当场消失);
|
||||
2. **每小时 cron 兜底**:扫描全部 `autoArchive != 'never'` 项目的到期 done 任务批量归档(`archiveSource='auto'`)。
|
||||
- **幂等**:`archived = false` 且 `status = 'done'` 且 `doneAt` 非空才进入判定;重复执行无副作用。
|
||||
- **批量写**:cron 用 `updateMany`(按到期集合),读时惰性用单项目范围 `updateMany`。
|
||||
|
||||
### 15.4 前端
|
||||
|
||||
| 落点 | 说明 |
|
||||
| --- | --- |
|
||||
| 任务 `⋯` 菜单 | 新增「归档」(`Dialog.confirm`,含子任务时提示连带条数);成功后列表/看板乐观移除 |
|
||||
| 项目管理 | 「归档设置」分区:`Selector` 四档(3 天后/一周后/一月后/永不),保存即生效 |
|
||||
| 项目视图头部 | 项目栏新增「已归档」入口 → 全屏弹层:过滤条(关键词/类型/列/归档时段)+ 分页「加载更多」+ 行内「恢复 / 删除」 |
|
||||
| 主视图 | 列表/看板无需感知归档(数据源已排除);恢复后经 `load()` 回到原位 |
|
||||
|
||||
### 15.5 边界
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| 归档父任务时新增/恢复子任务 | 子任务随父判定:父 archived 则子不单独出现在主列表(查询按父排除) |
|
||||
| 自动归档期间用户正在编辑该任务 | 详情弹层保存正常(归档不锁写);保存后任务仍在归档集 |
|
||||
| 恢复后任务再次到期隐藏 | 重新进入自动归档倒计时(`doneAt` 未变则可能立即再次到期,属预期) |
|
||||
| 「永不」档位 | 不做任何自动归档;手动归档不受影响 |
|
||||
| 拷贝任务 | 拷贝件不继承归档态(`copyTask` 不复制 archived 三字段) |
|
||||
|
||||
---
|
||||
|
||||
## 16. 覆盖自检表
|
||||
|
||||
| 模块 | 编号范围 | 条数 | 本文对应章节 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -337,6 +393,7 @@
|
||||
| 数据需求 | T-DT-01 ~ 09 | 9 | §12 |
|
||||
| 非功能需求 | T-NF-01 ~ 05 | 5 | §13 |
|
||||
| 异常与边界 | T-EX-01 ~ 19 | 19 | §14 |
|
||||
| **合计** | — | **149** | — |
|
||||
| 任务归档 | T-ARC-01 ~ 08 | 8 | §15 |
|
||||
| **合计** | — | **157** | — |
|
||||
|
||||
> 需求文档共 **149 条**需求点,本文逐条给出设计落点,覆盖率 **149/149**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11` 为评审后新增,编号不连续,非笔误。)
|
||||
> 需求文档共 **157 条**需求点,本文逐条给出设计落点,覆盖率 **157/157**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11`、`T-ARC-*` 为评审后新增,编号不连续,非笔误。)
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
| `ownerId` | `INT` | 否 | — | 创建人(可退出项目,故与成员表分离) |
|
||||
| `isPersonal` | `BOOLEAN` | 否 | `false` | 个人默认项目:不可删、不可加成员(T-PJ-02/03) |
|
||||
| `archived` | `BOOLEAN` | 否 | `false` | 归档(T-PJ-09) |
|
||||
| `notes` | `JSON` | 否 | `[]` | 项目信息块 `[{title, content}]`(T-PJ-13) |
|
||||
| `taskKinds` | `JSON` | **是** | `NULL` | 任务类型标签列表;`NULL` 时服务端回落默认四类(T-TK-14) |
|
||||
| `icon` | `TEXT` | **是** | `NULL` | 项目图标 data URL(客户端裁剪 96x96 JPEG) |
|
||||
| `autoArchive` | `VARCHAR(8)` | 否 | `'never'` | 自动归档档位:`3d`/`1w`/`1m`/`never`(T-ARC-02) |
|
||||
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
|
||||
| `updatedAt` | `DATETIME(3)` | 否 | — | `@updatedAt` |
|
||||
|
||||
@@ -186,6 +190,9 @@
|
||||
| `creatorId` | `INT` | 否 | — | 创建人;拷贝时为操作者(T-CP-04) |
|
||||
| `extras` | `JSON` | 否 | `[]` | 扩展信息卡片 `[{label,value,url?}]`(T-TK-04/05) |
|
||||
| `sortOrder` | `INT` | 否 | `0` | 列内/列表内排序 |
|
||||
| `archived` | `BOOLEAN` | 否 | `false` | 归档标记;主查询一律 `archived = false`(T-ARC-01/03) |
|
||||
| `archivedAt` | `DATETIME(3)` | **是** | `NULL` | 归档时刻;归档查询的过滤/排序键(T-ARC-04) |
|
||||
| `archiveSource` | `VARCHAR(8)` | **是** | `NULL` | `'manual'` / `'auto'` 溯源(T-ARC-01/02) |
|
||||
| `createdAt` | `DATETIME(3)` | 否 | `now()` | — |
|
||||
| `updatedAt` | `DATETIME(3)` | 否 | — | — |
|
||||
|
||||
@@ -202,6 +209,8 @@
|
||||
| `idx_task_project_status` | `(projectId, status)` | — | 列表/统计 |
|
||||
| `idx_task_parent` | `(parentId)` | — | 取子任务 |
|
||||
| `idx_task_done_at` | `(projectId, doneAt)` | — | 保留窗口计算 |
|
||||
| `idx_task_project_archived` | `(projectId, archived, archivedAt)` | — | 已归档查询(T-ARC-04) |
|
||||
| `idx_task_project_status_arch` | `(projectId, status, archived)` | — | 主列表/看板排除归档(T-ARC-03) |
|
||||
|
||||
> \* `creatorId` 用 Cascade 会丢失"他人创建的任务"的历史;更稳妥的做法是 `Restrict` 并禁止删除有任务的用户。**建议改为 `Restrict`**,与既有 `User` 删除行为保持一致(本期系统无删除用户入口)。
|
||||
|
||||
@@ -214,6 +223,8 @@
|
||||
| 子任务 `projectId` == 父任务 `projectId`(T-EX-12) | 应用层 + 事务内校验 |
|
||||
| `parentId` 不得指向自身或其后代(T-EX-15) | 应用层 |
|
||||
| `status='done'` 时写 `doneAt=now()`;退出 `done` 时置 `NULL` | 应用层(领域统一裁决) |
|
||||
| 归档/恢复父任务连带全部后代,同事务内更新 `archived`/`archivedAt`/`archiveSource`(T-ARC-01/05) | 应用层 |
|
||||
| `autoArchive` 枚举 `3d`/`1w`/`1m`/`never`,非法 400(T-ARC-02) | 应用层 |
|
||||
| `assigneeId` 必须是 `ProjectMember` 中的用户(T-AS-03) | 应用层 |
|
||||
|
||||
### 3.5 `TaskMember` — 任务成员
|
||||
@@ -375,6 +386,7 @@ model User {
|
||||
| 权限校验 | `SELECT 1 FROM ProjectMember WHERE projectId=? AND userId=?` |
|
||||
| 保留窗口过滤 | 服务端可选:`status <> 'done' OR (doneAt >= ?)`;前端另有一份同口径实现 |
|
||||
| 自动归位选列 | 内存中对已加载的 `columns`(按 sortOrder)`find(c => c.boundStatus === status)` |
|
||||
| 已归档查询(T-ARC-04) | `WHERE projectId=? AND archived=1 [AND kind=?] [AND columnId=?] [AND archivedAt BETWEEN ? AND ?] [AND title LIKE ?] ORDER BY archivedAt DESC, id DESC LIMIT ?+1`;多取 1 条判定 `nextCursor` |
|
||||
|
||||
**防 N+1**:一次请求返回 `{ project, columns, members, tasks }`,子任务与父任务同批返回,由前端按 `parentId` 分组。
|
||||
|
||||
@@ -391,6 +403,16 @@ model User {
|
||||
3. 建唯一索引与普通索引;
|
||||
4. **不做数据回填**(本模块为新功能,无历史数据)。
|
||||
|
||||
后续增量迁移:
|
||||
|
||||
| 迁移 | 内容 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `20261022120000_add_checkin_checklist` | 打卡/清单模块 | 已上线 |
|
||||
| `20261025030000_task_kind` | `Project.taskKinds` / `Task.kind` | 已上线 |
|
||||
| `20261025120000_task_mark_date` | `Task.markDate` | 已上线 |
|
||||
| `20261025130000_project_icon` | `Project.icon` | 已上线 |
|
||||
| `20261026000000_task_archive` | 任务归档三字段 + 两索引(T-ARC,DDL 见 §3.1/§3.4 与功能设计 §15) | **待实施** |
|
||||
|
||||
### 6.2 个人默认项目初始化
|
||||
|
||||
```sql
|
||||
|
||||
@@ -384,12 +384,29 @@
|
||||
| T-EX-19 | 新增列时已达列数上限 | 拒绝新增并提示已达上限,不产生空列 | P1 |
|
||||
| T-EX-18 | 已完成任务跨周期(周一零点 / 月初零点)后应隐藏 | **当场消失**并带移除动画,其余任务平滑重排,不留空白 | P1 |
|
||||
|
||||
### 3.15 任务归档(T-ARC)
|
||||
|
||||
> 与「已完成显示控制(T-FL)」的关系:T-FL 决定任务**看不看得见**(可逆的展示过滤),
|
||||
> 归档决定任务**在不在主工作集**(数据层面的收纳)。隐藏是自动归档的前置条件,归档移出主列表,
|
||||
> 可通过「已归档查询」恢复。
|
||||
|
||||
| 编号 | 需求描述 | 验收要点 | 优先级 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-ARC-01 | **手动归档**:任务 `⋯` 菜单增加「归档」;任意未归档任务(不限状态)均可归档;父任务归档时**连带全部子任务**一并归档;操作需二次确认(提示连带条数) | 归档后任务从列表/看板消失;子任务同步消失;确认弹窗条数正确 | P0 |
|
||||
| T-ARC-02 | **自动归档**:项目级配置「**隐藏的任务**多久归档」,可选 `3 天后 / 一周后 / 一月后 / 永不`(默认永不);已隐藏(T-FL 保留窗口过期)的 done 任务到期后由系统自动归档 | 配置保存后生效;隐藏任务到期被自动归档;「永不」时永不自动归档;未隐藏的任务不受影响 | P1 |
|
||||
| T-ARC-03 | **归档排除**:已归档任务从任务列表、看板、列计数、项目任务数统计中排除 | 归档后各视图与计数立即减少;恢复后回到原位 | P0 |
|
||||
| T-ARC-04 | **已归档查询**:独立入口查看本项目已归档任务;支持关键词(标题)、任务类型、看板列、归档时间范围过滤;**游标分页**支撑大量数据(千级) | 过滤条件生效;分页可连续加载到底;千级数据下查询流畅 | P0 |
|
||||
| T-ARC-05 | **恢复**:已归档任务可取消归档,回到原看板列(列被删除的场景因外键 `ON DELETE RESTRICT` 不存在),状态保持不变;父任务恢复连带子任务 | 恢复后任务与子任务重新出现在列表/看板原列 | P0 |
|
||||
| T-ARC-06 | **彻底删除**:已归档任务可物理删除(含子任务),二次确认 | 删除后不再出现在已归档查询 | P1 |
|
||||
| T-ARC-07 | **配置入口**:项目管理新增「归档设置」分区,展示与修改自动归档档位 | 修改后即时保存并对全项目生效 | P1 |
|
||||
| T-ARC-08 | **权限口径**:项目成员均可归档/恢复/删除归档任务(权限均等口径同 T-PM-04) | 与其他写操作权限一致,无额外限制 | P1 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 优先级汇总(本期交付范围)
|
||||
|
||||
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17。
|
||||
- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-FL-14;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19。
|
||||
- **P0(必须)**:T-VW-01~04、T-VW-06~09;T-COL-01~07、T-COL-09、T-COL-10;T-BD-01~10、13~16;T-FL-01~13、15;T-TK-01~11;T-AS-01~05、07;T-ST-01~09、11~14;T-PJ-01~08、10~13;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~11;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17;T-ARC-01、03、04、05。
|
||||
- **P1(应该)**:T-VW-05、10;T-COL-08;T-BD-11、12;T-FL-14;T-TK-12、13;T-AS-06;T-ST-10、15;T-PJ-09;T-PM-03;T-CP-07、09;T-UI-06;T-DT-09;T-NF-05;T-EX-06、08、13、14、18、19;T-ARC-02、06、07、08。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 任务归档 (T-ARC): 项目自动归档档位 + 任务归档三字段与查询索引
|
||||
ALTER TABLE `Project` ADD COLUMN `autoArchive` VARCHAR(8) NOT NULL DEFAULT 'never';
|
||||
ALTER TABLE `Task` ADD COLUMN `archived` BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE `Task` ADD COLUMN `archivedAt` DATETIME(3) NULL;
|
||||
ALTER TABLE `Task` ADD COLUMN `archiveSource` VARCHAR(8) NULL;
|
||||
ALTER TABLE `Task` ADD INDEX `idx_task_project_archived` (`projectId`, `archived`, `archivedAt`);
|
||||
ALTER TABLE `Task` ADD INDEX `idx_task_project_status_arch` (`projectId`, `status`, `archived`);
|
||||
@@ -396,6 +396,7 @@ model Project {
|
||||
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
||||
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
|
||||
icon String? @db.Text // 项目图标 (data URL, 客户端裁剪为 96x96 JPEG)
|
||||
autoArchive String @default("never") @db.VarChar(8) // 自动归档档位: 3d/1w/1m/never (T-ARC-02)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -461,6 +462,9 @@ model Task {
|
||||
|
||||
extras Json @default("[]")
|
||||
sortOrder Int @default(0)
|
||||
archived Boolean @default(false)
|
||||
archivedAt DateTime?
|
||||
archiveSource String? @db.VarChar(8) // manual | auto (T-ARC)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -471,6 +475,8 @@ model Task {
|
||||
@@index([projectId, status])
|
||||
@@index([parentId])
|
||||
@@index([projectId, doneAt])
|
||||
@@index([projectId, archived, archivedAt])
|
||||
@@index([projectId, status, archived])
|
||||
}
|
||||
|
||||
model TaskMember {
|
||||
|
||||
+110
-94
@@ -1,94 +1,110 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||
|
||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||
async function importNewsFetcher() {
|
||||
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
||||
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
||||
}
|
||||
|
||||
async function alertError(taskName, message) {
|
||||
try {
|
||||
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
||||
await insertCronAlert(taskName, message)
|
||||
} catch (e) {
|
||||
console.error('[cron] 报警写入失败(忽略):', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
async function openConn() {
|
||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
}
|
||||
|
||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
console.log('[cron] 6:00 触发')
|
||||
try {
|
||||
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
const conn = await openConn()
|
||||
try {
|
||||
const a = await checkAfterRefresh(conn)
|
||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
await alertError('新闻拉取+报警检测', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
|
||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
||||
// - 0 命中: 不调 apizero (省配额)
|
||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
||||
function scheduleOilHistory() {
|
||||
cron.schedule('10 6 * * *', async () => {
|
||||
console.log('[cron] 6:10 油价历史触发')
|
||||
try {
|
||||
const year = new Date().getFullYear()
|
||||
const sched = await oil.getSchedule(year)
|
||||
const list = sched.schedule || []
|
||||
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
|
||||
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||
const today = new Date(todayStr + 'T00:00:00+08:00')
|
||||
const yesterdayMs = today.getTime() - 86400000
|
||||
const pendingPast = list.filter((it) => {
|
||||
if (it.status !== '待定') return false
|
||||
const t = new Date(it.date + 'T00:00:00').getTime()
|
||||
return Number.isFinite(t) && t <= yesterdayMs
|
||||
})
|
||||
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
|
||||
if (pendingPast.length === 0) {
|
||||
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
|
||||
return
|
||||
}
|
||||
const r = await history.fetchAndStore()
|
||||
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
|
||||
} catch (e) {
|
||||
if (e instanceof ApizeroError) {
|
||||
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
|
||||
} else {
|
||||
console.error('[cron] 油价历史任务失败:', e.message)
|
||||
}
|
||||
await alertError('浙江油价历史抓取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleOilHistory()
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
import { ApizeroError, oil, history } from './apizero/index.js'
|
||||
|
||||
// scripts/ 下的可选脚本模块 (部署容器可能缺失或依赖不全): 惰性加载,
|
||||
// 单个 fetcher 缺失只降级对应任务, 不拖垮 cron 整体注册。
|
||||
async function importNewsFetcher() {
|
||||
const mod = await import('../scripts/fetchers/news.fetcher.js')
|
||||
return { fetchNewsList: mod.fetchNewsList, persistNews: mod.persistNews }
|
||||
}
|
||||
|
||||
async function alertError(taskName, message) {
|
||||
try {
|
||||
const { insertCronAlert } = await import('../scripts/lib/cron-alert.js')
|
||||
await insertCronAlert(taskName, message)
|
||||
} catch (e) {
|
||||
console.error('[cron] 报警写入失败(忽略):', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
async function openConn() {
|
||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
}
|
||||
|
||||
// 每天 6:00 (Asia/Shanghai) 拉取新闻 + 跑跨源对比, 出错生成报警
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 6 * * *', async () => {
|
||||
console.log('[cron] 6:00 触发')
|
||||
try {
|
||||
const { fetchNewsList, persistNews } = await importNewsFetcher()
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
const conn = await openConn()
|
||||
try {
|
||||
const a = await checkAfterRefresh(conn)
|
||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
await alertError('新闻拉取+报警检测', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
// 每天 6:10 (Asia/Shanghai) 抓浙江油价历史, 出错生成报警
|
||||
// 逻辑: 拉今年 schedule, 检查待定且 <= 昨天的调价日, 命中就抓 price?province=浙江 存库
|
||||
// - 0 命中: 不调 apizero (省配额)
|
||||
// - >0 命中: 调一次, 4 个油品批量入库 (unique 兜底去重)
|
||||
function scheduleOilHistory() {
|
||||
cron.schedule('10 6 * * *', async () => {
|
||||
console.log('[cron] 6:10 油价历史触发')
|
||||
try {
|
||||
const year = new Date().getFullYear()
|
||||
const sched = await oil.getSchedule(year)
|
||||
const list = sched.schedule || []
|
||||
// 待定 且 日期 <= 昨天 (用 Asia/Shanghai 时区推导, 与 cron 触发时区对齐)
|
||||
const todayStr = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' })
|
||||
const today = new Date(todayStr + 'T00:00:00+08:00')
|
||||
const yesterdayMs = today.getTime() - 86400000
|
||||
const pendingPast = list.filter((it) => {
|
||||
if (it.status !== '待定') return false
|
||||
const t = new Date(it.date + 'T00:00:00').getTime()
|
||||
return Number.isFinite(t) && t <= yesterdayMs
|
||||
})
|
||||
console.log('[cron] 待定且已过的调价日: ' + pendingPast.length + ' 个')
|
||||
if (pendingPast.length === 0) {
|
||||
console.log('[cron] 油价历史: 0 命中, 跳过 apizero 调用')
|
||||
return
|
||||
}
|
||||
const r = await history.fetchAndStore()
|
||||
console.log('[cron] 油价历史入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped + ' (date=' + r.date + ')')
|
||||
} catch (e) {
|
||||
if (e instanceof ApizeroError) {
|
||||
console.error('[cron] 油价历史 apizero 错误: code=' + e.code + ' msg=' + e.message)
|
||||
} else {
|
||||
console.error('[cron] 油价历史任务失败:', e.message)
|
||||
}
|
||||
await alertError('浙江油价历史抓取', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每天 06:10 浙江油价历史抓取')
|
||||
}
|
||||
|
||||
// 每小时兜底: 任务自动归档扫描 (T-ARC-02; 项目加载时另有读时惰性执行)
|
||||
function scheduleAutoArchive() {
|
||||
cron.schedule('30 * * * *', async () => {
|
||||
try {
|
||||
const { runAutoArchiveSweep } = await import('./task-domain/archive.js')
|
||||
const n = await runAutoArchiveSweep()
|
||||
if (n > 0) console.log('[cron] 自动归档:', n, '条')
|
||||
} catch (e) {
|
||||
console.error('[cron] 自动归档失败:', e.message)
|
||||
await alertError('任务自动归档', e.message)
|
||||
}
|
||||
}, { timezone: 'Asia/Shanghai' })
|
||||
console.log('[cron] 已注册: 每小时 30 分 任务自动归档扫描')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
scheduleOilHistory()
|
||||
scheduleAutoArchive()
|
||||
|
||||
@@ -41,6 +41,9 @@ export async function ensurePersonalProject(userId) {
|
||||
// 任务类型标签: 项目级自定义, 未配置时用默认四类
|
||||
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
||||
|
||||
// 自动归档档位 (T-ARC-02)
|
||||
export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never']
|
||||
|
||||
export function normTaskKinds(v) {
|
||||
if (!Array.isArray(v)) return null
|
||||
const out = []
|
||||
@@ -63,6 +66,7 @@ function projectSummary(p) {
|
||||
updatedAt: p.updatedAt,
|
||||
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||
icon: p.icon || null,
|
||||
autoArchive: AUTO_ARCHIVE_OPTIONS.includes(p.autoArchive) ? p.autoArchive : 'never',
|
||||
memberCount: p._count?.members ?? undefined,
|
||||
taskCount: p._count?.tasks ?? undefined,
|
||||
}
|
||||
@@ -77,7 +81,7 @@ router.get('/', async (req, res, next) => {
|
||||
await ensurePersonalProject(userId)
|
||||
const rows = await prisma.project.findMany({
|
||||
where: { members: { some: { userId } } },
|
||||
include: { _count: { select: { members: true, tasks: true } } },
|
||||
include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } },
|
||||
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
||||
})
|
||||
res.json({ items: rows.map(projectSummary) })
|
||||
@@ -98,7 +102,7 @@ router.post('/', async (req, res, next) => {
|
||||
await createDefaultColumns(project.id)
|
||||
const full = await prisma.project.findUnique({
|
||||
where: { id: project.id },
|
||||
include: { _count: { select: { members: true, tasks: true } } },
|
||||
include: { _count: { select: { members: true, tasks: { where: { archived: false } } } } },
|
||||
})
|
||||
res.json({ item: projectSummary(full) })
|
||||
} catch (e) { next(e) }
|
||||
@@ -135,7 +139,7 @@ router.put('/:id', async (req, res, next) => {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
const { name, archived, notes, taskKinds, icon } = req.body || {}
|
||||
const { name, archived, notes, taskKinds, icon, autoArchive } = req.body || {}
|
||||
const data = {}
|
||||
if (name !== undefined) {
|
||||
const t = String(name).trim()
|
||||
@@ -159,6 +163,11 @@ router.put('/:id', async (req, res, next) => {
|
||||
} else if (icon === null) {
|
||||
data.icon = null
|
||||
}
|
||||
// 自动归档档位 (T-ARC-02)
|
||||
if (autoArchive !== undefined) {
|
||||
if (!AUTO_ARCHIVE_OPTIONS.includes(autoArchive)) return res.status(400).json({ error: '自动归档档位非法' })
|
||||
data.autoArchive = autoArchive
|
||||
}
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
||||
if (notes !== undefined) {
|
||||
|
||||
+84
-1
@@ -8,10 +8,13 @@ import {
|
||||
import { resolveTaskWrite } from '../task-domain/resolveStatus.js'
|
||||
import { filterVisible } from '../task-domain/retention.js'
|
||||
import { copyTask } from '../task-domain/copyTask.js'
|
||||
import { runAutoArchiveForProject } from '../task-domain/archive.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const TASK_INCLUDE = {
|
||||
column: true,
|
||||
assignee: { select: { id: true, name: true } },
|
||||
@@ -19,6 +22,7 @@ const TASK_INCLUDE = {
|
||||
members: { include: { user: { select: { id: true, name: true } } } },
|
||||
tags: true,
|
||||
children: {
|
||||
where: { archived: false },
|
||||
include: {
|
||||
column: true,
|
||||
assignee: { select: { id: true, name: true } },
|
||||
@@ -50,9 +54,12 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
// 读时惰性自动归档 (T-ARC-02): 保证刚到期的隐藏任务当场消失
|
||||
await runAutoArchiveForProject(project.id)
|
||||
|
||||
const columns = await loadColumns(project.id)
|
||||
const rows = await prisma.task.findMany({
|
||||
where: { projectId: project.id, parentId: null },
|
||||
where: { projectId: project.id, parentId: null, archived: false },
|
||||
include: TASK_INCLUDE,
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
@@ -63,6 +70,82 @@ router.get('/projects/:id/tasks', async (req, res, next) => {
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 归档 (T-ARC) ----------
|
||||
|
||||
// POST /api/tasks/:id/archive 手动归档: 连带全部子任务, 幂等
|
||||
router.post('/tasks/:id/archive', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const found = await loadTaskForUser(req.params.id, userId)
|
||||
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
|
||||
const { task } = found
|
||||
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
|
||||
const r = await prisma.task.updateMany({
|
||||
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
|
||||
data: { archived: true, archivedAt: new Date(), archiveSource: 'manual' },
|
||||
})
|
||||
res.json({ ok: true, count: r.count })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/tasks/:id/unarchive 恢复: 回原列, 状态不变, 连带子任务
|
||||
router.post('/tasks/:id/unarchive', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const found = await loadTaskForUser(req.params.id, userId)
|
||||
if (!found) return res.status(404).json({ error: '任务不存在或无权限' })
|
||||
const { task } = found
|
||||
const children = await prisma.task.findMany({ where: { parentId: task.id }, select: { id: true } })
|
||||
const r = await prisma.task.updateMany({
|
||||
where: { id: { in: [task.id, ...children.map((c) => c.id)] } },
|
||||
data: { archived: false, archivedAt: null, archiveSource: null },
|
||||
})
|
||||
res.json({ ok: true, count: r.count })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// GET /api/projects/:id/tasks/archived 已归档查询: 过滤 + 游标分页 (T-ARC-04)
|
||||
router.get('/projects/:id/tasks/archived', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const project = await loadProjectForUser(req.params.id, userId)
|
||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||
|
||||
const q = req.query || {}
|
||||
const where = { projectId: project.id, archived: true, parentId: null }
|
||||
if (q.keyword) where.title = { contains: String(q.keyword).trim() }
|
||||
if (q.kind) where.kind = String(q.kind)
|
||||
if (q.columnId && Number.isFinite(Number(q.columnId))) where.columnId = Number(q.columnId)
|
||||
if (q.from && DATE_RE.test(q.from)) where.archivedAt = { ...(where.archivedAt || {}), gte: new Date(q.from + 'T00:00:00+08:00') }
|
||||
if (q.to && DATE_RE.test(q.to)) where.archivedAt = { ...(where.archivedAt || {}), lte: new Date(q.to + 'T23:59:59.999+08:00') }
|
||||
|
||||
const limit = Math.min(100, Math.max(1, Number(q.limit) || 20))
|
||||
if (q.cursor) {
|
||||
const [ts, id] = String(q.cursor).split('|')
|
||||
const t = new Date(ts)
|
||||
if (Number.isNaN(t.getTime()) || !Number.isFinite(Number(id))) {
|
||||
return res.status(400).json({ error: 'cursor 无效' })
|
||||
}
|
||||
where.OR = [
|
||||
{ archivedAt: { lt: t } },
|
||||
{ archivedAt: t, id: { lt: Number(id) } },
|
||||
]
|
||||
}
|
||||
|
||||
const rows = await prisma.task.findMany({
|
||||
where,
|
||||
include: TASK_INCLUDE,
|
||||
orderBy: [{ archivedAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1,
|
||||
})
|
||||
const hasMore = rows.length > limit
|
||||
const page = rows.slice(0, limit)
|
||||
const last = page[page.length - 1]
|
||||
const nextCursor = hasMore && last ? `${last.archivedAt.toISOString()}|${last.id}` : null
|
||||
res.json({ items: page.map(shape), nextCursor })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/projects/:id/tasks { title, description?, parentId?, assigneeId?, memberIds?, tags? }
|
||||
router.post('/projects/:id/tasks', async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// 任务归档领域逻辑 (T-ARC)
|
||||
// 自动归档判定: 倒计时起点 = 任务"被隐藏的时刻"(与 date.js isVisibleByRetention
|
||||
// 精确同口径推导): 完成日 + 保留窗口后的上海零点; 加上项目 autoArchive 档位即到期时刻。
|
||||
// 执行双保险: 项目任务加载时惰性执行 (runAutoArchiveForProject) + 每小时 cron 兜底。
|
||||
import { prisma } from '../db.js'
|
||||
import { toDateStr, addDays, startOfWeek, startOfMonth } from './date.js'
|
||||
|
||||
export const AUTO_ARCHIVE_OPTIONS = ['3d', '1w', '1m', 'never']
|
||||
const DAY_MS = 24 * 3600 * 1000
|
||||
const SHANGHAI_OFFSET_MS = 8 * 3600 * 1000
|
||||
|
||||
/** 上海日历日 "YYYY-MM-DD" 的零点时刻 (UTC 表示) */
|
||||
function shanghaiMidnight(dayStr) {
|
||||
const [y, m, d] = dayStr.split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d) - SHANGHAI_OFFSET_MS)
|
||||
}
|
||||
|
||||
/** 上海日历日 + n 个月后的零点 (月末溢出由 Date 归一化) */
|
||||
function addMonths(dayStr, n) {
|
||||
const [y, m, d] = dayStr.split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1 + n, d) - SHANGHAI_OFFSET_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务被隐藏的时刻: doneAt 所在完成日之后, 保留窗口结束的上海零点。
|
||||
* 与 date.js isVisibleByRetention 的可见判定精确同口径:
|
||||
* never → doneAt 即刻隐藏; today → 完成日次日零点; 3d → 完成日+3 零点;
|
||||
* week → 完成周次下一周一零点; month → 次月 1 日零点; forever → 永不隐藏 (null)
|
||||
*/
|
||||
export function hiddenAtOf(doneAt, option) {
|
||||
if (!doneAt) return null
|
||||
const day = toDateStr(doneAt)
|
||||
switch (option) {
|
||||
case 'never': return new Date(doneAt)
|
||||
case 'today': return shanghaiMidnight(addDays(day, 1))
|
||||
case '3d': return shanghaiMidnight(addDays(day, 3))
|
||||
case 'week': return shanghaiMidnight(addDays(startOfWeek(day), 7))
|
||||
case 'month': return shanghaiMidnight(String(addDays(day, 32)).slice(0, 8) + '01')
|
||||
default: return null // forever: 永不隐藏
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动归档到期时刻: 隐藏时刻 + 档位; 'never' 或无法隐藏 → null */
|
||||
export function autoArchiveDue(autoArchive, doneAt, columnRetention) {
|
||||
if (!autoArchive || autoArchive === 'never') return null
|
||||
const hidden = hiddenAtOf(doneAt, columnRetention)
|
||||
if (!hidden) return null
|
||||
switch (autoArchive) {
|
||||
case '3d': return new Date(hidden.getTime() + 3 * DAY_MS)
|
||||
case '1w': return new Date(hidden.getTime() + 7 * DAY_MS)
|
||||
case '1m': {
|
||||
const d = new Date(hidden)
|
||||
d.setUTCMonth(d.getUTCMonth() + 1)
|
||||
return d
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个项目执行一轮到期自动归档 (T-ARC-02)。
|
||||
* 只处理 status=done / doneAt 非空 / 未归档 的任务; 幂等。
|
||||
* @returns {number} 本次归档条数
|
||||
*/
|
||||
export async function runAutoArchiveForProject(projectId) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
select: { autoArchive: true },
|
||||
})
|
||||
if (!project || !AUTO_ARCHIVE_OPTIONS.includes(project.autoArchive) || project.autoArchive === 'never') return 0
|
||||
|
||||
const columns = await prisma.boardColumn.findMany({
|
||||
where: { projectId },
|
||||
select: { id: true, retention: true },
|
||||
})
|
||||
const retentionById = new Map(columns.map((c) => [c.id, c.retention || 'forever']))
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { projectId, archived: false, status: 'done', doneAt: { not: null } },
|
||||
select: { id: true, doneAt: true, columnId: true },
|
||||
})
|
||||
const now = new Date()
|
||||
const dueIds = []
|
||||
for (const t of tasks) {
|
||||
const due = autoArchiveDue(project.autoArchive, t.doneAt, retentionById.get(t.columnId))
|
||||
if (due && due <= now) dueIds.push(t.id)
|
||||
}
|
||||
if (!dueIds.length) return 0
|
||||
|
||||
const r = await prisma.task.updateMany({
|
||||
where: { id: { in: dueIds } },
|
||||
data: { archived: true, archivedAt: now, archiveSource: 'auto' },
|
||||
})
|
||||
return r.count
|
||||
}
|
||||
|
||||
/** 每小时 cron 兜底: 扫描所有启用了自动归档的项目 */
|
||||
export async function runAutoArchiveSweep() {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { autoArchive: { not: 'never' } },
|
||||
select: { id: true },
|
||||
})
|
||||
let total = 0
|
||||
for (const p of projects) {
|
||||
try {
|
||||
total += await runAutoArchiveForProject(p.id)
|
||||
} catch (e) {
|
||||
console.error('[archive] 项目自动归档失败:', p.id, e.message)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -38,4 +38,7 @@ export const taskApi = {
|
||||
reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }),
|
||||
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
|
||||
archive: (id) => api(`/tasks/${id}/archive`, { method: 'POST', body: {} }),
|
||||
unarchive: (id) => api(`/tasks/${id}/unarchive`, { method: 'POST', body: {} }),
|
||||
archivedList: (projectId, params) => api(`/projects/${projectId}/tasks/archived` + q(params)),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, DatePicker, Dialog, DotLoading, Input, Popup, Selector, Toast } from 'antd-mobile'
|
||||
import { taskApi } from '../../api/task.js'
|
||||
import { kindColor } from './kind.js'
|
||||
|
||||
// 已归档任务查询 (T-ARC-04/05/06)
|
||||
// 过滤: 关键词(标题) / 任务类型 / 看板列 / 归档时间范围
|
||||
// 游标分页: 「加载更多」追加; 行内 恢复 / 彻底删除
|
||||
const LIMIT = 20
|
||||
|
||||
export default function ArchivedTasks({ project, visible, onClose, onChanged }) {
|
||||
const [items, setItems] = useState(null)
|
||||
const [nextCursor, setNextCursor] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [filters, setFilters] = useState({ keyword: '', kind: '', columnId: '', from: '', to: '' })
|
||||
const [fromVisible, setFromVisible] = useState(false)
|
||||
const [toVisible, setToVisible] = useState(false)
|
||||
const cursorRef = useRef('')
|
||||
|
||||
const columns = project?.columns || []
|
||||
const kinds = project?.taskKinds || []
|
||||
|
||||
const buildParams = (f, cursor) => {
|
||||
const p = { limit: LIMIT }
|
||||
if (f.keyword.trim()) p.keyword = f.keyword.trim()
|
||||
if (f.kind) p.kind = f.kind
|
||||
if (f.columnId) p.columnId = f.columnId
|
||||
if (f.from) p.from = f.from
|
||||
if (f.to) p.to = f.to
|
||||
if (cursor) p.cursor = cursor
|
||||
return p
|
||||
}
|
||||
|
||||
const load = useCallback(async (reset) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const cursor = reset ? '' : cursorRef.current
|
||||
const r = await taskApi.archivedList(project.id, buildParams(filters, cursor))
|
||||
setItems((prev) => (reset || !prev ? r.items : [...prev, ...r.items]))
|
||||
cursorRef.current = r.nextCursor || ''
|
||||
setNextCursor(r.nextCursor || null)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message || '加载失败' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project.id, filters])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
setItems(null)
|
||||
cursorRef.current = ''
|
||||
load(true)
|
||||
}, [visible, load])
|
||||
|
||||
const setF = (patch) => setFilters((p) => ({ ...p, ...patch }))
|
||||
|
||||
async function doRestore(item) {
|
||||
try {
|
||||
await taskApi.unarchive(item.id)
|
||||
Toast.show({ content: '已恢复' })
|
||||
setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
|
||||
onChanged?.()
|
||||
load(true)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message || '恢复失败' })
|
||||
}
|
||||
}
|
||||
|
||||
async function doRemove(item) {
|
||||
const subs = item.children?.length || 0
|
||||
const ok = await Dialog.confirm({
|
||||
content: subs ? `彻底删除「${item.title}」?将一并删除 ${subs} 条子任务,不可恢复。` : `彻底删除「${item.title}」?不可恢复。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await taskApi.remove(item.id)
|
||||
Toast.show({ content: '已删除' })
|
||||
setItems((prev) => (prev || []).filter((x) => x.id !== item.id))
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message || '删除失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (iso) => (iso ? iso.slice(0, 10) : '')
|
||||
const colName = (id) => columns.find((c) => c.id === id)?.name || ''
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
position="right"
|
||||
bodyStyle={{ width: '100vw', height: '100vh', overflowY: 'auto', background: 'var(--color-bg)' }}
|
||||
>
|
||||
<div className="cs-page" style={{ position: 'static', height: 'auto', minHeight: '100vh' }}>
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onClose}>‹</span>
|
||||
<span className="title">已归档任务</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Input
|
||||
placeholder="搜索任务标题"
|
||||
value={filters.keyword}
|
||||
clearable
|
||||
onEnter={() => load(true)}
|
||||
onChange={(v) => setF({ keyword: v })}
|
||||
onBlur={() => load(true)}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Selector
|
||||
options={[{ label: '全部类型', value: '' }, ...kinds.map((k) => ({ label: k, value: k }))]}
|
||||
value={[filters.kind]}
|
||||
onSelect={(_, ext) => { setF({ kind: ext.items[0].value }); setTimeout(() => load(true), 0) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Selector
|
||||
options={[{ label: '全部看板列', value: '' }, ...columns.map((c) => ({ label: c.name, value: String(c.id) }))]}
|
||||
value={[filters.columnId]}
|
||||
onSelect={(_, ext) => { setF({ columnId: ext.items[0].value }); setTimeout(() => load(true), 0) }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: 12, color: '#999' }}>
|
||||
<span>归档</span>
|
||||
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setFromVisible(true)}>
|
||||
{filters.from || '开始日期'}
|
||||
</span>
|
||||
<span>~</span>
|
||||
<span className="mini-btn" style={{ padding: '3px 10px' }} onClick={() => setToVisible(true)}>
|
||||
{filters.to || '结束日期'}
|
||||
</span>
|
||||
{(filters.from || filters.to) && (
|
||||
<span className="link" onClick={() => { setF({ from: '', to: '' }); setTimeout(() => load(true), 0) }}>清除</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DatePicker
|
||||
visible={fromVisible}
|
||||
onClose={() => setFromVisible(false)}
|
||||
precision="day"
|
||||
onConfirm={(d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
setF({ from: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })
|
||||
setFromVisible(false)
|
||||
setTimeout(() => load(true), 0)
|
||||
}}
|
||||
/>
|
||||
<DatePicker
|
||||
visible={toVisible}
|
||||
onClose={() => setToVisible(false)}
|
||||
precision="day"
|
||||
onConfirm={(d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
setF({ to: `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` })
|
||||
setToVisible(false)
|
||||
setTimeout(() => load(true), 0)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ padding: '0 14px 24px', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{items === null && loading && <div style={{ textAlign: 'center', padding: 30 }}><DotLoading /> 加载中...</div>}
|
||||
{items !== null && items.length === 0 && !loading && (
|
||||
<div style={{ textAlign: 'center', color: '#b8bcc4', padding: 40 }}>没有符合条件的归档任务</div>
|
||||
)}
|
||||
{(items || []).map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
background: '#fff', borderRadius: 9, padding: '10px 12px', display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,.04)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 600, wordBreak: 'break-word' }}>{t.title}</div>
|
||||
<div style={{ fontSize: 10.5, color: '#999', marginTop: 4, display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{t.kind && <span className="kind-tag" style={{ background: kindColor(t.kind) }}>{t.kind}</span>}
|
||||
<span>{colName(t.columnId)}</span>
|
||||
<span>归档于 {fmt(t.archivedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, flexShrink: 0 }}>
|
||||
<Button size="mini" color="primary" fill="outline" onClick={() => doRestore(t)}>恢复</Button>
|
||||
<Button size="mini" color="danger" fill="none" onClick={() => doRemove(t)}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{nextCursor && (
|
||||
<Button
|
||||
block
|
||||
fill="outline"
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => load(false)}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
||||
import { Dialog, Input, Picker, Popup, Toast } from 'antd-mobile'
|
||||
import { projectApi } from '../../api/task.js'
|
||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||
@@ -24,6 +24,15 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
const [newKind, setNewKind] = useState('')
|
||||
// 项目图标更换: 隐藏 file input
|
||||
const iconInputRef = useRef(null)
|
||||
// 自动归档档位 (T-ARC-02)
|
||||
const AUTO_ARCHIVE = [
|
||||
{ label: '3 天后', value: '3d' },
|
||||
{ label: '一周后', value: '1w' },
|
||||
{ label: '一月后', value: '1m' },
|
||||
{ label: '永不', value: 'never' },
|
||||
]
|
||||
const AUTO_ARCHIVE_LABEL = Object.fromEntries(AUTO_ARCHIVE.map((o) => [o.value, o.label]))
|
||||
const [archivePick, setArchivePick] = useState(false)
|
||||
|
||||
const members = project.members || []
|
||||
const notes = Array.isArray(project.notes) ? project.notes : []
|
||||
@@ -238,6 +247,14 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
<span className="link">列设置 ›</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>归档设置</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setArchivePick(true)}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>隐藏的任务自动归档</span>
|
||||
<span style={{ fontSize: 12.5, color: '#999' }}>
|
||||
{AUTO_ARCHIVE_LABEL[project.autoArchive] || '永不'} ›
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
|
||||
<div className="kind-chip-row" style={{ flex: 1 }}>
|
||||
@@ -328,6 +345,24 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
||||
onClose={() => setRenaming(false)}
|
||||
/>
|
||||
|
||||
{/* 自动归档档位选择 (T-ARC-02) */}
|
||||
<Picker
|
||||
visible={archivePick}
|
||||
onClose={() => setArchivePick(false)}
|
||||
columns={[AUTO_ARCHIVE]}
|
||||
value={[project.autoArchive || 'never']}
|
||||
onConfirm={async (v) => {
|
||||
setArchivePick(false)
|
||||
try {
|
||||
await projectApi.update(project.id, { autoArchive: v[0] })
|
||||
Toast.show({ content: '已保存' })
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 任务类型编辑: chips 删除 + 输入新增 */}
|
||||
<Popup
|
||||
visible={kindEdit}
|
||||
|
||||
@@ -9,6 +9,7 @@ import BoardView from './BoardView.jsx'
|
||||
import TaskDetail from './TaskDetail.jsx'
|
||||
import ProjectManage from './ProjectManage.jsx'
|
||||
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
|
||||
import ArchivedTasks from './ArchivedTasks.jsx'
|
||||
|
||||
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
|
||||
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
|
||||
@@ -23,6 +24,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [tick, setTick] = useState(0) // 保留窗口重算触发
|
||||
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
|
||||
const [archivedOpen, setArchivedOpen] = useState(false) // 已归档任务查询
|
||||
|
||||
const load = useCallback(() => {
|
||||
setErr('')
|
||||
@@ -169,6 +171,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
const handler = ActionSheet.show({
|
||||
actions: [
|
||||
{ text: '编辑', key: 'edit' },
|
||||
{ text: '归档', key: 'archive' },
|
||||
{ text: '拷贝到其他项目', key: 'copy' },
|
||||
{ text: '删除', key: 'delete', bold: true, danger: true },
|
||||
],
|
||||
@@ -176,6 +179,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
onAction: async (item) => {
|
||||
handler.close?.()
|
||||
if (item.key === 'edit') setOpenTask(task)
|
||||
else if (item.key === 'archive') {
|
||||
const subs = task.children?.length || 0
|
||||
const ok = await Dialog.confirm({
|
||||
content: subs ? `归档「${task.title}」?将连带归档 ${subs} 条子任务。` : `归档「${task.title}」?`,
|
||||
confirmText: '归档',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await taskApi.archive(task.id)
|
||||
Toast.show({ content: '已归档' })
|
||||
load()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
else if (item.key === 'copy') setCopyTask(task)
|
||||
else if (item.key === 'delete') {
|
||||
const kids = task.children?.length || 0
|
||||
@@ -225,6 +243,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
<div className="navbar-sub task-projbar">
|
||||
<span className="title">{project.name}</span>
|
||||
<div className="right">
|
||||
<span className="mini-btn" onClick={() => setArchivedOpen(true)}>已归档</span>
|
||||
<span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
|
||||
<span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
|
||||
</div>
|
||||
@@ -301,6 +320,15 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
/>
|
||||
)}
|
||||
|
||||
{archivedOpen && (
|
||||
<ArchivedTasks
|
||||
project={project}
|
||||
visible={archivedOpen}
|
||||
onClose={() => setArchivedOpen(false)}
|
||||
onChanged={load}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showManage && (
|
||||
<ProjectManage
|
||||
project={project}
|
||||
|
||||
@@ -1090,7 +1090,7 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
/* 列体包裹层: 承载右缘滚动条 (滚动时出现, 停止后淡出) */
|
||||
.bcol-main { flex: 1 1 auto; min-height: 0; position: relative; display: flex; }
|
||||
.bcol-bar {
|
||||
position: absolute; right: 1px; width: 3px; border-radius: 2px;
|
||||
position: absolute; right: -4px; width: 3px; border-radius: 2px;
|
||||
background: rgba(15, 23, 42, .3); opacity: 0; transition: opacity .25s ease;
|
||||
pointer-events: none; z-index: 3;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user