端上:#47~#55 打包 —— 行程双轨、动态类型、表单全局规范 (#53)

#48 计划/实际双轨:nodeAction(start/done/cancel),完成即转实际行程,
    实际行程不再显示状态;新增 actualStartedAt/actualEndedAt
#49 类型/出行方式/车辆 chip 改内部读状态(@Builder 值传参不刷新)
#50 类型点击立即变色、出行方式平铺、参与人显示已选头像
#51 节点类型动态加载(原硬编码5种)、地址 onPick 拿详细地址与坐标
#52 标签改主题色 Bold、必填红无星号、编辑行程标题同步
#53 表单两排版式(标签独占一行解决左右对不齐);电影弹层 layoutWeight 修滚动
#54 地址联想框加时间窗抑制(600ms 内不搜)、候选限高 180

含附件回填诊断日志(DIAG-ATTACH/FILL),待真机确认后移除
This commit is contained in:
2026-11-05 12:41:04 +08:00
parent 88fa38ecf9
commit 86de59616c
35 changed files with 5818 additions and 911 deletions
+249 -45
View File
@@ -29,6 +29,7 @@ import { hilog } from '@kit.PerformanceAnalysisKit';
import { FbColors, FbMetric } from '../common/Theme';
import { FbGeoTip } from '../model/Types';
import { FB_GEO_MIN_LEN, fbGeoTips } from '../data/FbGeoRepo';
import { FbDiag } from '../common/FbDiag';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbAddrInput';
@@ -50,6 +51,22 @@ export struct FbAddrInput {
@Event onValue: (v: string) => void = (_v: string): void => {
};
/**
* **结构化选中回调**(#51)。
*
* 🔴 用户:「鸿蒙端如果添加一个行程,出发地和目的地地址是不是也能通过
* 高德地图 API 拿到详细地址?」
*
* `FbGeoTip` 本来就有 `detail` / `address` / `lng` / `lat`(高德返回就有),
* 但 `onValue` 只传了"地名 · 详细地址"**拼好的字符串** ⇒ 父组件拿不到坐标,
* 也没法分别写 `from_place` / `from_addr` / `from_lng` / `from_lat`。
*
* ⇒ 加这个回调把整条 tip 交出去。
* ⚠️ 保留 `onValue`(6 处调用方只关心文字,改签名改动面太大)。
*/
@Event onPick: (t: FbGeoTip) => void = (_t: FbGeoTip): void => {
};
/** 候选列表 */
@Local tips: FbGeoTip[] = [];
/** 是否展开候选面板 */
@@ -66,6 +83,46 @@ export struct FbAddrInput {
*/
@Local private seq: number = 0;
/**
* 最后一次**程序写入**的值(选中候选后回写的那种)。
*
* 🔴 2026-11-03 用户报:「点选之后,那个框还会再弹出来一次」。
* 根因:`onValue()` 回写 → 父组件改 `value` → `TextInput` 重渲染
* → 再次触发 `onChange` → 又发起一次搜索 → 候选框重新弹出。
*
* 解法照 SyncCalendar(`EventEditPage.ets` 的 `lastAppliedText`):
* 在 onChange 里精确比对,命中就**直接 return**,不再搜。
*/
@Local private lastApplied: string = '';
/**
* 首次 `onChange` 是否已经发生过。
*
* 🔴 2026-11-05(用户:「一打开之后,马上弹出来两个框让我选地址」):
* ArkUI 会为**新挂载**的 `TextInput` 触发一次 `onChange`(即使 text 没变),
* 而编辑浮层打开时 `value` 已经预填了地址 ⇒ 那一次 onChange 被当成
* "用户输入" ⇒ 去搜 ⇒ 有候选 ⇒ `bindPopup` 弹出。
* 两个地址框都这样 ⇒ 一进浮层就弹两个候选框。
*
* ⇒ 第一次 onChange 只认作初始化回填,**不搜索**。
*/
@Local private primed: boolean = false;
/**
* **时间窗抑制**(#53):选中医选后的这段时间内,一律不搜索。
*
* 🔴 前三次修"选中后又弹一次"都靠值比对(原串 → trim),都没修好。
* 根因是值比对这条路本身不可靠:ArkUI 在 TextInput 内容被程序改写时,
* onChange 的**触发次数和时机不确定**(重渲染、光标定位、输入法同步各一次),
* 只要有一次值与记录的不一致,就会搜一次、框就重弹。
*
* ⇒ 改用时间窗:选中后 600ms 内任何 onChange 都直接返回。
* 600ms 足以覆盖所有回写触发的 onChange,而人不可能在这段时间打完第二个字。
*/
private suppressUntil: number = 0;
/** 选中后抑制搜索的时长(毫秒) */
private static readonly SUPPRESS_MS: number = 600;
/** 收起面板并清空候选 */
private close(): void {
this.open = false;
@@ -74,12 +131,59 @@ export struct FbAddrInput {
/** 输入变化 → 防抖 → 查候选 */
private onType(v: string): void {
/* 🔴 #53:**时间窗抑制**(最优先,先于一切值比对)。
刚选中候选后的 600ms 内,任何 onChange 都不搜 ——
不管 ArkUI 触发几次、值有没有微妙差异。 */
if (Date.now() < this.suppressUntil) {
FbDiag.log('GEO', 'onChange 在抑制窗口内,跳过 v=[' + v + ']');
return;
}
/* 🔴 2026-11-05:**新挂载时的那次 onChange 是初始化,不是用户输入**。
编辑浮层预填了地址 ⇒ 那一次 onChange 会去搜 ⇒ 候选框一进页面就弹出来。
⇒ 第一次直接吞掉(同时记下 lastApplied,防止后续误判)。
⚠️ 不能用"判 lastApplied 是否为空"代替:初始化时它本来就是空的,
与"用户刚开始打字"无法区分。只有"第几次"这个信息能区分。 */
if (!this.primed) {
this.primed = true;
this.lastApplied = v;
return;
}
/* 🔴 2026-11-03:**程序回写导致的重入**必须挡掉。
选中候选时 `pick()` 会 `onValue(t.name)` → 父组件改 `value`
→ TextInput 重渲染 → 又触发 onChange → 又搜一次 → 框重新弹出。
判据:值等于我刚提交的那个 ⇒ 是回写,不是用户打字 ⇒ 直接返回。
⚠️ 用完立刻清空 `lastApplied`:否则用户后续真的手动输入同一个字符串时
会被误判成"回写"而不搜索。 */
/* 🔴 #52:**trim 后比对**。
父组件回写时可能带首尾空格/全角差异,原串比对会失败
⇒ 判定成"用户输入" ⇒ 又搜一次 ⇒ 框重弹(用户:「这个并没有解决」)。 */
if (this.lastApplied !== '' && v.trim() === this.lastApplied.trim()) {
FbDiag.log('GEO', 'onChange 是程序回写,跳过搜索 v=[' + v + ']');
/* 🔴 #51(用户:「选中,又会弹出一次地址选择框,就是那个联想框,
这个问题在添加日程时就有」):
**不要在这里清 `lastApplied`**。
原来这里 `this.lastApplied = ''` —— 但选中候选时 ArkUI 可能触发
**不止一次** onChange(重渲染一次、光标定位一次),
第一次消费掉标记后,第二次就挡不住了 ⇒ 又搜一次 ⇒ 框重弹。
⇒ 标记留到"用户真的输入了别的值"时再清(见下面那条分支)。
这样无论回写触发几次 onChange,都会被同一个标记挡住。 */
return;
}
/* 走到这里说明 v 与 lastApplied 不同 ⇒ 是**用户真实输入** ⇒ 清掉标记 */
this.lastApplied = '';
this.onValue(v); // 值先给父组件(保存用的是它,不能等联想)
if (this.timer >= 0) {
clearTimeout(this.timer);
this.timer = -1;
}
const kw: string = v.trim();
FbDiag.log('GEO', 'onChange v=[' + v + '] kw.len=' + kw.length + ' MIN=' + FB_GEO_MIN_LEN);
/* 字太少 ⇒ 不查,并把已有候选收起来。
⚠️ 一定要收:用户已经把字删了,留着旧候选会被误点。 */
if (kw.length < FB_GEO_MIN_LEN) {
@@ -97,18 +201,73 @@ export struct FbAddrInput {
const my: number = this.seq;
/* ⚠️ 不再传 token:改用鸿蒙自带 Map Kit 的 `site.queryAutoComplete()`,
鉴权走 AGC 应用认证,与我们的 API 令牌无关(见 FbGeoRepo 文件头)。 */
FbDiag.log('GEO', 'search 开始 kw=[' + kw + ']');
const list: FbGeoTip[] = await fbGeoTips(kw, this.limit);
FbDiag.log('GEO', 'search 返回 ' + list.length + ' 条 seq=' + my + '/' + this.seq);
if (my !== this.seq) {
return; // 已经有一次更新的输入了,这份丢弃
FbDiag.log('GEO', '丢弃过期结果');
return;
}
this.tips = list;
this.open = list.length > 0;
FbDiag.log('GEO', '面板 open=' + this.open);
}
/**
* 把一条候选拼成**要存进输入框的值**。
*
* 🔴 2026-11-03 修(用户指出):「你放到输入框的地址,不是详细地址啊,
* 没有黄岛区珠江路这样的字样」。
*
* 原来只写 `t.name`(如「招商银行(青岛经济技术开发区支行)」),
* **没有省市区和门牌**。而这一栏的值会原样存进服务端 `location`:
* · 以后导航做地理编码时又只搜「招商银行」→ 全国几百个 → 可能导到外省;
* · 等于把我们今晚修好的"详细地址"问题绕回了原点。
*
* ⇒ 照网页端 `addr_suggest.js` 第 566 行的格式:
* name + ' · ' + detail
* 「招商银行(青岛经济技术开发区支行) · 青岛市黄岛区珠江路1018号」
*
* ⚠️ 分隔符必须与网页端**完全一致**(空格+中点+空格):
* 两端存进库的字符串要同形,否则服务端去重判断和用户观感都会不一致。
*
* ⚠️ `detail` 已含「省市区 + 门牌」(服务端 `Geo::suggest` 拼好的
* `district + address`),这里直接拼,不要二次组合。
*
* ⚠️ 兜底:个别条目 detail 为空(纯 POI 无名址)⇒ 只写名字,
* 不留下「名字 · 」这种尾巴。
*/
private valueOfTip(t: FbGeoTip): string {
const nm: string = t.name.trim();
const dt: string = t.detail.trim();
if (nm === '') {
return dt;
}
if (dt === '' || dt === nm) {
return nm;
}
return nm + ' · ' + dt;
}
/** 选中一条候选 */
private pick(t: FbGeoTip): void {
this.onValue(t.name);
const v: string = this.valueOfTip(t);
/* ⚠️ 顺序很重要:**先记下要写入的值,再 onValue**。
反过来的话父组件同步改 value 会立刻触发 onChange,
那一刻 lastApplied 还是空的 ⇒ 挡不住 ⇒ 又弹一次。 */
/* #52:存 trim 后的值,与 onType 里的 trim 比对保持同一口径 */
/* #53:开启抑制窗口(在 onValue **之前**设,
因为 onValue 会立刻触发父组件回写 → onChange,必须先挡住) */
this.suppressUntil = Date.now() + FbAddrInput.SUPPRESS_MS;
this.lastApplied = v.trim();
this.onValue(v);
/* #51:把结构化数据也交出去(地名/详细地址/经纬度分开)。
⚠️ 放在 onValue 之后:先用文字更新输入框(用户立刻看到),
再让父组件收坐标 —— 顺序反了会出现"输入框还空着但坐标已存"的瞬间。 */
this.onPick(t);
this.close();
FbDiag.log('GEO', '已选中并写入: [' + v + ']');
}
build() {
@@ -128,52 +287,97 @@ export struct FbAddrInput {
.onChange((v: string): void => {
this.onType(v);
})
/* 🔴🔴 2026-11-03 改:候选面板从「布局流内联」改成 **bindPopup 浮层**。
用户原话:「下拉框在下面,完全被输入法遮挡」。
照 SyncCalendar 的 `EventEditPage.ets` 写法。
/* 候选面板:只在有候选时占位(用 if 而不是 visibility,
否则空列表也会把下面的字段撑开一块空白)。 */
if (this.open && this.tips.length > 0) {
Column() {
ForEach(this.tips, (t: FbGeoTip, idx: number): void => {
Column({ space: 2 }) {
/* 第一行:地名(用户认的是名字) */
Text(t.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
/* 第二行:详细地址 —— **这就是用户要的"参考"**。
高德对纯行政区划条目不给 address,那时 detail 就是省市区本身;
万一都空就整行不画(不要留一条空灰线)。 */
if (t.detail !== '') {
Text(t.detail)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
为什么 inline Column 不行:
· 它在布局流里 ⇒ 输入法弹起时被顶住/被盖住;
· 候选出现/消失会改变输入框位置 ⇒ 输入框上下跳(很难受)。
bindPopup 的三个关键参数:
· `placement: Placement.Top` —— 向**上**弹,躲开输入法;
· `focusable: false` —— **不抢焦点**,否则输入法会收起、体验断裂;
· `autoCancel: true` —— 点外部关闭(符合下拉的直觉)。 */
.bindPopup(this.open, {
builder: (): void => {
this.tipPanel()
},
/* 🔴 #52(用户:「有时候弹出的框会在输入框下面。我们之前定的是在输入框
上面弹出来,不然如果在下面弹出来,会被输入键盘挡住。」)
`Placement.Top` 只是**偏好**:上方空间不足时系统会自动翻到下方。
真正能控制的是**让它在上面放得下** ⇒ 候选列表限高 + 内部滚动
(见 tipPanel 的 constraintSize)。 */
placement: Placement.Top,
popupColor: FbColors.surface,
enableArrow: false,
showInSubWindow: false,
focusable: false,
autoCancel: true,
radius: FbMetric.radiusS,
onStateChange: (e: PopupStateChangeParam): void => {
/* 用户点浮层外部 / 系统收起时同步状态,
否则 `open` 还是 true,下次想弹会弹不出来。 */
if (!e.isVisible && this.open) {
this.open = false;
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.pick(t);
})
}, (t: FbGeoTip, idx: number): string => t.name + '|' + t.detail + '|' + idx.toString())
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
.border({ width: 1, color: FbColors.line })
/* 最多约 5 条高度,多了内部滚动 —— 不把整张表单顶下去 */
.constraintSize({ maxHeight: 260 })
.clip(true)
}
}
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
/**
* 候选浮层的内容(bindPopup 的 builder)。
*
* ⚠️ 必须是 `@Builder` —— `bindPopup` 的 builder 签名就要求 void 返回。
* 这里读的是 `@Local tips`,V2 下状态变化会正常触发重建
* (与文件头说的"@Builder 值类型参数不参与观察"不冲突:
* 那个坑是**按值传参**,这里是直接读组件状态)。
*/
@Builder
tipPanel() {
Column() {
ForEach(this.tips, (t: FbGeoTip, idx: number): void => {
Column({ space: 2 }) {
/* 第一行:地名(用户认的是名字) */
Text(t.name)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
/* #52:限高,让候选在上方也放得下,不被输入法挡住 */
.constraintSize({ maxHeight: 180 })
.width('100%')
.textAlign(TextAlign.Start)
/* 第二行:详细地址 —— **这就是用户要的"参考"**。
高德对纯行政区划条目不给 address,那时 detail 就是省市区本身;
万一都空就整行不画(不要留一条空灰线)。 */
if (t.detail !== '') {
Text(t.detail)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.textAlign(TextAlign.Start)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.onClick((): void => {
this.pick(t);
})
}, (t: FbGeoTip, idx: number): string => t.name + '|' + t.detail + '|' + idx.toString())
}
.width('100%')
.backgroundColor(FbColors.surface)
.borderRadius(FbMetric.radiusS)
/* 最多约 5 条高度,多了内部滚动 —— 浮层不该占满整屏 */
.constraintSize({ maxHeight: 260 })
.clip(true)
}
}
@@ -531,10 +531,17 @@ export struct FbMovieNewSheet {
.width('100%')
.padding({ top: 6, bottom: 4 })
}
.flexShrink(1)
/* 🔴 #55(用户:「"记一场电影"这种输入,输入框怎么上下划不动?」)
根因:`Scroll` 在 `Column` 里只写了 `flexShrink(1)` ——
那只是"允许被压缩",**不等于"占满剩余高度"**。
没有 `layoutWeight(1)` 时,Scroll 的高度 = 内容高度
(超出父级后被裁切)⇒ 自身没有可滚动空间 ⇒ **划不动**。
⇒ 必须给它 `layoutWeight(1)` 才能拿到父级剩下的高度。 */
.layoutWeight(1)
.width('100%')
.scrollBar(BarState.Off)
.align(Alignment.TopStart)
.edgeEffect(EdgeEffect.Spring)
// ---- 结果提示 ----
if (this.note2 !== '') {
@@ -320,20 +320,54 @@ export struct FbPersonEditSheet {
// 小件
// ============================================================
/** 一个输入行 */
/**
* 一个输入行(**两排版式**,全局规范 —— 用户 2026-11-05)。
*
* 🔴 「点击名册里面的"添加",姓名之类的内容全部都按这种形式显示。
* 它会按照主题色,加粗大一点显示。**所有地方都需要这样改**。」
* 🔴 「为了解决字数原因导致输入框左右来回对不齐的问题,我们全部按两排来显示:
* 第一排显示片名…下一行开始是片名的输入框…这样一列下来就行了。」
*
* ## 规范
*
* | 项 | 规则 |
* |---|---|
* | 标签颜色 | **主题色** `this.accent` |
* | 标签字重 | **Bold** |
* | 标签字号 | `fontSmall`(比正文大一号) |
* | 标签位置 | **独占第一排**(不是和输入框同行) |
* | 输入框 | 第二排,整宽 |
* | 必填 | **红色**,**无星号** |
*
* ## 为什么两排能解决对齐
*
* 原来同行时标签宽度由字数决定(「姓名」2 字 vs「我对他的称呼」6 字),
* 输入框起点跟着变 ⇒ 整列左右错开。标签独占一行后,
* 所有输入框都从同一左边距开始 ⇒ **天然对齐**,与标签字数无关。
*
* ⚠️ `hint` 参数保留(它是 placeholder),另加 `req` 表示必填。
* 原来用 `hint='必填'` 冒充必填标记 —— 那只会在输入框里显示灰字,
* 既不是红色也不在标签上,用户看不出哪个字段是必填。
*/
@Builder
field(label: string, value: string, hint: string, onChange: (v: string) => void) {
Column({ space: 4 }) {
field(label: string, value: string, hint: string, onChange: (v: string) => void,
req: boolean = false) {
Column({ space: 6 }) {
/* 第一排:标签(主题色 / 必填红,加粗,独占一行) */
Text(label)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontWeight(FontWeight.Bold)
.fontColor(req ? '#C0533F' : this.accent)
.width('100%')
/* 第二排:输入框(整宽,与标签左边缘对齐) */
TextInput({ text: value, placeholder: hint })
.placeholderColor(FbColors.faint)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.backgroundColor(FbColors.inputBg)
.borderRadius(FbMetric.radiusS)
.height(40)
.height(42)
.width('100%')
.padding({ left: 12, right: 12 })
.onChange((v: string): void => {
onChange(v);
@@ -765,7 +799,7 @@ export struct FbPersonEditSheet {
Scroll() {
Column({ space: 12 }) {
this.section('基本')
this.field('姓名', this.fName, '必填', (v: string): void => {
this.field('姓名', this.fName, '', (v: string): void => {
this.fName = v;
})
// 🔴 「我怎么称呼他」—— 只有我看得到(视角属性),与姓名分开
@@ -18,7 +18,9 @@ import { FbSheetClose } from './FbSheetClose';
import { FbSheetRing } from './FbSheetRing';
import { FbDateTimeRow, FbDateTimePicker, fbIsoOf, fbJoinDateTime } from './FbDateTimeField';
import { FB_TOKEN_STORE } from '../data/FbTokenStore';
import { createTask, updateTask, FbTaskDraft } from '../data/FbTaskRepo';
import { createTask, updateTask, FbTaskDraft,
setTaskStatus
} from '../data/FbTaskRepo';
import { FbApiResult } from '../data/FbHttp';
@ComponentV2
@@ -38,6 +40,13 @@ export struct FbTaskEditSheet {
/** v79:分类 / 循环(编辑时回填) */
@Param editingCatId: number = 0;
@Param editingRepeat: string = '';
/**
* 编辑时的原状态(`todo` / `doing` / `done`)。
*
* 🔴 2026-11-04:没有它的话,编辑一条**已完成**的任务会显示成"待办"(默认值),
* 保存时又把 'todo' 发出去 ⇒ **静默把已完成改回待办**。比不做还糟。
*/
@Param editingStatus: string = 'todo';
@Event onClose: () => void = (): void => {};
@Event onSaved: () => void = (): void => {};
@@ -47,6 +56,18 @@ export struct FbTaskEditSheet {
@Local boardId: number = 0;
/** v79:分类 id(0 = 不分类) */
@Local catId: number = 0;
/**
* 任务状态:`todo`(待办)/ `doing`(进行中)/ `done`(已完成)。
*
* 🔴 2026-11-04 新增。用户指出:「新建任务这个功能里面,怎么没有选择是待办、
* 正在进行,还是已完成的地方呢?」—— 原来 `save()` 里写死 `'todo'`,
* 用户建"进行中"的任务只能先建成待办再回去拖看板。
*
* ⚠️ 服务端 `Task::STATUS` 就是这三个值,别自造。
*/
@Local status: string = 'todo';
/** 编辑时记住原状态 —— 用于判断"用户改没改",改过才额外打 /status */
@Local status0: string = 'todo';
/** v79:循环规则('' = 不循环) */
@Local repeat: string = '';
/** 截止日期 `YYYY-MM-DD`,空 = 没有期限 */
@@ -63,6 +84,9 @@ export struct FbTaskEditSheet {
this.boardId = this.editingBoardId;
this.catId = this.editingCatId;
this.repeat = this.editingRepeat;
// 状态回填:新建时 editingStatus 是默认 'todo',编辑时是这条的原状态
this.status = this.editingStatus !== '' ? this.editingStatus : 'todo';
this.status0 = this.status;
// `2026-10-25 18:00` → 日期 + 时分(服务端给的是空格分隔)
const raw: string = this.editingDueAt.trim();
if (raw.length >= 16) {
@@ -187,6 +211,24 @@ export struct FbTaskEditSheet {
.width('100%')
.alignItems(HorizontalAlign.Start)
/* ---- 状态(2026-11-04 新增)----
🔴 用户指出「怎么没有选择是待办、正在进行,还是已完成的地方」。
放在「看板」之前:状态是任务最基本的属性,
而看板是"归到哪一类",后者依赖前者。 */
Column({ space: 6 }) {
Text('状态')
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
Row({ space: 8 }) {
this.statusChip('todo', '待做')
this.statusChip('doing', '进行中')
this.statusChip('done', '已完成')
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// ---- 看板 ----
Column({ space: 6 }) {
Text('看板')
@@ -332,7 +374,9 @@ export struct FbTaskEditSheet {
// 没有期限就发空串(服务端 `Api::dt('due_at', '')` → null)
dueAt: this.dueDate !== '' ? fbJoinDateTime(this.dueDate, this.dueTime) : '',
boardId: this.boardId,
status: 'todo',
/* 🔴 2026-11-04:不再写死 'todo' —— 用户可以在表单里选状态。
⚠️ 新建走 `createTask()`,服务端 POST 是**认** status 的(见 FbTaskRepo 文件头)。 */
status: this.status,
/* 0 = 顶层任务(新建子任务走 `FbTaskPanel.addKid`,不在这里) */
parentId: 0,
/* -1 = 这次不改负责人:表单里没有这一项,传 0 会把已指派的负责人清空 */
@@ -344,11 +388,29 @@ export struct FbTaskEditSheet {
const res: FbApiResult = this.editingId > 0
? await updateTask(token, this.editingId, d)
: await createTask(token, d);
this.saving = false;
if (!res.ok) {
this.saving = false;
this.note2 = res.error !== '' ? res.error : '保存失败,请稍后再试。';
return;
}
/* 🔴 2026-11-04:编辑时若改了状态,**必须额外打 `/status`**。
原因(见 FbTaskRepo 文件头):服务端 `PATCH /tasks/<id>` 会**显式忽略**
请求里的 status(原样回填),只有 `POST /tasks/<id>/status` 才生效。
⇒ 不补这一下,用户改了状态、界面说"保存成功"、回去看还是原状态。
⚠️ 只在"改过"时才打:没改就多一次无谓请求(还会白刷一次服务端 update)。
⚠️ 新建不用补:`POST /tasks` 是认 status 的。 */
if (this.editingId > 0 && this.status !== this.status0) {
const st: FbApiResult = await setTaskStatus(token, this.editingId, this.status);
if (!st.ok) {
this.saving = false;
// 内容已存好,只有状态没改成功 —— 说清楚,别让用户以为整条都没保存
this.note2 = '内容已保存,但状态没改成功,请再试一次。';
return;
}
}
this.saving = false;
this.onSaved();
}
@@ -367,6 +429,26 @@ export struct FbTaskEditSheet {
}
/** 分类单选胶囊(v79) */
@Builder
/**
* 状态 chip。
*
* ⚠️ 用 `!==` 而不是 `===` 判断"未选中":`status` 理论上只会是三个合法值,
* 但万一服务端返回了别的(脏数据),不能三个都不亮 —— 那样用户会以为控件坏了。
*/
@Builder
statusChip(v: string, name: string) {
Text(name)
.fontSize(FbMetric.fontTiny)
.fontColor(this.status === v ? '#FFFFFF' : FbColors.muted)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.status === v ? this.accent : FbColors.inputBg)
.borderRadius(FbMetric.radiusPill)
.onClick((): void => {
this.status = v;
})
}
@Builder
catChip(id: number, name: string) {
Text(name)
@@ -329,7 +329,7 @@ export struct FbTaskPanel {
if (w === 'done') {
return '已完成';
}
return w === 'doing' ? '进行中' : '待办';
return w === 'doing' ? '进行中' : '待做';
}
/**
File diff suppressed because it is too large Load Diff