feat(V0.2): 任务模块界面调整 —— 首屏/项目管理/底部安全距离
Docker Build and Push / build-image (push) Successful in 4m8s

按 2026-10-24 评审的 5 条调整实现 (需求 T-VW-08~10 / T-PJ-11/12 / T-UI-11):

首屏 (T-VW-08/09):
- /task 改为「上=最近项目条(3 个+更多入口), 下=当前项目任务管理」
- 完整项目列表迁至 /task/list, 从「更多项目」进入; /task/:pid 保留供深链

最近项目排序 (T-VW-10):
- 卡片长按 300ms 拖动换位, 结果存设备端 prefs.recentOrder
- 顺序 = 手动排序 > 最近访问(recentIds) > 列表序, 归档项目不展示

项目管理 (T-PJ-11/12):
- 新增 ProjectManage: 成员 / 看板列 / 项目信息 三分区统一入口
- 项目信息含 改名 / 归档切换 / 删除(个人项目隐藏) / 退出(仅创建人)
- 项目页头部只留「项目管理 + 新增」, 移除散置的成员/列设置按钮

底部安全距离 (T-UI-11):
- 任务详情 / 人员选择 / 拷贝弹层 padding-bottom = 安全区 + 20px
- 小屏下弹层 maxHeight + 内部滚动, 底部按钮完整可见

