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';
}
// ============================================================
// 内部工具
// ============================================================