端上:#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:
@@ -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
Reference in New Issue
Block a user