v79 端上:任务面板(首页/任务页共用)+ 任务模块功能补齐

用户原话:「鸿蒙端的功能,也需要同样适配,在首页上可以直接修改编辑任务,
另外任务模块的功能需要完善。」

端上原来只有"看列表 / 勾完成 / 新建改标题",网页 v78 那套「就地处理」
(改状态、挪分组、子任务、附件、留言、删除)一件都没有;首页点任务
弹的还是 v73 的只读卡。

一、新增 FbTaskPanel(首页与任务页共用同一组件)
  · 改状态三段(待办/进行中/已完成)+ 标题左侧状态圈
  · 挪看板 chips、勾/加子任务、加图片附件并删、发留言
  · 编辑 → 交给宿主开 FbTaskEditSheet;删除要**点两次**(端上没有 undo)
  · 每个动作成功后再 getTask() 重拉一次:一次动作会牵动子任务计数、
    附件张数、看板色,逐处同步 DOM 必然漏一处

二、退役 FbTaskDetailSheet(v73 只读卡,387 行)
  ⚠️ 它已无人挂载,而守卫还在逐条"通过" —— 等于给死代码盖章。

三、任务模块补齐
  · 卡片点击 → 面板(不再直接进编辑表单:误触一下就开始改标题,
    且子任务/附件/留言没地方放)
  · 「只看已完成」改真打 ?view=done:看板视图服务端就写死
    status <> 'done',在客户端筛永远是空
  · 看板列表改走 ?view=boards:原来从任务反推 ⇒ 空看板永远选不到
  · 编辑表单补分类/循环两排

四、顺带修掉一个自查发现的真 bug
  任务页里 sheetCount 有**两个** @Monitor 都监听 boardSheetOpen、都写同一个值
  ⇒ 关掉设置浮层时谁最后执行不确定,会把还开着的面板"顺便让位结束",
  底部悬浮栏浮上来盖住面板按钮。已并成一个取并集的 monitor。

服务端(另一仓):新增 GET /tasks?view=boards、POST/DELETE /tasks/<id>/attach,
详情补 board_name/board_color_calc,越权删别条任务的附件 → 404。