已验证: 97 文件语法 0 错误、相对导入 0 缺失
This commit is contained in:
jif2.zhang
2026-10-24 20:32:44 +08:00
parent 6153b9db99
commit 0cdbfb434f
13 changed files with 771 additions and 179 deletions
@@ -4,14 +4,14 @@
| --- | --- | | --- | --- |
| 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) | | 版本 | V0.2(对应分支 `dev-0.2`,应用版本 `0.2.0`) |
| 文档状态 | **设计基线**(待评审) | | 文档状态 | **设计基线**(待评审) |
| 上游文档 | `任务模块需求文档.md`(**142 条需求点**,编号 `T-*`) | | 上游文档 | `任务模块需求文档.md`(**148 条需求点**,编号 `T-*`) |
| 配套文档 | `任务模块设计文档-框架设计.md`、`任务模块数据库设计文档.md`、`任务模块界面设计.html` | | 配套文档 | `任务模块设计文档-框架设计.md`、`任务模块数据库设计文档.md`、`任务模块界面设计.html` |
| 对应关系 | **本文与需求点一一对应**:每条需求点均给出「设计落点(功能/组件/接口/表字段)」与「实现要点」 | | 对应关系 | **本文与需求点一一对应**:每条需求点均给出「设计落点(功能/组件/接口/表字段)」与「实现要点」 |
> **阅读方式** > **阅读方式**
> 1. 本文按需求文档的模块码组织(`VW` / `COL` / `BD` / `FL` / `TK` / `AS` / `ST` / `PJ` / `PM` / `CP` / `UI` / `DT` / `NF` / `EX`)。 > 1. 本文按需求文档的模块码组织(`VW` / `COL` / `BD` / `FL` / `TK` / `AS` / `ST` / `PJ` / `PM` / `CP` / `UI` / `DT` / `NF` / `EX`)。
> 2. 每条需求点一行:**编号 → 设计落点 → 实现要点**。设计落点中的组件名、接口路径、表字段均与《任务模块数据库设计文档》《任务模块界面设计》一致。 > 2. 每条需求点一行:**编号 → 设计落点 → 实现要点**。设计落点中的组件名、接口路径、表字段均与《任务模块数据库设计文档》《任务模块界面设计》一致。
> 3. 文末 §15 给出**覆盖自检表**,确保 142 条无遗漏。 > 3. 文末 §15 给出**覆盖自检表**,确保 148 条无遗漏。
--- ---
@@ -62,6 +62,11 @@
| T-VW-05 | 默认值 `'list'` | 新项目首次进入为列表 | | T-VW-05 | 默认值 `'list'` | 新项目首次进入为列表 |
| T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承(与 T-FL-15 的项目级配置区分) | | T-VW-06 | `localStorage.iboard_task_prefs` | **不落库、不同步**;换设备不继承(与 T-FL-15 的项目级配置区分) |
| T-VW-07 | `prefs.getLastProjectId()` / `setLastProjectId()` | 进入 `/task` 时读取;无记录或记录的项目已不可见 ⇒ 回落个人默认项目 | | T-VW-07 | `prefs.getLastProjectId()` / `setLastProjectId()` | 进入 `/task` 时读取;无记录或记录的项目已不可见 ⇒ 回落个人默认项目 |
| T-VW-08 | `TaskPage` 首屏上部 `RecentProjects` 条 | 取最近访问的 3 个项目(按设备端访问时间倒序),末尾「更多项目」入口 → `/task/list`(完整项目列表 `ProjectList`,从「更多」进入) |
| T-VW-09 | `TaskPage` 首屏下部直接内嵌 `ProjectView` | 当前项目 = 上次访问项目(无记录 ⇒ 个人默认项目);点首屏项目卡片切换当前项目,下部整体切换,不跳转页面 |
| T-VW-10 | `prefs.js` 增 `recentOrder[]`(设备端) | 长按卡片拖动排序并持久化;无手动记录时按最近访问时间自动排列;项目不可见时从记录剔除 |
> **首屏结构调整(T-VW-08/09)**:`TaskPage`(`/task`)不再以项目列表为首屏,改为「最近项目条 + 当前项目 `ProjectView`」;完整项目列表迁移至 `/task/list`。旧路由 `/task/:pid` 保留(打开指定项目),供深链使用。
> **实现细节**:`lastProjectId` 指向的项目若已被退出/删除,视为"无记录",静默回落到个人默认项目,不弹错。 > **实现细节**:`lastProjectId` 指向的项目若已被退出/删除,视为"无记录",静默回落到个人默认项目,不弹错。
@@ -203,6 +208,8 @@
| T-PJ-08 | 删除最后成员不删项目 | 仅删 `ProjectMember`(B-11);见 T-EX-11 | | T-PJ-08 | 删除最后成员不删项目 | 仅删 `ProjectMember`(B-11);见 T-EX-11 |
| T-PJ-09 | `Project.archived` | 列表分「进行中 / 已归档」 | | T-PJ-09 | `Project.archived` | 列表分「进行中 / 已归档」 |
| T-PJ-10 | 列表查询按成员关系过滤 | `WHERE EXISTS ProjectMember(userId)` | | T-PJ-10 | 列表查询按成员关系过滤 | `WHERE EXISTS ProjectMember(userId)` |
| T-PJ-11 | 新组件 `ProjectManage`(项目管理页) | 分区承载:成员(复用 `ProjectMembers` 逻辑)、列设置(复用 `ColumnSettings`)、项目信息;`ProjectView` 头部仅保留「项目管理」一个入口 |
| T-PJ-12 | `ProjectManage` 的「项目信息」分区 | 改名(`PUT /api/projects/:id {name}`)、归档/取消归档(`{archived}`)、删除(个人项目隐藏入口)、退出(仅创建人可见);删除与退出均有二次确认 |
--- ---
@@ -250,6 +257,7 @@
| T-UI-08 | `EmptyState` 组件 | 无项目 / 无任务 / 无子任务三档空态 + CTA | | T-UI-08 | `EmptyState` 组件 | 无项目 / 无任务 / 无子任务三档空态 + CTA |
| T-UI-09 | `LoadingState` 骨架屏 | 首屏与切换视图 | | T-UI-09 | `LoadingState` 骨架屏 | 首屏与切换视图 |
| T-UI-10 | `ErrorState` 组件 | 展示错误 + 重试按钮 | | T-UI-10 | `ErrorState` 组件 | 展示错误 + 重试按钮 |
| T-UI-11 | 弹层容器统一 `padding-bottom: calc(env(safe-area-inset-bottom) + 20px)` | 底部按钮(删除等)与屏幕底边保持安全距离;小屏下弹层 `max-height` + 内部滚动,底部按钮始终完整可见 |
--- ---
@@ -311,20 +319,20 @@
| 模块 | 编号范围 | 条数 | 本文对应章节 | | 模块 | 编号范围 | 条数 | 本文对应章节 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 视图体系 | T-VW-01 ~ 07 | 7 | §1 | | 视图体系 | T-VW-01 ~ 10 | 10 | §1 |
| 看板列管理 | T-COL-01 ~ 10 | 10 | §2 | | 看板列管理 | T-COL-01 ~ 10 | 10 | §2 |
| 列-状态绑定 | T-BD-01 ~ 16 | 16 | §3 | | 列-状态绑定 | T-BD-01 ~ 16 | 16 | §3 |
| 已完成显示控制 | T-FL-01 ~ 15 | 15 | §4 | | 已完成显示控制 | T-FL-01 ~ 15 | 15 | §4 |
| 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 | | 任务字段体系 | T-TK-01 ~ 13 | 13 | §5 |
| 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 | | 负责人 & 成员 | T-AS-01 ~ 07 | 7 | §6 |
| 子任务体系 | T-ST-01 ~ 15 | 15 | §7 | | 子任务体系 | T-ST-01 ~ 15 | 15 | §7 |
| 项目体系 | T-PJ-01 ~ 10 | 10 | §8 | | 项目体系 | T-PJ-01 ~ 12 | 12 | §8 |
| 项目权限与入口 | T-PM-01 ~ 07 | 7 | §9 | | 项目权限与入口 | T-PM-01 ~ 07 | 7 | §9 |
| 跨项目拷贝 | T-CP-01 ~ 09 | 9 | §10 | | 跨项目拷贝 | T-CP-01 ~ 09 | 9 | §10 |
| UI 交互规范 | T-UI-01 ~ 10 | 10 | §11 | | UI 交互规范 | T-UI-01 ~ 11 | 11 | §11 |
| 数据需求 | T-DT-01 ~ 09 | 9 | §12 | | 数据需求 | T-DT-01 ~ 09 | 9 | §12 |
| 非功能需求 | T-NF-01 ~ 05 | 5 | §13 | | 非功能需求 | T-NF-01 ~ 05 | 5 | §13 |
| 异常与边界 | T-EX-01 ~ 19 | 19 | §14 | | 异常与边界 | T-EX-01 ~ 19 | 19 | §14 |
| **合计** | — | **142** | — | | **合计** | — | **148** | — |
> 需求文档共 **142 条**需求点,本文逐条给出设计落点,覆盖率 **142/142**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19` 为评审后新增,编号不连续,非笔误。) > 需求文档共 **148 条**需求点,本文逐条给出设计落点,覆盖率 **148/148**。(其中 `T-BD-14~16`、`T-COL-*`、`T-EX-18~19`、`T-VW-08~10`、`T-PJ-11/12`、`T-UI-11` 为评审后新增,编号不连续,非笔误。)
@@ -100,6 +100,28 @@
.sdot.pill{width:auto;height:auto;border-radius:999px;font-size:10px;padding:1px 6px;color:#fff;line-height:14px} .sdot.pill{width:auto;height:auto;border-radius:999px;font-size:10px;padding:1px 6px;color:#fff;line-height:14px}
.sdot.pill.todo{background:#a8b0ba} .sdot.pill.doing{background:var(--primary)} .sdot.pill.done{background:var(--success)} .sdot.pill.todo{background:#a8b0ba} .sdot.pill.doing{background:var(--primary)} .sdot.pill.done{background:var(--success)}
/* ---------- 首屏 · 最近项目条 (T-VW-08/10) ---------- */
.rp-bar { display: flex; gap: 8px; padding: 10px 12px 6px; overflow-x: auto; flex-shrink: 0; }
.rp-card {
flex: 0 0 96px; background: #fff; border-radius: 10px; padding: 9px 10px;
box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer; position: relative;
border: 1.5px solid transparent;
}
.rp-card.on { border-color: var(--primary); background: #f5f9ff; }
.rp-card .rp-ico {
width: 26px; height: 26px; border-radius: 7px; color: #fff; font-size: 11px;
font-weight: 700; display: flex; align-items: center; justify-content: center;
}
.rp-card .rp-name { font-size: 12px; font-weight: 600; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rp-card .rp-meta { font-size: 9.5px; color: var(--weak); margin-top: 1px; }
.rp-card .rp-grip { position: absolute; top: 5px; right: 6px; color: #d3d8de; font-size: 10px; letter-spacing: -1px; }
.rp-more {
flex: 0 0 auto; align-self: stretch; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; color: var(--muted); font-size: 10.5px; cursor: pointer; padding: 0 6px;
}
.rp-more .rp-more-ico { width: 30px; height: 30px; border-radius: 50%; background: #f2f3f5; color: #9aa1aa; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.rp-drag-tip { font-size: 9.5px; color: var(--weak); padding: 0 14px 2px; }
/* ---------- 项目列表 ---------- */ /* ---------- 项目列表 ---------- */
.proj-head{padding:12px 14px 8px;display:flex;align-items:center} .proj-head{padding:12px 14px 8px;display:flex;align-items:center}
.proj-head b{font-size:18px;font-weight:700} .proj-head b{font-size:18px;font-weight:700}
@@ -186,7 +208,7 @@
.sw-toggle.on{background:var(--primary)} .sw-toggle.on{background:var(--primary)}
.sw-toggle.on::after{left:19px} .sw-toggle.on::after{left:19px}
.mask{position:absolute;inset:28px 0 0 0;background:rgba(0,0,0,.45);z-index:9} .mask{position:absolute;inset:28px 0 0 0;background:rgba(0,0,0,.45);z-index:9}
.sheet{position:absolute;left:0;right:0;bottom:0;background:#fff;border-radius:12px 12px 0 0;padding:16px 16px 14px;z-index:10;max-height:640px;overflow:hidden} .sheet{position:absolute;left:0;right:0;bottom:0;background:#fff;border-radius:12px 12px 0 0;padding:16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px);z-index:10;max-height:640px;overflow:hidden}
.sheet-title{font-size:16px;font-weight:700;margin-bottom:4px} .sheet-title{font-size:16px;font-weight:700;margin-bottom:4px}
.sheet-sub{font-size:11.5px;color:var(--weak);margin-bottom:14px} .sheet-sub{font-size:11.5px;color:var(--weak);margin-bottom:14px}
.field{margin-bottom:13px} .field{margin-bottom:13px}
@@ -284,23 +306,119 @@
</div> </div>
</div> </div>
<h2>2 项目列表 <span class="sub">入口 · 个人默认项目 · 归档分组</span></h2> <h2>2 任务首屏 <span class="sub">最近项目条(可排序)+ 当前项目任务管理 · 更多进项目列表</span></h2>
<p class="desc">顶级 Tab「任务」的第一屏。个人默认项目置顶且不可删除、不可加成员(T-PJ-01~04);普通协作项目可增删成员(T-PJ-05/06)。</p> <p class="desc">首屏<b>上部</b>为最近使用的 3 个项目卡片 + 「更多项目」入口(T-VW-08),卡片可长按拖动排序(T-VW-10,设备端记忆);<b>下部</b>直接是当前选中项目的任务管理视图(T-VW-09,默认上次访问的项目),不必先进项目列表。完整项目列表从「更多项目」进入。</p>
<div class="stage"> <div class="stage">
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar-sm">任务</div> <div class="navbar-sm">任务</div>
<div class="body"> <div class="body" style="display:flex;flex-direction:column">
<div class="proj-head"> <!-- 上: 最近项目条 -->
<b>我的项目</b> <div class="rp-bar">
<div class="rp-card">
<div class="rp-ico" style="background:#1677ff">张</div>
<div class="rp-name">张三的任务</div>
<div class="rp-meta">12 个任务</div>
<div class="rp-grip">⠿</div>
</div>
<div class="rp-card on">
<div class="rp-ico" style="background:#00b578">APP</div>
<div class="rp-name">APP 改版</div>
<div class="rp-meta">28 个任务</div>
<div class="rp-grip">⠿</div>
</div>
<div class="rp-card">
<div class="rp-ico" style="background:#ff8f1f">二</div>
<div class="rp-name">APP 二期</div>
<div class="rp-meta">9 个任务</div>
<div class="rp-grip">⠿</div>
</div>
<div class="rp-more">
<div class="rp-more-ico">▤</div>
更多项目
</div>
</div>
<div class="rp-drag-tip">长按卡片可调整顺序(本机记忆)</div>
<!-- 下: 当前项目的任务管理 -->
<div class="navbar-sub" style="margin-top:4px">
<span class="title">APP 改版</span>
<div class="right"> <div class="right">
<div class="mini-btn">+ 归档</div> <div class="mini-btn">项目管理</div>
<div class="mini-btn solid">+ 新建</div> <div class="mini-btn solid">+ 新增</div>
</div>
</div>
<div class="pv-head">
<div class="seg"><div class="on">列表</div><div>看板</div></div>
<div class="right"><div class="mini-btn grey">过滤</div></div>
</div>
<div style="flex:1;min-height:0;overflow:hidden;padding-bottom:4px">
<div class="task-row" style="margin:0 12px 7px">
<div class="box"><svg viewBox="0 0 14 14" width="11" height="11"><path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/></svg></div>
<div class="task-main">
<div class="task-title">登录接口返回 500</div>
<div class="task-meta">
<span class="sdot doing"></span><span>执行中</span>
<span class="col">Doing</span>
<span class="assignee">张三</span>
<span class="members">+ 李四、王五</span>
</div>
</div>
<div class="task-right"><span class="more">›</span></div>
</div>
<div class="task-row" style="margin:0 12px 7px">
<div class="box"><svg viewBox="0 0 14 14" width="11" height="11"><path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/></svg></div>
<div class="task-main">
<div class="task-title">首页改版视觉走查</div>
<div class="task-meta">
<span class="sdot doing"></span><span>执行中</span>
<span class="col">Doing</span>
<span class="assignee">李四</span>
</div>
</div>
<div class="task-right"><span class="more">›</span></div>
</div>
<div class="task-row done" style="margin:0 12px 7px">
<div class="box"><svg viewBox="0 0 14 14" width="11" height="11"><path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/></svg></div>
<div class="task-main">
<div class="task-title">需求评审</div>
<div class="task-meta">
<span class="sdot done"></span><span>完成</span>
<span class="col">Done</span>
<span class="assignee">王五</span>
</div>
</div>
<div class="task-right"><span class="more">›</span></div>
</div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>打卡</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M8 10h8M8 14h5"/><path d="M9 5V3M15 5V3"/></svg>任务</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 13h4l2-5 3 10 2-5h7"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.2"/><path d="M5 20c1.2-4 4-6 7-6s5.8 2 7 6"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>任务首屏(T-VW-08/09)</b>
<p>上:最近 3 个项目卡片,当前项目主色描边高亮;长按「⠿」拖动排序(T-VW-10,设备端)。下:当前项目的任务管理,默认上次访问的项目。项目页头部仅保留<b>「项目管理」+「+ 新增」</b>两个入口(T-PJ-11)。</p>
</div> </div>
</div> </div>
<div class="proj-item"> <div class="shot">
<div class="phone">
<div class="navbar-sm">任务</div>
<div class="navbar-sub">
<span class="back">‹</span>
<span class="title">我的项目</span>
<div class="right">
<div class="mini-btn solid">+ 新建</div>
</div>
</div>
<div class="body scroll">
<div class="proj-item" style="margin-top:8px">
<div class="pico" style="background:#1677ff">张</div> <div class="pico" style="background:#1677ff">张</div>
<div class="pmain"> <div class="pmain">
<div class="ptitle">张三的任务 <span class="badge">个人</span></div> <div class="ptitle">张三的任务 <span class="badge">个人</span></div>
@@ -354,32 +472,8 @@
</div> </div>
</div> </div>
<div class="caption"> <div class="caption">
<b>说明</b> <b>我的项目列表(从「更多项目」进入)</b>
<p>个人默认项目带「个人」徽标,无删除入口;归档项目降透明并单独分组。Tab 共 5 项,<b>任务位于打卡之后</b>(T-PM-06),默认首页仍为打卡。</p> <p>完整项目列表:个人默认项目带「个人」徽标、无删除入口;归档项目降透明分组。选择项目后返回首屏并切换当前项目(T-VW-09)。</p>
</div>
</div>
<div class="shot">
<div class="phone">
<div class="navbar-sm">任务</div>
<div class="body">
<div class="empty" style="padding-top:120px">
<div class="ico">▤</div>
<p>还没有协作项目</p>
<div class="mini-btn solid">新建项目</div>
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>打卡</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M8 10h8M8 14h5"/><path d="M9 5V3M15 5V3"/></svg>任务</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 13h4l2-5 3 10 2-5h7"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.2"/><path d="M5 20c1.2-4 4-6 7-6s5.8 2 7 6"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>空状态(T-UI-08)</b>
<p>无协作项目时展示空态与 CTA;个人默认项目始终存在,故不会出现完全空白。</p>
</div> </div>
</div> </div>
</div> </div>
@@ -395,14 +489,14 @@
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"> <div class="right">
<div class="mini-btn">⋯</div> <div class="mini-btn">项目管理</div>
<div class="mini-btn solid">+ 新增</div> <div class="mini-btn solid">+ 新增</div>
</div> </div>
</div> </div>
<div class="body scroll"> <div class="body scroll">
<div class="pv-head"> <div class="pv-head">
<div class="seg"><div class="on">列表</div><div>看板</div></div> <div class="seg"><div class="on">列表</div><div>看板</div></div>
<div class="right"><div class="mini-btn grey">过滤</div><div class="mini-btn grey">成员</div></div> <div class="right"><div class="mini-btn grey">过滤</div></div>
</div> </div>
<div class="group-title">Doing <span class="cnt">2</span></div> <div class="group-title">Doing <span class="cnt">2</span></div>
@@ -496,12 +590,12 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body scroll"> <div class="body scroll">
<div class="pv-head"> <div class="pv-head">
<div class="seg"><div class="on">列表</div><div>看板</div></div> <div class="seg"><div class="on">列表</div><div>看板</div></div>
<div class="right"><div class="mini-btn grey">过滤</div><div class="mini-btn grey">成员</div></div> <div class="right"><div class="mini-btn grey">过滤</div></div>
</div> </div>
<div class="group-title">Done <span class="cnt">3</span></div> <div class="group-title">Done <span class="cnt">3</span></div>
@@ -563,12 +657,12 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body" style="padding-top:8px"> <div class="body" style="padding-top:8px">
<div class="pv-head" style="padding-bottom:6px"> <div class="pv-head" style="padding-bottom:6px">
<div class="seg"><div>列表</div><div class="on">看板</div></div> <div class="seg"><div>列表</div><div class="on">看板</div></div>
<div class="right"><div class="mini-btn grey">列设置</div></div> <div class="right"><div class="mini-btn grey">过滤</div></div>
</div> </div>
<div class="board-wrap" style="height:calc(100% - 44px)"> <div class="board-wrap" style="height:calc(100% - 44px)">
@@ -668,12 +762,12 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body" style="padding-top:8px"> <div class="body" style="padding-top:8px">
<div class="pv-head" style="padding-bottom:6px"> <div class="pv-head" style="padding-bottom:6px">
<div class="seg"><div>列表</div><div class="on">看板</div></div> <div class="seg"><div>列表</div><div class="on">看板</div></div>
<div class="right"><div class="mini-btn grey">列设置</div></div> <div class="right"><div class="mini-btn grey">过滤</div></div>
</div> </div>
<div class="board-wrap" style="height:calc(100% - 44px)"> <div class="board-wrap" style="height:calc(100% - 44px)">
@@ -838,12 +932,12 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body" style="padding-top:8px"> <div class="body" style="padding-top:8px">
<div class="pv-head" style="padding-bottom:6px"> <div class="pv-head" style="padding-bottom:6px">
<div class="seg"><div>列表</div><div class="on">看板</div></div> <div class="seg"><div>列表</div><div class="on">看板</div></div>
<div class="right"><div class="mini-btn grey">列设置</div></div> <div class="right"><div class="mini-btn grey">过滤</div></div>
</div> </div>
<div class="board-wrap" style="height:calc(100% - 44px)"> <div class="board-wrap" style="height:calc(100% - 44px)">
<div class="bcol"> <div class="bcol">
@@ -895,7 +989,7 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body" style="position:relative"> <div class="body" style="position:relative">
<div class="mask"></div> <div class="mask"></div>
@@ -958,7 +1052,7 @@
</div> </div>
<div class="caption"> <div class="caption">
<b>详情(扩展信息收起)</b> <b>详情(扩展信息收起)</b>
<p>负责人默认填入创建者本人,可清空为「未指派」(T-TK-09/10)。扩展信息卡片默认收起,右侧标出条目数。</p> <p>负责人默认填入创建者本人,可清空为「未指派」(T-TK-09/10)。扩展信息卡片默认收起,右侧标出条目数。弹层底部预留 <b>安全区 + 20px</b>(T-UI-11):删除按钮不被手势条遮挡,小屏下弹层内部滚动、底部按钮始终完整可见。</p>
</div> </div>
</div> </div>
@@ -968,7 +1062,7 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body" style="position:relative"> <div class="body" style="position:relative">
<div class="mask"></div> <div class="mask"></div>
@@ -1043,10 +1137,67 @@
</div> </div>
</div> </div>
<h2>7 成员与跨项目拷贝 <span class="sub">项目成员唯一选人源 · 拷贝非移动</span></h2> <h2>7 项目管理 · 成员与跨项目拷贝 <span class="sub">统一入口:成员 / 列设置 / 项目信息</span></h2>
<p class="desc">负责人与任务成员只能从<b>当前项目成员</b>中选择(T-AS-03/04);跨项目操作是<b>拷贝</b>(T-CP-01),新任务创建人为操作者(T-CP-04)。</p> <p class="desc">项目页头部的「项目管理」进入统一管理页(T-PJ-11),含<b>成员</b>、<b>列设置</b>、<b>项目信息</b>三个分区;负责人与任务成员只能从<b>当前项目成员</b>中选择(T-AS-03/04);跨项目操作是<b>拷贝</b>(T-CP-01)。</p>
<div class="stage"> <div class="stage">
<div class="shot">
<div class="phone">
<div class="navbar-sm">任务</div>
<div class="navbar-sub">
<span class="back">‹</span>
<span class="title">项目管理 · APP 改版</span>
</div>
<div class="body scroll">
<div class="set-hint" style="padding-top:12px">
项目内所有成员权限均等、全读写(T-PM-02);以下配置对全体成员即时生效。
</div>
<div class="section-h" style="margin:6px 14px 8px">成员(4)</div>
<div class="set-row" style="padding:12px 14px">
<div class="avatar">张</div>
<span class="pn" style="flex:1;font-size:13.5px">张三 <span class="tag grey" style="margin-left:6px">创建人</span></span>
<span class="link">管理 ›</span>
</div>
<div class="section-h" style="margin:14px 14px 8px">看板列</div>
<div class="set-row" style="padding:12px 14px">
<span class="pn" style="flex:1;font-size:13px;color:var(--muted)">Plan · Doing · Action · Done</span>
<span class="link">列设置 ›</span>
</div>
<div class="section-h" style="margin:14px 14px 8px">项目信息</div>
<div class="set-row" style="padding:12px 14px">
<span class="pn" style="flex:1;font-size:13.5px">项目名称</span>
<span style="font-size:12.5px;color:var(--muted)">APP 改版</span>
</div>
<div class="set-row" style="padding:12px 14px">
<span class="pn" style="flex:1;font-size:13.5px">归档项目</span>
<span class="link">归档</span>
</div>
<div class="set-row" style="padding:12px 14px">
<span class="pn" style="flex:1;font-size:13.5px;color:var(--danger)">删除项目</span>
<span class="link danger">删除</span>
</div>
<div class="set-hint">
个人默认项目不出现「删除项目」;退出项目仅创建人可见(T-PJ-07)。删除与退出均有二次确认。
</div>
</div>
<div class="tabbar">
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>打卡</div>
<div class="tab on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M8 10h8M8 14h5"/><path d="M9 5V3M15 5V3"/></svg>任务</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M4 19V9M10 19V5M16 19v-7M22 19H2"/></svg>宏观</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M3 13h4l2-5 3 10 2-5h7"/></svg>监控</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="8" r="3.2"/><path d="M5 20c1.2-4 4-6 7-6s5.8 2 7 6"/></svg>我</div>
</div>
</div>
<div class="caption">
<b>项目管理(T-PJ-11/12)</b>
<p>三分区:成员(增删)、列设置(进入第 5 节的列设置页)、项目信息(改名 / 归档 / 删除 / 退出)。项目页头部不再散置成员与列设置入口。</p>
</div>
</div>
<div class="shot"> <div class="shot">
<div class="phone"> <div class="phone">
<div class="navbar-sm">任务</div> <div class="navbar-sm">任务</div>
@@ -1105,7 +1256,7 @@
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span> <span class="back">‹</span>
<span class="title">APP 改版</span> <span class="title">APP 改版</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body" style="position:relative"> <div class="body" style="position:relative">
<div class="mask"></div> <div class="mask"></div>
@@ -1173,7 +1324,7 @@
<div class="navbar-sm">任务</div> <div class="navbar-sm">任务</div>
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span><span class="title">APP 二期</span> <span class="back">‹</span><span class="title">APP 二期</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body"> <div class="body">
<div class="pv-head"><div class="seg"><div class="on">列表</div><div>看板</div></div></div> <div class="pv-head"><div class="seg"><div class="on">列表</div><div>看板</div></div></div>
@@ -1198,7 +1349,7 @@
<div class="navbar-sm">任务</div> <div class="navbar-sm">任务</div>
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span><span class="title">APP 二期</span> <span class="back">‹</span><span class="title">APP 二期</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body"> <div class="body">
<div class="pv-head"><div class="seg"><div class="on">列表</div><div>看板</div></div></div> <div class="pv-head"><div class="seg"><div class="on">列表</div><div>看板</div></div></div>
@@ -1224,7 +1375,7 @@
<div class="navbar-sm">任务</div> <div class="navbar-sm">任务</div>
<div class="navbar-sub"> <div class="navbar-sub">
<span class="back">‹</span><span class="title">APP 二期</span> <span class="back">‹</span><span class="title">APP 二期</span>
<div class="right"><div class="mini-btn">⋯</div><div class="mini-btn solid">+ 新增</div></div> <div class="right"><div class="mini-btn">项目管理</div><div class="mini-btn solid">+ 新增</div></div>
</div> </div>
<div class="body"> <div class="body">
<div class="pv-head"><div class="seg"><div class="on">列表</div><div>看板</div></div></div> <div class="pv-head"><div class="seg"><div class="on">列表</div><div>看板</div></div></div>
@@ -1249,14 +1400,15 @@
<h2>9 需求点对照 <span class="sub">界面稿 ↔ 需求编号</span></h2> <h2>9 需求点对照 <span class="sub">界面稿 ↔ 需求编号</span></h2>
<table> <table>
<tr><th style="width:120px">界面</th><th>对应需求点</th></tr> <tr><th style="width:120px">界面</th><th>对应需求点</th></tr>
<tr><td>2 项目列表</td><td><code>T-PJ-01~10</code>、<code>T-PM-06</code>、<code>T-PM-07</code>、<code>T-UI-08</code></td></tr> <tr><td>2 任务首屏</td><td><code>T-VW-08/09/10</code>、<code>T-VW-03~07</code>、<code>T-PM-06/07</code>、<code>T-PJ-11</code>(头部入口)</td></tr>
<tr><td>3 列表视图</td><td><code>T-VW-01~07</code>、<code>T-ST-04/05/09</code>、<code>T-AS-01/02</code>、<code>T-FL-11</code>、<code>T-TK-12</code></td></tr> <tr><td>2′ 我的项目列表</td><td><code>T-PJ-01~10</code>、<code>T-UI-08</code></td></tr>
<tr><td>3 列表视图</td><td><code>T-VW-01/02</code>、<code>T-ST-04/05/09</code>、<code>T-AS-01/02</code>、<code>T-FL-11</code>、<code>T-TK-12</code></td></tr>
<tr><td>3′ 保留退场</td><td><code>T-FL-05/09</code>、<code>T-EX-18</code></td></tr> <tr><td>3′ 保留退场</td><td><code>T-FL-05/09</code>、<code>T-EX-18</code></td></tr>
<tr><td>4 看板视图</td><td><code>T-BD-01~16</code>、<code>T-UI-02/07</code>、<code>T-ST-06/07/09</code>、<code>T-ST-12~15</code></td></tr> <tr><td>4 看板视图</td><td><code>T-BD-01~16</code>、<code>T-UI-02/07</code>、<code>T-ST-06/07/09</code>、<code>T-ST-12~15</code></td></tr>
<tr><td>5 列设置</td><td><code>T-COL-01~10</code>、<code>T-BD-02/09/10</code>、<code>T-FL-01~15</code></td></tr> <tr><td>5 列设置</td><td><code>T-COL-01~10</code>、<code>T-BD-02/09/10</code>、<code>T-FL-01~15</code></td></tr>
<tr><td>5′ 自动归位</td><td><code>T-BD-14/16</code>、<code>T-EX-04/05</code></td></tr> <tr><td>5′ 自动归位</td><td><code>T-BD-14/16</code>、<code>T-EX-04/05</code></td></tr>
<tr><td>6 任务详情</td><td><code>T-TK-01~13</code>、<code>T-ST-03</code>、<code>T-AS-05</code>、<code>T-EX-07</code></td></tr> <tr><td>6 任务详情</td><td><code>T-TK-01~13</code>、<code>T-ST-03</code>、<code>T-AS-05</code>、<code>T-EX-07</code>、<code>T-UI-11</code>(底部安全距离)</td></tr>
<tr><td>7 成员 / 拷贝</td><td><code>T-AS-03/04/06/07</code>、<code>T-PM-01~05</code>、<code>T-CP-01~09</code>、<code>T-EX-06/08</code></td></tr> <tr><td>7 项目管理 / 成员 / 拷贝</td><td><code>T-PJ-11/12</code>、<code>T-AS-03/04/06/07</code>、<code>T-PM-01~05</code>、<code>T-CP-01~09</code>、<code>T-EX-06/08</code></td></tr>
<tr><td>8 三态</td><td><code>T-UI-08/09/10</code>、<code>T-EX-02/10</code></td></tr> <tr><td>8 三态</td><td><code>T-UI-08/09/10</code>、<code>T-EX-02/10</code></td></tr>
</table> </table>
@@ -36,15 +36,16 @@
``` ```
┌──────────────────── 前端 (React + antd-mobile,打包进 Tauri / OTA) ────────────────────┐ ┌──────────────────── 前端 (React + antd-mobile,打包进 Tauri / OTA) ────────────────────┐
│ App.jsx 路由 │ │ App.jsx 路由 │
│ └ /task ──► TaskPage ──┬── ProjectList (项目列表 / 个人默认项目) │ │ └ /task ──► TaskPage (首屏: 上=RecentProjects 条, 下=当前项目 ProjectView) │
│ └── /task/:pid ──► ProjectView │ │ /task/list ──► ProjectList (完整项目列表, 从「更多项目」进入) │
│ ├── ListView (列表 + 子任务缩进折叠) │ │ /task/:pid ──► TaskPage (打开指定项目, 供深链) │
│ ProjectView ──┬── ListView (列表 + 子任务缩进折叠) │
│ ├── BoardView (看板 + 长按拖拽) │ │ ├── BoardView (看板 + 长按拖拽) │
│ └── ColumnSettings / ProjectSettings │ │ └── ProjectManage (项目管理: 成员 / 列设置 / 项目信息) │
│ 组件: BoardColumn / TaskCard / SubtaskRow / StatusDot / ExtrasCard / UserPicker │ │ 组件: TaskCard / SubtaskRow / StatusDot / ExtrasCard / UserPicker / RecentProjects │
│ 状态: TaskStore (项目内单例) / ReorderableList / useLongPressDrag │ │ 状态: TaskStore (项目内单例) / ReorderableList / useLongPressDrag │
│ 工具: src/pages/task/date.js (与后端同口径的自然日边界) │ │ 工具: src/pages/task/date.js (与后端同口径的自然日边界) │
│ src/pages/task/prefs.js (设备端视图记忆 / 上次项目) │ │ src/pages/task/prefs.js (设备端: 视图记忆 / 上次项目 / 最近项目手动排序) │
└──────────────────────────────┬─────────────────────────────────────────────────────────┘ └──────────────────────────────┬─────────────────────────────────────────────────────────┘
│ HTTPS + JWT (Authorization: Bearer) │ HTTPS + JWT (Authorization: Bearer)
┌──────────────────────────────▼─────────────────────────────────────────────────────────┐ ┌──────────────────────────────▼─────────────────────────────────────────────────────────┐
@@ -98,18 +99,20 @@ src/
api/task.js # projectApi / columnApi / taskApi api/task.js # projectApi / columnApi / taskApi
pages/task/ pages/task/
date.js # 与后端同口径 date.js # 与后端同口径
prefs.js # 设备端偏好(localStorage) prefs.js # 设备端偏好(视图记忆/上次项目/最近项目手动排序)
TaskPage.jsx # 模块容器 + 路由 TaskPage.jsx # 首屏容器: RecentProjects 条 + 当前项目 ProjectView
ProjectList.jsx # 项目列表 ProjectList.jsx # 完整项目列表(/task/list, 从「更多项目」进入)
ProjectView.jsx # 列表/看板容器 ProjectView.jsx # 列表/看板容器
ListView.jsx # 列表视图 ListView.jsx # 列表视图
BoardView.jsx # 看板视图 BoardView.jsx # 看板视图
ColumnSettings.jsx # 列管理 + 绑定 + 保留选项 ColumnSettings.jsx # 列管理 + 绑定 + 保留选项(由项目管理进入)
TaskDetail.jsx # 任务详情(含扩展卡片 / 子任务) TaskDetail.jsx # 任务详情(含扩展卡片 / 子任务;底部安全距离)
ProjectMembers.jsx # 成员管理 ProjectMembers.jsx # 成员管理(由项目管理进入)
ProjectManage.jsx # 项目管理: 成员 / 列设置 / 项目信息 三区
components/task/ components/task/
TaskCard.jsx SubtaskRow.jsx StatusDot.jsx TaskCard.jsx SubtaskRow.jsx StatusDot.jsx
ExtrasCard.jsx UserPicker.jsx useLongPressDrag.js ExtrasCard.jsx UserPicker.jsx useLongPressDrag.js
RecentProjects.jsx # 首屏最近项目条(卡片可长按排序)
components/BottomNav.jsx # Tab 增「任务」,位于打卡之后 components/BottomNav.jsx # Tab 增「任务」,位于打卡之后
components/TabIcon.jsx # 增 task 图标 components/TabIcon.jsx # 增 task 图标
App.jsx # +/task、/task/:pid App.jsx # +/task、/task/:pid
@@ -37,7 +37,7 @@
### 1.3 完整功能 ### 1.3 完整功能
1. **视图体系**:列表视图、看板视图,可随时互相切换,同源数据、实时双向同步;每个项目独立记忆上次使用的视图。列表只是不按列分组,**任务归属的看板列与看板视图完全一致**。 1. **视图体系**:列表视图、看板视图,可随时互相切换,同源数据、实时双向同步;每个项目独立记忆上次使用的视图。列表只是不按列分组,**任务归属的看板列与看板视图完全一致**。**首屏**上部为「最近项目」(3 个卡片 + 更多入口,卡片可手动排序),下部直接展示**当前项目**的任务管理视图,不必先进项目列表。
2. **看板与任务状态的绑定配置**: 2. **看板与任务状态的绑定配置**:
- 看板列**默认 4 列** `Plan → Doing → Action → Done`;列**可增删改、可调整顺序**,数量不固定。 - 看板列**默认 4 列** `Plan → Doing → Action → Done`;列**可增删改、可调整顺序**,数量不固定。
- **每一列独立**选择是否绑定任务状态,并可选择绑定到 `未开始 / 执行中 / 完成` 中的任一个;**允许多列绑定同一状态**。 - **每一列独立**选择是否绑定任务状态,并可选择绑定到 `未开始 / 执行中 / 完成` 中的任一个;**允许多列绑定同一状态**。
@@ -50,7 +50,7 @@
4. **任务字段体系**:基础字段(标题必填,负责人默认本人)+ 内置可折叠**扩展信息卡片**(默认收起、全非必填);负责人(单人)与任务成员(多人)均为信息标记。 4. **任务字段体系**:基础字段(标题必填,负责人默认本人)+ 内置可折叠**扩展信息卡片**(默认收起、全非必填);负责人(单人)与任务成员(多人)均为信息标记。
5. **负责人 & 成员展示规则**:负责人大字号突出、成员小字号弱化并置于下方;二者只能从**当前项目成员**中选择;不改变项目统一读写权限。 5. **负责人 & 成员展示规则**:负责人大字号突出、成员小字号弱化并置于下方;二者只能从**当前项目成员**中选择;不改变项目统一读写权限。
6. **子任务体系**:仅一级、必须同项目、父子完成状态互不联动;列表视图支持缩进与折叠展开;**看板卡片内以简洁形式嵌套展示子任务并可直接勾选**,完整详情在卡片弹窗内编辑;支持**长按拖拽把任务降为另一个任务的一级子任务**。 6. **子任务体系**:仅一级、必须同项目、父子完成状态互不联动;列表视图支持缩进与折叠展开;**看板卡片内以简洁形式嵌套展示子任务并可直接勾选**,完整详情在卡片弹窗内编辑;支持**长按拖拽把任务降为另一个任务的一级子任务**。
7. **项目体系**:个人默认项目(不可删、不可加成员、名称以用户名开头且可改名);普通协作项目(可增删成员);创建人可主动退出;成员清空后静默保留,交后台维护。 7. **项目体系**:个人默认项目(不可删、不可加成员、名称以用户名开头且可改名);普通协作项目(可增删成员);创建人可主动退出;成员清空后静默保留,交后台维护。成员管理、列设置与项目信息(改名 / 归档 / 删除 / 退出)统一收进「**项目管理**」入口。
8. **项目权限**:以项目为隔离单位,只能看到自己是成员的项目;项目内成员权限均等、全读写;预留权限分级入口但不实现细分角色;任意成员均可归档项目。 8. **项目权限**:以项目为隔离单位,只能看到自己是成员的项目;项目内成员权限均等、全读写;预留权限分级入口但不实现细分角色;任意成员均可归档项目。
9. **跨项目拷贝**:是**拷贝**不是移动;完整复制全部字段、扩展信息、子任务、负责人、成员、状态、看板列、标签;新任务创建人为执行拷贝者;拷贝后新旧任务完全独立。 9. **跨项目拷贝**:是**拷贝**不是移动;完整复制全部字段、扩展信息、子任务、负责人、成员、状态、看板列、标签;新任务创建人为执行拷贝者;拷贝后新旧任务完全独立。
10. **UI 交互规范**:参考 Todoist 的列表交互与子任务折叠风格;看板卡片轻量化;列表与看板同源、切换无刷新、数据一致;具备空状态 / 加载状态 / 数据异常状态。 10. **UI 交互规范**:参考 Todoist 的列表交互与子任务折叠风格;看板卡片轻量化;列表与看板同源、切换无刷新、数据一致;具备空状态 / 加载状态 / 数据异常状态。
@@ -82,8 +82,11 @@
**场景 G · 看板卡片内的子任务** **场景 G · 看板卡片内的子任务**
看板中某任务卡片下方以简洁样式嵌套显示其子任务 → 直接在卡片内勾选子任务完成,**父任务状态不受影响**,子任务状态也不受该列绑定规则约束;每个嵌套子任务带**自身状态小标记**,便于识别与父任务不一致的状态;需要编辑子任务标题或负责人时,点开卡片弹窗操作。 看板中某任务卡片下方以简洁样式嵌套显示其子任务 → 直接在卡片内勾选子任务完成,**父任务状态不受影响**,子任务状态也不受该列绑定规则约束;每个嵌套子任务带**自身状态小标记**,便于识别与父任务不一致的状态;需要编辑子任务标题或负责人时,点开卡片弹窗操作。
**场景 H · 从底部 Tab 进入** **场景 H · 从底部 Tab 进入(首屏)**
底部导航顺序为「打卡 → **任务** → 宏观 → 监控 → 我」→ 点击「任务」即进入任务模块,落在上次访问的项目(记录在设备端;无记录则回到个人默认项目)。 底部导航顺序为「打卡 → **任务** → 宏观 → 监控 → 我」→ 点击「任务」进入首屏:**上部**是最近使用的 3 个项目卡片 + 「更多项目」入口(点卡片即切换当前项目,长按卡片可调整顺序);**下部**直接是当前项目(上次访问的,记录在设备端;无记录则回到个人默认项目)的任务管理页,无需先进项目列表。点「更多项目」才进入完整的项目列表页。
**场景 I · 项目管理**
在项目页头部点「项目管理」→ 弹出统一管理页,内含三个分区:**成员**(查看 / 添加 / 移除)、**列设置**(列管理 / 状态绑定 / 保留选项)、**项目信息**(改名 / 归档 / 删除 / 退出,个人默认项目不出现删除与退出)。
### 1.5 不在本期范围(明确排除) ### 1.5 不在本期范围(明确排除)
@@ -156,6 +159,9 @@
| T-VW-05 | 从未选择过视图的新项目,**默认使用列表视图** | 新建项目首次进入为列表视图 | P1 | | T-VW-05 | 从未选择过视图的新项目,**默认使用列表视图** | 新建项目首次进入为列表视图 | P1 |
| T-VW-06 | 视图记忆保存在**设备端**,不要求跨设备同步;本机跨会话保持 | 杀进程重进某项目,视图仍为上次选择;换设备登录不要求一致 | P0 | | T-VW-06 | 视图记忆保存在**设备端**,不要求跨设备同步;本机跨会话保持 | 杀进程重进某项目,视图仍为上次选择;换设备登录不要求一致 | P0 |
| T-VW-07 | **上次访问的项目**记录在**设备端**;无记录时**一律回到个人默认项目** | 杀进程重进落在上次项目;首次使用或记录失效则进入个人默认项目 | P0 | | T-VW-07 | **上次访问的项目**记录在**设备端**;无记录时**一律回到个人默认项目** | 杀进程重进落在上次项目;首次使用或记录失效则进入个人默认项目 | P0 |
| T-VW-08 | 任务**首屏上部**展示**最近使用的 3 个项目**卡片,末尾放「更多项目」入口;点击「更多项目」进入完整项目列表页 | 最近项目按访问时间倒序取前 3;点卡片切换当前项目并高亮;点「更多」进入项目列表 | P0 |
| T-VW-09 | 任务**首屏下部**直接展示**当前选中项目**的任务管理视图(列表 / 看板,随该项目视图记忆);当前项目默认为上次访问的项目,无记录时为个人默认项目 | 进入任务 Tab 即见当前项目的任务,**无需先进项目列表**;切换首屏项目卡片后下部随之切换 | P0 |
| T-VW-10 | 首屏**最近项目卡片支持手动排序**(长按拖动调整顺序),排序结果存**设备端**;未手动排序时按最近使用时间自动排列 | 拖动后顺序持久化,杀进程重进保持;新近访问但未手动排序的项目不出现在固定卡位之外 | P1 |
### 3.2 看板列管理(T-COL) ### 3.2 看板列管理(T-COL)
@@ -281,6 +287,8 @@
| T-PJ-08 | 项目**成员清空后静默保留**,交后台后续维护,前端不做特殊处理 | 成员全部退出后,前端不弹特殊提示、不出现错误;不可见 | P0 | | T-PJ-08 | 项目**成员清空后静默保留**,交后台后续维护,前端不做特殊处理 | 成员全部退出后,前端不弹特殊提示、不出现错误;不可见 | P0 |
| T-PJ-09 | 项目可**归档**,归档项目与进行中项目区分展示 | 归档后从进行中列表移出,可在归档区找回 | P1 | | T-PJ-09 | 项目可**归档**,归档项目与进行中项目区分展示 | 归档后从进行中列表移出,可在归档区找回 | P1 |
| T-PJ-10 | 项目列表按用户可见范围返回,仅含自己为成员的项目 | 非成员项目不出现在列表中 | P0 | | T-PJ-10 | 项目列表按用户可见范围返回,仅含自己为成员的项目 | 非成员项目不出现在列表中 | P0 |
| T-PJ-11 | 项目的**成员管理、成员新增、列设置**统一收进「**项目管理**」入口;项目页头部不再散置多个功能入口 | 项目管理内含 成员 / 列设置 / 项目信息 三个分区;头部仅保留「项目管理」一个入口 | P0 |
| T-PJ-12 | 项目管理内提供**项目信息管理**:改名(T-PJ-04)、归档 / 取消归档(T-PJ-09)、删除项目(T-PJ-02,个人默认项目禁用)、退出项目(T-PJ-07,创建人可退) | 各操作在项目管理中可用且遵循既有约束;个人项目不出现删除与退出入口 | P0 |
### 3.9 项目权限(T-PM) ### 3.9 项目权限(T-PM)
@@ -322,6 +330,7 @@
| T-UI-08 | 具备**空状态**展示(无项目 / 无任务 / 无子任务等) | 各层级无数据时展示空态与引导入口,不出现空白页 | P0 | | T-UI-08 | 具备**空状态**展示(无项目 / 无任务 / 无子任务等) | 各层级无数据时展示空态与引导入口,不出现空白页 | P0 |
| T-UI-09 | 具备**加载状态**展示 | 首屏与切换视图时展示加载态,不出现闪烁或错乱 | P0 | | T-UI-09 | 具备**加载状态**展示 | 首屏与切换视图时展示加载态,不出现闪烁或错乱 | P0 |
| T-UI-10 | 具备**数据异常状态**展示,可重试 | 接口失败时展示异常态与重试入口,重试成功后恢复 | P0 | | T-UI-10 | 具备**数据异常状态**展示,可重试 | 接口失败时展示异常态与重试入口,重试成功后恢复 | P0 |
| T-UI-11 | 弹层(任务详情等)**底部按钮与屏幕底边保持安全距离**,不被安全区 / 手势条遮挡 | 底部留白 ≥ 安全区高度 + 12px;小屏下弹层内容可滚动,删除等底部按钮始终完整可见可点 | P0 |
### 3.12 数据需求(T-DT) ### 3.12 数据需求(T-DT)
@@ -375,8 +384,8 @@
## 4. 优先级汇总(本期交付范围) ## 4. 优先级汇总(本期交付范围)
- **P0(必须)**:T-VW-01~04、T-VW-06、T-VW-07;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;T-PM-01、02、04~07;T-CP-01~06、08;T-UI-01~05、07~10;T-DT-01~08;T-NF-01~04;T-EX-01~05、07、09~12、15~17。 - **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~12;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;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。 - **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。
--- ---
@@ -416,6 +425,11 @@
| 26 | 是否允许删除最后一个列? | **不允许**。删除唯一剩余列时拒绝并提示,项目至少保留 1 列(T-COL-06、T-EX-17) | | 26 | 是否允许删除最后一个列? | **不允许**。删除唯一剩余列时拒绝并提示,项目至少保留 1 列(T-COL-06、T-EX-17) |
| 27 | 新增列的默认设置? | 按**默认值**:保留选项 `永久显示`、不绑定状态,**不继承**相邻列设置(T-COL-02、B-18) | | 27 | 新增列的默认设置? | 按**默认值**:保留选项 `永久显示`、不绑定状态,**不继承**相邻列设置(T-COL-02、B-18) |
| 28 | 看板列数量上限? | 暂定**上限 10 列**(`待确认`,见下) | | 28 | 看板列数量上限? | 暂定**上限 10 列**(`待确认`,见下) |
| 29 | 任务首屏结构? | **上部** = 最近使用的 3 个项目卡片 + 「更多项目」入口;**下部** = 当前选中项目的任务管理视图,默认上次访问的项目(T-VW-08/09) |
| 30 | 首屏「任务卡片可以排序」的指代? | 指**最近项目卡片**可长按拖动排序,结果存设备端;任务卡片的列内排序维持既有口径(T-VW-10) |
| 31 | 成员 / 列设置入口的位置? | 统一收进「**项目管理**」入口(含成员、列设置、项目信息三区),项目页头部不再散置(T-PJ-11) |
| 32 | 项目信息管理包含什么? | 改名、归档 / 取消归档、删除(个人项目禁用)、退出(创建人可退),入口在项目管理内(T-PJ-12) |
| 33 | 任务详情底部按钮遮挡? | 弹层底部预留**安全区 + 12px** 距离;小屏下弹层可滚动,底部按钮完整可见(T-UI-11) |
**仍待评审确认(影响实现口径):** **仍待评审确认(影响实现口径):**
+2 -1
View File
@@ -37,8 +37,9 @@ export default function App() {
{/* 打卡为默认首页 */} {/* 打卡为默认首页 */}
<Route path="/" element={<Navigate to={HOME_PATH} replace />} /> <Route path="/" element={<Navigate to={HOME_PATH} replace />} />
<Route path="/checkin" element={<CheckinPage />} /> <Route path="/checkin" element={<CheckinPage />} />
{/* 任务模块 (V0.2): 项目列表 / 项目内列表+看板 (T-PM-06) */} {/* 任务模块 (V0.2): 首屏(最近项目+当前项目) / 项目列表 / 深链 (T-VW-08/09) */}
<Route path="/task" element={<TaskPage />} /> <Route path="/task" element={<TaskPage />} />
<Route path="/task/list" element={<TaskPage />} />
<Route path="/task/:pid" element={<TaskPage />} /> <Route path="/task/:pid" element={<TaskPage />} />
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */} {/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
<Route path="/macro" element={<Navigate to="/macro/news" replace />} /> <Route path="/macro" element={<Navigate to="/macro/news" replace />} />
+136
View File
@@ -0,0 +1,136 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js'
// 首屏最近项目条 (T-VW-08/10)
// - 展示最近使用的 3 个项目 + 「更多项目」入口
// - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序
// - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端)
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length]
const LONG_PRESS = 300
const MOVE_TOLERANCE = 8
export default function RecentProjects({ projects, currentId, onSelect, onMore }) {
const [ver, setVer] = useState(0) // 手动排序后强制重算
const [dragIdx, setDragIdx] = useState(-1) // 拖拽中的卡片下标
const barRef = useRef(null)
const timer = useRef(null)
const start = useRef(null)
const active = useRef(false)
// 展示顺序: 手动 > 自动 > 列表序, 只取未归档的前 3
const shown = useMemo(() => {
if (!projects) return []
const score = new Map()
getRecentOrder().forEach((id, i) => score.set(id, i))
let n = score.size
getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) })
const fallback = n + 1000
return [...projects]
.filter((p) => !p.archived)
.sort((a, b) => (score.get(a.id) ?? fallback) - (score.get(b.id) ?? fallback))
.slice(0, 3)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, ver])
useEffect(() => () => cleanup(), [])
function cleanup() {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
active.current = false
start.current = null
document.removeEventListener('touchmove', onMove, { passive: false })
document.removeEventListener('touchend', onEnd)
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onEnd)
}
const pointOf = (ev) => {
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
return { x: t.clientX, y: t.clientY }
}
// 按横坐标计算落点下标 (卡片宽 96 + 间距 8)
function indexAt(x) {
const rect = barRef.current?.getBoundingClientRect()
if (!rect) return dragIdx
const rel = x - rect.left + 4
return Math.max(0, Math.min(shown.length - 1, Math.floor(rel / 104)))
}
function onMove(ev) {
const p = pointOf(ev)
if (!active.current) {
// 未激活前: 移动超容差则取消长按 (用户想滚动)
if (start.current && (Math.abs(p.x - start.current.x) > MOVE_TOLERANCE || Math.abs(p.y - start.current.y) > MOVE_TOLERANCE)) {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
}
return
}
if (ev.cancelable) ev.preventDefault()
const to = indexAt(p.x)
if (to !== dragIdx && to >= 0) {
// 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生)
const ids = shown.map((p2) => p2.id)
const [moved] = ids.splice(dragIdx, 1)
ids.splice(to, 0, moved)
setRecentOrder(ids)
setDragIdx(to)
setVer((v) => v + 1)
}
}
function onEnd() {
if (active.current) setVer((v) => v + 1)
cleanup()
setDragIdx(-1)
}
function beginPress(i, ev) {
if (ev.button && ev.button !== 0) return
const p = pointOf(ev)
start.current = p
active.current = false
cleanup()
timer.current = setTimeout(() => {
active.current = true
setDragIdx(i)
if (navigator.vibrate) navigator.vibrate(10)
}, LONG_PRESS)
document.addEventListener('touchmove', onMove, { passive: false })
document.addEventListener('touchend', onEnd)
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onEnd)
}
if (!projects) return null
return (
<>
<div className="rp-bar" ref={barRef}>
{shown.map((p, i) => (
<div
key={p.id}
className={`rp-card${p.id === currentId ? ' on' : ''}${i === dragIdx ? ' dragging' : ''}`}
data-project-id={p.id}
onTouchStart={(ev) => beginPress(i, ev)}
onMouseDown={(ev) => beginPress(i, ev)}
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
>
<div className="rp-ico" style={{ background: colorOf(p.id) }}>
{(p.name || '?').slice(0, 1)}
</div>
<div className="rp-name">{p.name}</div>
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
<div className="rp-grip">⠿</div>
</div>
))}
<div className="rp-more" onClick={onMore}>
<div className="rp-more-ico">▤</div>
更多项目
</div>
</div>
<div className="rp-drag-tip">长按卡片可调整顺序(本机记忆)</div>
</>
)
}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function UserPicker({
} }
return ( return (
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}> <Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
<div className="sheet-title">{title}</div> <div className="sheet-title">{title}</div>
<div className="sheet-sub">仅可选择当前项目的成员</div> <div className="sheet-sub">仅可选择当前项目的成员</div>
+172
View File
@@ -0,0 +1,172 @@
import { useState } from 'react'
import { Dialog, Input, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
import ColumnSettings from './ColumnSettings.jsx'
import ProjectMembers from './ProjectMembers.jsx'
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
export default function ProjectManage({ project, onClose, onChanged, onExited }) {
const { user } = useAuth()
const [view, setView] = useState('main') // main | columns | members
const [renaming, setRenaming] = useState(false)
const [name, setName] = useState(project.name)
const members = project.members || []
const isOwner = user?.id === project.ownerId
const isPersonal = !!project.isPersonal
if (view === 'columns') {
return (
<ColumnSettings
projectId={project.id}
columns={project.columns || []}
onClose={() => setView('main')}
onSaved={() => { setView('main'); onChanged?.() }}
/>
)
}
if (view === 'members') {
return (
<ProjectMembers
project={project}
onClose={() => setView('main')}
onChanged={onChanged}
/>
)
}
async function saveName() {
const t = name.trim()
if (!t) { Toast.show({ content: '项目名称不能为空' }); return }
try {
await projectApi.update(project.id, { name: t })
Toast.show({ content: '已改名' })
setRenaming(false)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function toggleArchive() {
const toArchive = !project.archived
const ok = await Dialog.confirm({
content: toArchive ? '归档该项目?归档后从进行中列表移出,可在归档区找回。' : '取消归档该项目?',
confirmText: toArchive ? '归档' : '取消归档',
})
if (!ok) return
try {
await projectApi.update(project.id, { archived: toArchive })
Toast.show({ content: toArchive ? '已归档' : '已恢复' })
if (toArchive) onExited?.()
else onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function remove() {
const ok = await Dialog.confirm({
content: `删除项目「${project.name}」?其中的任务、看板列将一并删除,不可恢复。`,
confirmText: '删除',
})
if (!ok) return
try {
await projectApi.remove(project.id)
Toast.show({ content: '已删除' })
onExited?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function quit() {
const ok = await Dialog.confirm({
content: '退出该项目?退出后将不再可见。',
confirmText: '退出',
})
if (!ok) return
try {
await projectApi.removeMember(project.id, user.id)
Toast.show({ content: '已退出' })
onExited?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">项目管理 · {project.name}</span>
</div>
<div className="cs-body">
<div className="set-hint" style={{ paddingTop: 12 }}>
项目内所有成员权限均等、全读写;以下配置对全体成员即时生效。
</div>
<div className="section-h" style={{ margin: '6px 14px 8px' }}>成员({members.length})</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('members')}>
<div className="avatar">{(members[0]?.name || '?').slice(0, 1)}</div>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
{members.slice(0, 3).map((m) => m.name).join('、')}{members.length > 3 ? ` 等 ${members.length} 人` : ''}
</span>
<span className="link">管理 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>看板列</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('columns')}>
<span className="pn" style={{ flex: 1, fontSize: 13, color: '#999' }}>
{(project.columns || []).map((c) => c.name).join(' · ')}
</span>
<span className="link">列设置 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={toggleArchive}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>归档项目</span>
<span className="link">{project.archived ? '取消归档' : '归档'}</span>
</div>
{!isPersonal && (
<div className="set-row" style={{ padding: '12px 14px' }} onClick={remove}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>删除项目</span>
<span className="link danger">删除</span>
</div>
)}
{isOwner && !isPersonal && (
<div className="set-row" style={{ padding: '12px 14px' }} onClick={quit}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>退出项目</span>
<span className="link danger">退出</span>
</div>
)}
<div className="set-hint">
个人默认项目不可删除、不可退出(T-PJ-02);退出项目仅创建人可见(T-PJ-07)。
</div>
</div>
<Dialog
visible={renaming}
title="项目名称"
content={
<div style={{ marginTop: 8 }}>
<Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
</div>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setRenaming(false) },
{ key: 'ok', text: '保存', bold: true, onClick: saveName },
]}
onClose={() => setRenaming(false)}
/>
</div>
)
}
+18 -37
View File
@@ -5,20 +5,19 @@ import { filterVisible, msToNextBoundary } from './date.js'
import { getViewMode, setViewMode } from './prefs.js' import { getViewMode, setViewMode } from './prefs.js'
import ListView from './ListView.jsx' import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx' import BoardView from './BoardView.jsx'
import ColumnSettings from './ColumnSettings.jsx'
import TaskDetail from './TaskDetail.jsx' import TaskDetail from './TaskDetail.jsx'
import ProjectMembers from './ProjectMembers.jsx' import ProjectManage from './ProjectManage.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新 // 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
export default function ProjectView({ projectId, onBack }) { // 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
export default function ProjectView({ projectId, onProjectChanged, onProjectExited }) {
const [project, setProject] = useState(null) const [project, setProject] = useState(null)
const [tasks, setTasks] = useState(null) const [tasks, setTasks] = useState(null)
const [columns, setColumns] = useState([]) const [columns, setColumns] = useState([])
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [mode, setMode] = useState(() => getViewMode(projectId) || 'list') const [mode, setMode] = useState(() => getViewMode(projectId) || 'list')
const [openTask, setOpenTask] = useState(null) const [openTask, setOpenTask] = useState(null)
const [showColumns, setShowColumns] = useState(false) const [showManage, setShowManage] = useState(false)
const [showMembers, setShowMembers] = useState(false)
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发 const [tick, setTick] = useState(0) // 保留窗口重算触发
@@ -139,12 +138,8 @@ export default function ProjectView({ projectId, onBack }) {
if (err) { if (err) {
return ( return (
<div className="page-with-navbar"> <div className="pv-embed">
<div className="navbar-sub"> <div className="empty" style={{ paddingTop: 40 }}>
<span className="back" onClick={onBack}>‹</span>
<span className="title">任务</span>
</div>
<div className="empty">
<div className="ico err">!</div> <div className="ico err">!</div>
<p>{err}</p> <p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div> <div className="mini-btn solid" onClick={load}>重试</div>
@@ -155,24 +150,21 @@ export default function ProjectView({ projectId, onBack }) {
if (!project || !tasks) { if (!project || !tasks) {
return ( return (
<div className="page-with-navbar"> <div className="pv-embed">
<div className="navbar-sub"> <div className="empty" style={{ padding: '40px 20px' }}>
<span className="back" onClick={onBack}>‹</span> <div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" />
<span className="title">任务</span>
</div> </div>
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
</div> </div>
) )
} }
return ( return (
<div className="page-with-navbar"> <div className="pv-embed">
<div className="navbar-sub"> <div className="navbar-sub task-projbar">
<span className="back" onClick={onBack}>‹</span>
<span className="title">{project.name}</span> <span className="title">{project.name}</span>
<div className="right"> <div className="right">
<span className="mini-btn" onClick={() => setShowMembers(true)}>成员</span> <span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
<span className="mini-btn" onClick={() => setAdding((v) => !v)}>+ 新增</span> <span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
</div> </div>
</div> </div>
@@ -182,9 +174,6 @@ export default function ProjectView({ projectId, onBack }) {
<div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div> <div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div>
<div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div> <div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div>
</div> </div>
<div className="right">
<span className="mini-btn grey" onClick={() => setShowColumns(true)}>列设置</span>
</div>
</div> </div>
{adding && ( {adding && (
@@ -234,20 +223,12 @@ export default function ProjectView({ projectId, onBack }) {
/> />
)} )}
{showColumns && ( {showManage && (
<ColumnSettings <ProjectManage
projectId={projectId}
columns={columns}
onClose={() => setShowColumns(false)}
onSaved={() => { setShowColumns(false); load() }}
/>
)}
{showMembers && (
<ProjectMembers
project={project} project={project}
onClose={() => setShowMembers(false)} onClose={() => setShowManage(false)}
onChanged={load} onChanged={() => { load(); onProjectChanged?.() }}
onExited={() => { setShowManage(false); onProjectExited?.() }}
/> />
)} )}
</div> </div>
+2 -2
View File
@@ -133,7 +133,7 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
visible visible
onMaskClick={onClose} onMaskClick={onClose}
position="bottom" position="bottom"
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px 14px' }} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}
> >
<div className="sheet-title">{task.title}</div> <div className="sheet-title">{task.title}</div>
<div className="sheet-sub"> <div className="sheet-sub">
@@ -253,7 +253,7 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
<Popup <Popup
visible={!!copies} visible={!!copies}
onMaskClick={() => setCopies(null)} onMaskClick={() => setCopies(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}
> >
<div className="sheet-title">跨项目拷贝</div> <div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">源任务保持不变;新任务的创建人是你</div> <div className="sheet-sub">源任务保持不变;新任务的创建人是你</div>
+87 -36
View File
@@ -1,19 +1,26 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Dialog, DotLoading, Empty, Input, Toast } from 'antd-mobile' import { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
import PageHeader from '../../components/PageHeader.jsx' import PageHeader from '../../components/PageHeader.jsx'
import { projectApi } from '../../api/task.js' import { projectApi } from '../../api/task.js'
import { getLastProjectId, setLastProjectId, prunePrefs } from './prefs.js' import {
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
} from './prefs.js'
import RecentProjects from '../../components/task/RecentProjects.jsx'
import ProjectView from './ProjectView.jsx' import ProjectView from './ProjectView.jsx'
// 任务模块容器 (T-PM-06) // 任务模块容器 (T-PM-06, T-VW-08/09)
// 无 :pid -> 项目列表 // /task 首屏: 上 = 最近项目条(3 个 + 更多入口), 下 = 当前项目的任务管理
// 有 :pid -> 该项目视图(列表/看板) // /task/list 完整项目列表(从「更多项目」进入)
// /task/:pid 打开指定项目(深链), 布局同首屏
export default function TaskPage() { export default function TaskPage() {
const { pid } = useParams() const { pid } = useParams()
const { pathname } = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const isList = pathname === '/task/list'
const [projects, setProjects] = useState(null) const [projects, setProjects] = useState(null)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [currentId, setCurrentId] = useState(null)
const load = useCallback(() => { const load = useCallback(() => {
setErr('') setErr('')
@@ -28,34 +35,91 @@ export default function TaskPage() {
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// 记住上次访问的项目 (T-VW-07) // 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09)
useEffect(() => { useEffect(() => {
if (pid) setLastProjectId(Number(pid)) if (!projects) return
}, [pid]) const ok = (x) => x && projects.some((p) => p.id === x && !p.archived)
let id = pid ? Number(pid) : getLastProjectId()
if (!ok(id)) id = projects.find((p) => p.isPersonal)?.id ?? projects[0]?.id ?? null
if (!ok(id)) id = null
setCurrentId(id)
if (id) { setLastProjectId(id); setRecentIds(id) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, pid])
if (pid) { const selectProject = (id) => {
return <ProjectView projectId={Number(pid)} onBack={() => navigate('/task')} /> setCurrentId(id)
setLastProjectId(id)
setRecentIds(id)
if (pid) navigate('/task', { replace: true })
} }
// 项目被删除 / 退出 / 归档后: 重载并回落个人默认项目
const handleExited = () => {
setCurrentId(null)
load()
}
if (isList) {
return ( return (
<div className="page-with-navbar"> <div className="page-with-navbar">
<PageHeader title="任务" /> <div className="navbar-sub task-projbar">
<span className="back" onClick={() => navigate('/task')}>‹</span>
<span className="title">我的项目</span>
</div>
<div className="task-content"> <div className="task-content">
<ProjectList <ProjectList
projects={projects} projects={projects}
err={err} err={err}
onRetry={load} onRetry={load}
onOpen={(id) => { onOpen={(id) => { selectProject(id); navigate('/task') }}
setLastProjectId(id)
navigate(`/task/${id}`)
}}
onChanged={load} onChanged={load}
/> />
</div> </div>
</div> </div>
) )
}
return (
<div className="page-with-navbar">
<PageHeader title="任务" />
<div className="task-home">
{err && !projects ? (
<div className="empty">
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div>
</div>
) : !projects ? (
<div className="empty" style={{ padding: '40px 20px' }}>
<div className="ico">··</div>
<p>加载中…</p>
<DotLoading color="#1677ff" />
</div>
) : (
<>
<RecentProjects
projects={projects}
currentId={currentId}
onSelect={selectProject}
onMore={() => navigate('/task/list')}
/>
{currentId != null && (
<ProjectView
key={currentId}
projectId={currentId}
onProjectChanged={load}
onProjectExited={handleExited}
/>
)}
</>
)}
</div>
</div>
)
} }
// 完整项目列表 (从「更多项目」进入)
function ProjectList({ projects, err, onRetry, onOpen, onChanged }) { function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [name, setName] = useState('') const [name, setName] = useState('')
@@ -71,9 +135,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
) )
} }
if (!projects) { if (!projects) {
return ( return <div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
)
} }
const active = projects.filter((p) => !p.archived) const active = projects.filter((p) => !p.archived)
@@ -84,11 +146,12 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
if (!t) { Toast.show({ content: '请输入项目名称' }); return } if (!t) { Toast.show({ content: '请输入项目名称' }); return }
setSaving(true) setSaving(true)
try { try {
await projectApi.create({ name: t }) const r = await projectApi.create({ name: t })
setName('') setName('')
setCreating(false) setCreating(false)
Toast.show({ content: '已创建' }) Toast.show({ content: '已创建' })
onChanged?.() onChanged?.()
onOpen?.(r.item.id)
} catch (e) { } catch (e) {
Toast.show({ content: e.message }) Toast.show({ content: e.message })
} finally { } finally {
@@ -101,13 +164,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
return ( return (
<> <>
<div className="proj-head"> <div style={{ height: 8 }} />
<b>我的项目</b>
<div className="right">
<div className="mini-btn" onClick={() => setCreating(true)}>+ 新建</div>
</div>
</div>
{active.map((p) => ( {active.map((p) => (
<div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}> <div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}>
<div className="pico" style={{ background: colorOf(p) }}> <div className="pico" style={{ background: colorOf(p) }}>
@@ -150,17 +207,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
</> </>
)} )}
<div style={{ height: 24 }} />
<Dialog <Dialog
visible={creating} visible={creating}
title="新建项目" title="新建项目"
content={ content={
<div className="field" style={{ marginTop: 8 }}> <div className="field" style={{ marginTop: 8 }}>
<Input <Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
placeholder="项目名称"
value={name}
onChange={setName}
maxLength={20}
/>
</div> </div>
} }
actions={[ actions={[
@@ -172,6 +226,3 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
</> </>
) )
} }
// 避免未使用告警
void Empty
+48 -9
View File
@@ -1,19 +1,27 @@
// 任务模块: 设备端偏好 (不落库, 不跨设备) // 任务模块: 设备端偏好 (不落库, 不跨设备)
// lastProjectId 上次访问的项目 (T-VW-07) // lastProjectId 上次访问的项目 (T-VW-07)
// viewMode 按项目记忆的视图 list | board (T-VW-03/06) // viewMode 按项目记忆的视图 list | board (T-VW-03/06)
// recentIds 自动按最近访问排序的项目 id (最前 = 最近), 上限 10 (T-VW-08)
// recentOrder 首屏最近项目卡片的手动排序结果, 优先于 recentIds (T-VW-10)
const KEY = 'iboard_task_prefs' const KEY = 'iboard_task_prefs'
function blank() {
return { lastProjectId: null, viewMode: {}, recentIds: [], recentOrder: [] }
}
function read() { function read() {
try { try {
const raw = localStorage.getItem(KEY) const raw = localStorage.getItem(KEY)
if (!raw) return { lastProjectId: null, viewMode: {} } if (!raw) return blank()
const obj = JSON.parse(raw) const obj = JSON.parse(raw)
return { return {
lastProjectId: obj.lastProjectId ?? null, lastProjectId: obj.lastProjectId ?? null,
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {}, viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
recentIds: Array.isArray(obj.recentIds) ? obj.recentIds : [],
recentOrder: Array.isArray(obj.recentOrder) ? obj.recentOrder : [],
} }
} catch { } catch {
return { lastProjectId: null, viewMode: {} } return blank()
} }
} }
@@ -46,13 +54,44 @@ export function setViewMode(projectId, mode) {
write(cur) write(cur)
} }
/** 清理已不可见的项目记录 */ /** 最近访问记录: 把 id 提到队首, 上限 10 (T-VW-08) */
export function prunePrefs(visibleIds) { export function getRecentIds() {
const set = new Set((visibleIds || []).map(String)) return read().recentIds
}
export function setRecentIds(id) {
if (!id) return
const cur = read() const cur = read()
if (cur.lastProjectId && !set.has(String(cur.lastProjectId))) cur.lastProjectId = null cur.recentIds = [id, ...cur.recentIds.filter((x) => x !== id)].slice(0, 10)
for (const k of Object.keys(cur.viewMode)) {
if (!set.has(k)) delete cur.viewMode[k]
}
write(cur) write(cur)
} }
/** 首屏卡片手动排序 (T-VW-10): 完整顺序, 排在前面的项目优先展示 */
export function getRecentOrder() {
return read().recentOrder
}
export function setRecentOrder(ids) {
const cur = read()
cur.recentOrder = (Array.isArray(ids) ? ids : []).map(Number).filter(Boolean)
write(cur)
}
/** 清理已不可见的项目记录 */
export function prunePrefs(visibleIds) {
const set = new Set((visibleIds || []).map(Number))
const cur = read()
let dirty = false
if (cur.lastProjectId && !set.has(cur.lastProjectId)) { cur.lastProjectId = null; dirty = true }
for (const k of Object.keys(cur.viewMode)) {
if (!set.has(Number(k))) { delete cur.viewMode[k]; dirty = true }
}
const ri = cur.recentIds.filter((id) => set.has(id))
const ro = cur.recentOrder.filter((id) => set.has(id))
if (ri.length !== cur.recentIds.length || ro.length !== cur.recentOrder.length) dirty = true
if (dirty) {
cur.recentIds = ri
cur.recentOrder = ro
write(cur)
}
}
+35
View File
@@ -1096,3 +1096,38 @@ textarea.inp { resize: vertical; }
} }
.empty .ico.err { background: #fff1f0; color: #ff7875; } .empty .ico.err { background: #fff1f0; color: #ff7875; }
.empty p { font-size: 13px; margin-bottom: 12px; } .empty p { font-size: 13px; margin-bottom: 12px; }
/* ---- 任务首屏 (T-VW-08/09/10): 最近项目条 + 当前项目 ---- */
.task-home { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.rp-bar { display: flex; gap: 8px; padding: 10px 12px 6px; overflow-x: auto; flex-shrink: 0; }
.rp-card {
flex: 0 0 96px; background: #fff; border-radius: 10px; padding: 9px 10px;
box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer; position: relative;
border: 1.5px solid transparent;
}
.rp-card.on { border-color: var(--color-primary); background: #f5f9ff; }
.rp-card.dragging { opacity: .6; transform: scale(.96); box-shadow: 0 8px 18px rgba(15,23,42,.18); z-index: 5; }
.rp-card .rp-ico {
width: 26px; height: 26px; border-radius: 7px; color: #fff; font-size: 11px;
font-weight: 700; display: flex; align-items: center; justify-content: center;
}
.rp-card .rp-name { font-size: 12px; font-weight: 600; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rp-card .rp-meta { font-size: 9.5px; color: #999; margin-top: 1px; }
.rp-card .rp-grip { position: absolute; top: 5px; right: 6px; color: #d3d8de; font-size: 10px; letter-spacing: -1px; }
.rp-more {
flex: 0 0 auto; align-self: stretch; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 3px; color: #666; font-size: 10.5px; cursor: pointer; padding: 0 6px;
}
.rp-more .rp-more-ico { width: 30px; height: 30px; border-radius: 50%; background: #f2f3f5; color: #9aa1aa; display: flex; align-items: center; justify-content: center; font-size: 14px; }
.rp-drag-tip { font-size: 9.5px; color: #b8bcc4; padding: 0 14px 2px; flex-shrink: 0; }
/* 首屏内嵌的项目视图 (T-VW-09) */
.pv-embed { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.task-projbar {
display: flex; align-items: center; gap: 10px; padding: 9px 14px;
background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; margin-top: 4px;
}
.task-projbar .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
.task-projbar .title { font-size: 15px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-projbar .right { margin-left: auto; display: flex; gap: 6px; flex-shrink: 0; }
.pv-embed .task-content { flex: 1 1 auto; }