验证:静态守卫 1055/0(新增 v79 一组,含"全页只许一个 sheetCount 赋值点")、
反向验证 215/0(新增 15 条注入,逐条证明新断言会红)、
服务端 e2e 1558/0、_fb_v79_api.py 26/0、编译通过并已装机。
This commit is contained in:
2026-10-22 16:40:44 +08:00
parent c7bd26dd1b
commit 25a0797a74
8 changed files with 1816 additions and 504 deletions
+202
View File
@@ -19,6 +19,7 @@ import {
FbMark,
FbLedgerBook, FbLedgerDetail, FbLedgerEntry, FbNoteItem, FbNotificationItem, FbResourceItem,
FbTaskItem, FbTripItem, FbWeather, FbStat, FbMemoryItem, FbRecentDay,
FbTaskAttach, FbTaskComment, FbTaskDetail, FbTaskKid, FbTaskMeta, FbCatOpt, FbBoardOpt,
} from '../model/Types';
// ============================================================
@@ -171,6 +172,45 @@ interface RawTaskBoard {
id?: number; name?: string; color?: string; tasks?: RawTask[];
}
/* ---- v79:任务详情(`GET /tasks/<id>`)才有那几块 ---- */
interface RawTaskKid {
id?: number; title?: string; status?: string;
}
interface RawTaskComment {
id?: number; person_id?: number; content?: string; created_at?: string;
name?: string | null; my_nickname?: string | null; avatar_color?: string | null;
}
interface RawTaskAttach {
id?: number; key?: string; name?: string | null; mime?: string | null;
size?: number; is_image?: number | null;
}
/** 任务详情的原始行:任务本体 + 详情独占的几列 + 三块子集合 */
interface RawTaskDetail extends RawTask {
assignee_id?: number | null;
/** 我对负责人的称呼(优先于 `assignee_name`,与网页 `person_name()` 同口径) */
assignee_nick?: string | null;
creator_name?: string | null;
creator_nick?: string | null;
repeat_rule?: string | null;
parent_title?: string | null;
children?: RawTaskKid[];
comments?: RawTaskComment[];
attachs?: RawTaskAttach[];
}
/** `?view=boards` 的看板元数据(v79) */
interface RawBoardMeta {
id?: number; name?: string; color?: string; open?: number;
}
interface RawCatMeta {
id?: number; name?: string;
}
interface RawLedgerBook {
id?: number; name?: string; my_perm?: string | null; entry_count?: number;
share_perm?: string | null;
@@ -799,6 +839,18 @@ export function parseMemories(v: Object | null | undefined): FbMemoryItem[] {
return out;
}
/**
* 扁平的任务列表 JSON(`?view=done` / `?view=status`)→ `FbTaskItem[]`。
*
* ⚠️ 与 `parseTasks`(看板视图)**不能互换**:那个认 `{boards:[...]}`,
* 这个认 `[...]`。缓存键也必须分开(`tasks` vs `tasks_done`),
* 否则两个形状会互相覆盖,表现是"切一次已完成,看板全空了"。
*/
export function parseTaskList(json: string): FbTaskItem[] {
const rows: RawTask[] = asArray<RawTask>(json);
return parseTaskRows(rows);
}
/** 一批任务行 → FbTaskItem[](首页与任务页共用同一段形状转换) */
export function parseTaskRows(rows: RawTask[]): FbTaskItem[] {
const out: FbTaskItem[] = [];
@@ -808,6 +860,156 @@ export function parseTaskRows(rows: RawTask[]): FbTaskItem[] {
return out;
}
/**
* `?view=boards` → 看板 + 分类(v79)。
*
* 🔴 端上原来是从已加载任务里**反推**看板的(`FbTasksPage.boardOpts`),
* 那样只能看到"有任务的看板" —— 用户刚建好、还没放任务的看板在端上
* 永远选不到(表现为"网页能建,端上挪不过去")。
*/
export function parseTaskMeta(json: string): FbTaskMeta {
const root: Record<string, Object> | null = asObject(json);
const boards: FbBoardOpt[] = [];
const cats: FbCatOpt[] = [];
if (root === null) {
const empty: FbTaskMeta = { boards: boards, cats: cats };
return empty;
}
for (const b of arr<RawBoardMeta>(root['boards'] as RawBoardMeta[])) {
const id: number = n(b.id);
const name: string = s(b.name);
if (name === '') {
continue;
}
const one: FbBoardOpt = { id: id, name: name, color: s(b.color) };
boards.push(one);
}
for (const c of arr<RawCatMeta>(root['categories'] as RawCatMeta[])) {
const name: string = s(c.name);
if (name === '') {
continue;
}
const one: FbCatOpt = { id: n(c.id), name: name };
cats.push(one);
}
const meta: FbTaskMeta = { boards: boards, cats: cats };
return meta;
}
/**
* 一条任务的详情 → `FbTaskDetail`(v79 面板用)。
*
* ⚠️ `base` 走**同一个** `taskRow()`(与首页/任务页列表同源)——
* 三处各写一份转换,迟早出现"同一张卡在两个页面显示不一样"。
*/
export function parseTaskDetail(json: string): FbTaskDetail | null {
const root: Record<string, Object> | null = asObject(json);
if (root === null) {
return null;
}
const d: RawTaskDetail = root as RawTaskDetail;
if (n(d.id) <= 0) {
return null;
}
const base: FbTaskItem = taskRow(d, s(d.board_name), s(d.board_color_calc));
const kids: FbTaskKid[] = [];
for (const k of arr<RawTaskKid>(d.children)) {
if (n(k.id) <= 0) {
continue;
}
const one: FbTaskKid = {
id: n(k.id),
title: s(k.title),
status: s(k.status) !== '' ? s(k.status) : 'todo',
};
kids.push(one);
}
const comments: FbTaskComment[] = [];
for (const c of arr<RawTaskComment>(d.comments)) {
const nick: string = s(c.my_nickname);
const one: FbTaskComment = {
id: n(c.id),
content: s(c.content),
name: nick !== '' ? nick : s(c.name),
color: s(c.avatar_color),
createdAt: s(c.created_at),
};
comments.push(one);
}
const attachs: FbTaskAttach[] = [];
for (const a of arr<RawTaskAttach>(d.attachs)) {
const name: string = s(a.name);
const mime: string = s(a.mime);
const isImg: boolean = n(a.is_image) === 1 || mime.indexOf('image/') === 0;
const one: FbTaskAttach = {
id: n(a.id),
name: name !== '' ? name : '附件',
kind: attachKindOf(mime, name, isImg),
isImage: isImg,
key: s(a.key),
sizeWord: sizeWordOf(n(a.size)),
};
attachs.push(one);
}
const creatorNick: string = s(d.creator_nick);
const detail: FbTaskDetail = {
base: base,
assigneeId: n(d.assignee_id),
categoryId: n(d.category_id),
category: s(d.category_name),
repeat: s(d.repeat_rule),
parentTitle: s(d.parent_title),
creator: creatorNick !== '' ? creatorNick : s(d.creator_name),
kids: kids,
comments: comments,
attachs: attachs,
};
return detail;
}
/** 附件是「哪一类」—— 决定画缩略图还是画一个图标 + 后缀 */
function attachKindOf(mime: string, name: string, isImg: boolean): string {
if (isImg) {
return 'image';
}
const dot: number = name.lastIndexOf('.');
const ext: string = dot >= 0 ? name.substring(dot + 1).toLowerCase() : '';
if (mime === 'application/pdf' || ext === 'pdf') {
return 'pdf';
}
if (ext === 'doc' || ext === 'docx') {
return 'doc';
}
if (ext === 'xls' || ext === 'xlsx' || ext === 'csv') {
return 'xls';
}
if (ext === 'ppt' || ext === 'pptx') {
return 'ppt';
}
if (ext === 'txt' || ext === 'md') {
return 'text';
}
return 'file';
}
/** 附件大小(给人看的那个串) */
function sizeWordOf(bytes: number): string {
if (bytes <= 0) {
return '';
}
if (bytes < 1024) {
return bytes.toString() + ' B';
}
if (bytes < 1024 * 1024) {
return (Math.round(bytes / 102.4) / 10).toString() + ' KB';
}
return (Math.round(bytes / 104857.6) / 10).toString() + ' MB';
}
// ============================================================
// 内部工具
// ============================================================
+101 -1
View File
@@ -18,6 +18,7 @@
*/
import { fbApi, FbApiResult, FbApiOptions } from './FbHttp';
import { fbUploadOne } from './FbUpload';
/** 新建/编辑任务提交的字段 */
export interface FbTaskDraft {
@@ -29,6 +30,22 @@ export interface FbTaskDraft {
boardId: number;
/** todo / doing / done(只在**新建**时生效;改状态走 `setTaskStatus`) */
status: string;
/**
* 父任务 id(v79;0 = 顶层任务)。
* 面板里「加一条子任务」就是 `parentId = 当前任务` 的新建请求。
*/
parentId: number;
/**
* 负责人 id。
*
* `> 0` = 指定这个人;`0` = 新建时"用默认值(我自己)"、编辑时"清空负责人";
* **`-1` = 这次不改**(编辑表单目前没有负责人这一项 ⇒ 不能传 0 把它抹掉)。
*/
assigneeId: number;
/** 分类 id(0 = 不分类) */
categoryId: number;
/** 循环规则:空 / day / week / month / year */
repeat: string;
}
/** 新建任务 */
@@ -41,12 +58,27 @@ export async function createTask(token: string, d: FbTaskDraft): Promise<FbApiRe
body['due_at'] = d.dueAt;
body['board_id'] = d.boardId;
body['status'] = d.status;
/* v79:这三个原来没传 —— 端上因此**建不出子任务**、也指定不了负责人/分类。
⚠️ `assignee_id` 只在 >0 时才传:服务端 `Api::int('assignee_id', $pid)`
的默认值是"我自己",传 0 会把它覆盖成"没人负责"。 */
if (d.parentId > 0) {
body['parent_id'] = d.parentId;
}
if (d.assigneeId > 0) {
body['assignee_id'] = d.assigneeId;
}
if (d.categoryId > 0) {
body['category_id'] = d.categoryId;
}
if (d.repeat !== '') {
body['repeat_rule'] = d.repeat;
}
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks', opts);
}
/**
* 改任务(标题 / 备注 / 截止 / 看板)。
* 改任务(标题 / 备注 / 截止 / 看板 / 负责人 / 分类 / 循环)。
*
* ⚠️ **不带 status**:服务端 PATCH 会忽略它(见文件头说明),
* 带上只会让人以为"改成功了",实际状态没动。
@@ -57,6 +89,15 @@ export async function updateTask(token: string, id: number, d: FbTaskDraft): Pro
body['note'] = d.note;
body['due_at'] = d.dueAt;
body['board_id'] = d.boardId;
/* v79:负责人/分类/循环也走 PATCH(服务端这三个字段都认)。
⚠️ `assignee_id` 只在**明确要改**(>=0)时才传:编辑表单里没有负责人这一项,
传 0 会把原来指派的负责人**静默清空**(与 v77「没传 ≠ 清空」同一类 bug)。
⚠️ 分类和循环则**必须**照传(0 / 空串就是"清空",那是合法操作)。 */
if (d.assigneeId >= 0) {
body['assignee_id'] = d.assigneeId;
}
body['category_id'] = d.categoryId;
body['repeat_rule'] = d.repeat;
const opts: FbApiOptions = { method: 'PATCH', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
@@ -74,3 +115,62 @@ export async function deleteTask(token: string, id: number): Promise<FbApiResult
const opts: FbApiOptions = { method: 'DELETE', token: token };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
/* ═══════════════════════════════════════════════════════════════
v79(用户 2026-10-22):「鸿蒙端的功能,也需要同样适配……
任务模块的功能需要完善。」
⇒ 下面这些是网页 v78「就地面板」在端上的对等物。
⚠️ 除 `getTask` 外全部返回 `FbApiResult`,且**服务端都会回整条任务** ——
调用方拿到 `res.data` 直接重画面板,不用再补一次 GET。
═══════════════════════════════════════════════════════════════ */
/** 取一条任务的详情(含子任务 / 留言 / 附件 / 共享人) */
export async function getTask(token: string, id: number): Promise<FbApiResult> {
const opts: FbApiOptions = { method: 'GET', token: token };
return await fbApi('/api/v1/tasks/' + id.toString(), opts);
}
/**
* 挪到另一个看板(网页版「分组」的等价物)。
*
* ⚠️ 走 `/move` 而不是 `PATCH`:`/move` 会顺手把这一列重排 position;
* PATCH 只改 board_id,同列里会留下重复的 position(网页端拖拽用的是 /move)。
*/
export async function moveTask(token: string, id: number, boardId: number): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['board_id'] = boardId;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString() + '/move', opts);
}
/** 发一条留言(服务端只回 `{id}`,要整块内容就再 `getTask` 一次) */
export async function addTaskComment(token: string, id: number, content: string): Promise<FbApiResult> {
const body: Record<string, Object> = {};
body['content'] = content;
const opts: FbApiOptions = { method: 'POST', token: token, body: body };
return await fbApi('/api/v1/tasks/' + id.toString() + '/comments', opts);
}
/**
* 传一个附件(图片/文档都行,服务端按 mime 自己分)。
*
* @return 空串 = 成功;否则是给人看的错误说明(与 `fbUploadOne` 的约定一致)
*/
export async function uploadTaskAttach(token: string, id: number, uri: string): Promise<string> {
return await fbUploadOne('/api/v1/tasks/' + id.toString() + '/attach', 'file', uri, token);
}
/** 删一个附件(列表里点 ✕) */
export async function delTaskAttach(token: string, id: number, aid: number): Promise<FbApiResult> {
/* ⚠️ 两处都踩过,别改回去:
① 路由只解析「资源/id/子动作」**三段** ⇒ 不能写成 `/attach/<aid>`,
那样服务端只会拿到 `$sub='attach'`,附件 id 被静默丢掉;
② 附件 id 走 **query**,不要塞 JSON body —— 实测 `DELETE` 带 JSON 体时
服务端读到的是空(`Api::param` 拿不到),症状是
「要指定删哪个附件」400,而请求看起来完全正常。 */
const q: Record<string, string> = {};
q['attachment_id'] = aid.toString();
const opts: FbApiOptions = { method: 'DELETE', token: token, query: q };
return await fbApi('/api/v1/tasks/' + id.toString() + '/attach', opts);
}